🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
23 KiB
Controller backup architecture
Source of truth: felhom-controller internal/appbackup/, internal/backup/, internal/quiesce/ at v0.59.0, updated for the v0.99.0 restore-path fixes (F1/F3/O4 — TASK C1); whole-guest backup is the agent's (felhom-agent). Line numbers are v0.59.0-era landmarks — re-grep the symbol.
This document describes what the in-guest controller actually does for backup and restore at v0.59.0. The controller was de-privileged in slice 8C: restic, cross-drive-to-arbitrary-disks execution, drive-recovery and infra-backup were removed from the controller and now live in the host agent + PBS. The restic string still appears in the backup package only inside comments that record its removal (backup.go:20, restore.go:14, restore.go:19); there is no restic execution in the controller.
1. The split: controller vs agent/PBS
The controller owns the app-data domain only. The agent owns the whole-LXC domain.
| Domain | Owner | What |
|---|---|---|
| Per-app DB dumps | controller | pg_dump / mariadb-dump via docker exec |
| Per-app Docker-volume tars | controller | docker run alpine tar of named volumes |
| Per-app secret-free recovery unit | controller | compose + dumps + manifest.json on the app's drive |
| Tier-2 off-drive copy | controller | rsync mirror of an HDD app's unit + userdata to a different physical disk |
| Whole-LXC vzdump snapshot | agent | POST /backup, crash- or stop-consistent vzdump to local / PBS |
| Offsite / encrypted backup, verify, restore-test | agent + PBS | reported read-only to the controller |
| Whole-guest restore (rootfs, secrets, keys) | agent + PBS | the controller refuses any restore that needs this |
The boundary is precise: app data that lives on an external user-data drive (an HDD bind mount) is NOT inside the PBS whole-guest snapshot — PBS cannot reach bind mounts. That data is protected only by the controller's recovery unit + Tier-2 copy. App data that lives on the rootfs (non-HDD apps) IS inside the PBS whole-guest snapshot, so it gets no Tier-2 copy (tier2.go:197-218). The encrypted app.yaml and the controller's encryption key live on the rootfs, so they are inside PBS too — which is exactly why the restore path recovers secrets from the guest rather than storing them off-rootfs (see §3, §4).
The agent surface the controller talks to is internal/agentapi/client.go: a TLS client pinning the agent's self-signed leaf by SHA-256 (client.go:69-82) and authenticating with a per-guest bearer token. Backup-relevant calls: BackupDue (GET /backup/due), StartBackup (POST /backup), BackupStatus (GET /backup/status), RestoreTestStatus (GET /restore-test/status). The whole-guest backup record (BackupRecord) and restore-test record are rendered read-only — the comment at client.go:135-136 states the controller does not own whole-guest backup.
2. Database dumps (internal/appbackup/dbdump.go)
Discovery is docker ps-driven. DiscoverDatabases (dbdump.go:66) runs docker ps --format {{.ID}}\t{{.Names}}\t{{.Image}} --filter status=running and classifies each container by image substring: postgres → Postgres, mariadb/mysql → MariaDB; everything else is skipped. The stack name is derived by stripping a known DB suffix from the container name (deriveStackName, dbdump.go:536 — postgres/db/mariadb/mysql/database/redis/cache). Connection details come from the container's own env (populateDBEnv, dbdump.go:477): POSTGRES_USER/POSTGRES_DB (defaults postgres), or MYSQL_DATABASE/MARIADB_DATABASE with root.
Per-DB dump is DumpOne (dbdump.go:162), one container at a time, 5-minute timeout each. It re-checks the container is still running, then:
- Postgres:
docker exec <id> pg_dump -U <user> -d <db> --clean --if-exists --no-owner --no-privileges(dbdump.go:202). - MariaDB:
docker exec <id> mariadb-dump -u root -p<pw> --single-transaction --routines --triggers <db>(dbdump.go:228). The root password is read from the container env (MYSQL_ROOT_PASSWORD/MARIADB_ROOT_PASSWORD,dbdump.go:516); the password is never logged (a redacted-p***form is built only for the debug log line).
The tmpfile safety (H8). The dump streams to <name>.sql.tmp, then before the rename to the final .sql: tmpFile.Sync() then tmpFile.Close() are called explicitly, each removing the tmp and failing the dump on error (dbdump.go:266-278). Only after a non-empty stat (dbdump.go:281) is the tmp atomically os.Renamed to the final path (dbdump.go:293). This guarantees the data is flushed to disk before the rename makes the dump "visible", so a crash never leaves a half-written .sql. Stale .tmp files older than one hour are reaped at the start of each run (cleanupTmpFiles, dbdump.go:553). Each finished dump is structurally validated (ValidateDump, dbdump.go:320): header line + at least one CREATE TABLE, scanned line-by-line with a bounded bufio.Reader so large data lines do not allocate (H1).
Where dumps are stored. Manager.GetAppDrivePath(stack) (backup.go:88) returns the app's HDD_PATH if it has one, else falls back to the configured systemDataPath (the internal SSD) for SSD-only apps. That drive path is mapped to its felhom-data namespace root by namespaceRoot (backup.go:105 — Model A: an in-guest drive mount IS the namespace root, so it is used as-is; only the system-data fallback gets the felhom-data subdir appended). Dumps land in AppDBDumpPath(nsRoot, stack) = <nsRoot>/backups/primary/<stack>/db-dumps/ (paths.go:58). The SSD-only fallback is the case that the C3 DR finding was about — and it is handled here: SSD-only apps get a correct path via systemDataPath, so there is no path gap. (The fallback only warns when systemDataPath itself is unconfigured, backup.go:75/backup.go:94.)
RunDBDumps (backup.go:143) acquires the running flag, discovers, dumps each DB to its app's path (skipping drives marked disconnected/decommissioned), persists each validation result to settings.json, then — v0.99.0 (F3) — runs the named-volume dumps (runVolumeDumps) and finally refreshes every recovery unit (captureAllRecoveryUnits) — even on partial failure, so units never go stale.
Volume dumps are real again (F3, v0.99.0). After the restic removal, DumpAppVolumesSafe had no caller — no trigger ever produced volume-dumps/, so named-volume app data was never captured (drill finding F3). runVolumeDumps now runs inside the same nightly/manual backup run, per deployed stack: skip if protected (IsProtectedStack), skip if it has no named volumes (this check deliberately precedes DumpAppVolumesSafe, which stops the stack before its own check), skip disconnected/decommissioned drives, else stop → tar each volume (docker run alpine tar) → restart. It runs BEFORE the unit capture so manifest.VolumeDumps enumerates the fresh tars. A per-stack failure lands in the run summary (FAIL <app> volumes:), flips the run's Success flag and fails the run — no silent partials. Note the operational consequence: volume-bearing apps are briefly stopped during each nightly backup (the locked stop-first policy — a live tar of a database volume would be torn).
Atomic volume dumps (F7, v0.118.0, CAMPAIGN-3). The volume dump now has the SAME crash-safety the DB dump has always had: DumpAppVolumes writes the tar to <vol>.tar.tmp (docker run … tar cf …/<vol>.tar.tmp), then atomicPromoteTar fsyncs the tmp (and best-effort the dir) and os.Renames it over the final .tar only on success. Before this, tar wrote the .tar IN PLACE, so a mid-write NFS cut left a 0-byte tar replacing the last good dump — and a tier-1 restore is replace-semantics, so the only "restore point" then restored an empty volume (the exact CAMPAIGN-3 F7 failure: a exportfs -u during a dump truncated a 247 MB calibre tar to 0 bytes). Now any tar error / timeout / dead-NFS EIO removes only the .tmp; the last good .tar is byte-untouched. The .tar.tmp name ends .tmp (not .tar), so it is invisible to the restore-point and stale scans; orphan .tar.tmp from a killed run is swept on the next dump. Live-proven 2026-07-12: an exportfs -u mid-volume-dump left all NAS volume tars byte-identical (sha unchanged), no 0-byte file, run success:false; the next run produced fresh good tars.
Stale-primary sweep (F5, v0.118.0). After the units are refreshed, pruneStalePrimaryDirs removes an orphaned backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved (invisible disk residue). Guards: removes only when the app is deployed AND its current namespace root differs from the dir's drive; never the current-drive dir (the live restore point) or an undeployed app's dir; strictly under a backups/primary/ prefix.
3. Recovery units (internal/backup/recovery_unit.go)
A recovery unit is a per-app, secret-free, self-contained directory at <nsRoot>/backups/primary/<app>/ (paths.go:42). It contains:
compose/—docker-compose.yml+.felhom.yml+ a secret-strippedapp.yaml.db-dumps/— the.sqldumps from §2.volume-dumps/— named-volume.tararchives.manifest.json— theRecoveryManifest(recovery_unit.go:31).
What the unit excludes: it holds no secret values, no data-encrypting keys, and not the Docker image. The manifest stores only the pinned image tag(s) (ImagePins — re-pulled on restore), the names of the secret env vars (SecretEnvVars), and the names of the data-key env vars (DataKeyEnvVars); SecretSource records in plain text that the values come from "guest app.yaml (live rootfs) or PBS whole-guest snapshot — never stored in this unit" (recovery_unit.go:141). The stripped app.yaml carries only non-secret env, with a header naming the omitted secrets (buildStrippedAppYaml, recovery_unit.go:188).
CaptureRecoveryUnit (recovery_unit.go:68) pulls the app's RecoveryInfo from the stack provider (GetStackRecoveryInfo), builds the captured content in memory, and is idempotent: it skips all drive writes when the existing manifest matches the current controller version, config checksums (sha256 of each captured file), and the DB/volume dump set (recovery_unit.go:112-118). This is what lets it run on the 5-minute status refresh without thrashing a spinning USB drive. Writes are atomic (atomicWrite, recovery_unit.go:270 — tmp + rename). DataKeyEnvVars is a fail-closed restore annotation only (see §4); it does not affect capture.
The .fab portable export/import path (internal/appexport/) is a separate, operator-driven mechanism documented elsewhere — cross-reference that doc; it is not the periodic recovery unit.
4. Restore
Two keep-side restore entry points exist; neither does a whole-guest restore (that is the agent's).
RestoreApp(stack, snapshotID) (restore.go:21) — the legacy volume-only path. Stops the stack, re-imports the named-volume .tar dumps (restoreDockerVolumes, restore.go:85: docker volume rm -f + create + docker run alpine tar xf), restarts, and health-checks. snapshotID is retained only for signature/logging compatibility now that restic is gone (restore.go:19-20).
RestoreFromRecoveryUnit(stack) (restore_unit.go:74) — the recovery-unit path. It reads the unit manifest (falling back to RestoreApp if no unit exists, restore_unit.go:99), recovers the secret values from the guest's own live app.yaml via stackProvider.RecoverStackSecrets (never from the unit), reconciles them, restores the named-volume data, then RecreateStackFromUnit rebuilds the app's definition from compose/ and redeploys with the reconstructed env (re-pulling the pinned image). Nothing is regenerated; no secret is read from the unit.
The fail-closed data-key gate is reconcileRestoreSecrets (restore_unit.go:22) — a pure, unit-tested function. It merges non-secret env with recovered secrets, then:
- A missing resettable secret (DB password, admin password) is non-fatal: returned in
missing. v0.99.0 (O4): the caller now generates a replacement from the field's cataloggeneratespec (stacks.GenerateSecretForFieldvia thebackup.SetSecretGeneratorseam) instead of proceeding with the secret blank — pre-O4, the redeploy failed compose-up with "Defaulting to a blank string". The generated value ridesfullEnvintoRecreateStackFromUnit→SaveAppConfig, so it persists encrypted in the guestapp.yamland round-trips on later backups. Fields with nogeneratespec still proceed, with a WARN that the app may fail to start. Generation NEVER applies to data-keys (the gate below refuses first, and the generator itself rejectsdata_keyfields). Residual case (honesty note): generation fully fixes the fresh-init path (empty DB volume → DB initialises with the new credential → dump replay restores the data). If a restored volume tar carries the OLD internal credential hash, the app may still fail auth until a manual in-DB credential reset — generation does not cover that. - A missing data-encrypting key (
DataKeyEnvVars) is fatal: the restore is refused with an explicit error directing the operator to do a PBS whole-guest restore first, because regenerating the key would render the stored data unreadable (restore_unit.go:45-50). This is the safety centerpiece: the controller never silently recreates an app whose data it can no longer decrypt.
The restore panel's data source (F1, v0.99.0). GET /api/backup/snapshots?stack=<app> is backed by backup.Manager.ListRestorePoints (internal/backup/restore_points.go). The route was a restic-era leftover the backups.html template still fetched; it was never registered, so the snapshot dropdown could never populate and the restore button never enabled (drill finding F1 — the whole keep-side restore UI was dead). It returns at most ONE entry — the current recovery unit (time = newest artifact mtime among manifest/db-dumps/volume-dumps, short_id:"helyi", tier:1, drive_label from the storage registry). Tier-2 entries are never emitted: POST /backup/restore only reads the primary unit, so a tier-2 listing would silently restore tier-1 data while claiming tier-2. Guards: invalid/empty stack name → 400, unknown stack → 404, no unit yet → ok:true, data:[].
Class-C in-place file restore (C2, v0.100.0 — closes drill finding F2). HDD bind-mount user files (appdata/<stack>) are outside both keep-side restore paths above (the drill's expected-negative). The customer path is now POST /backup/tier2/restore → RestoreTier2Files (internal/backup/tier2_restore.go), surfaced as the "Fájlok visszaállítása" button on the Tier-2 row. Semantics are deliberately additive-only (rsync -a --ignore-existing — rsyncRestoreMissing, the exec shape of rsyncMirror with the opposite-direction flags):
- a file missing live is copied back from the recorded Tier-2 copy (attrs preserved);
- an existing live file is never overwritten — a customer edit after the last nightly copy always wins;
- nothing is ever deleted —
rsyncMirror's--deletein this direction would erase every file created since the last copy, which is why that helper must never be used tier2→live.
The source is the RECORDED CrossDriveBackup.DestinationPath (never a fresh selectTier2Target, which could re-pick an empty drive). Refusals — no Tier-2 copy / never ran / copy dir gone / either drive disconnected / live drive decommissioned — happen before the app is stopped, with customer-readable Hungarian reasons; the flow is stop → copy → restart → health-wait, single-flight with backup/restore. Zero files copied is a success ("Nincs hiányzó fájl — minden fájl megvan a helyén."), and re-running the button is idempotent.
What it deliberately does NOT do: no overwrite/point-in-time restore (corruption rollback stays with the offbox restore-to-verify + operator paths), no per-file selection, and it never touches recovery-unit/ under the Tier-2 dir (backup artifacts are not user files). App-reindex caveat: the restore is filesystem-level — apps that index their data dir (e.g. Nextcloud → occ files:scan) may need a rescan before restored files appear in the app's own UI.
4b. Security note — .fab import path validation (CTRL-001, v0.59.0)
The portable .fab import (internal/appexport/) validates every manifest path segment before it reaches a filepath.Join against a trusted base (appexport.ValidateSegment, validate.go:28; validateManifestPaths, validate.go:51, called from UnmarshalManifest). The attacker-controllable AppName / HDDSubdirs / VolumeNames are rejected on any .., path separator, or absolute path, closing the v0.59.0 path-traversal finding. This is cross-referenced here; the detail lives in the appexport doc and the v0.59.0 audit record.
5. Tier-2 cross-drive (internal/backup/tier2.go)
Tier 2 is the only off-drive protection browsable HDD userdata can get (PBS cannot reach bind mounts, tier2.go:17-23). It is an rsync -a --delete mirror (rsyncMirror, tier2.go:358) of an HDD app's recovery unit + bulk appdata/ to <target>/backups/secondary/<app>/{recovery-unit,appdata}/ on a different physical disk.
No single-copy backups (F6, v0.118.0, CAMPAIGN-3). RunAllTier2 used to continue on every non-HDD app, so a volume-only app (no HDD_PATH, its dumps on sys_drive) got NO tier-2 copy — a single controller-level copy on one device. It now flows through too: its recovery unit (holding the db/volume dumps) gets the cross-drive second copy like any HDD app (live-proven 2026-07-12: actualbudget/seerr appear under felhom-usb/backups/secondary/). A sys_drive app's restore-point drive label is now the clear "Belső SSD (rendszer)", never blank.
3-2-1 honesty on a single-drive box (F6). When NO off-drive target exists (Manager.hasOffDriveTarget false — only the system drive, no enrolled second disk), there is genuinely only ONE local copy. FullBackupStatus.SingleCopyWarning surfaces an honest Hungarian banner on the backup page ("Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót.") rather than implying a 3-2-1 guarantee the box cannot keep.
NAS backup locality (Part 4 — decision A, v0.118.0). A NAS-resident app's tier-1 dumps live on the NAS itself (nas-media/backups/primary/<app>), beside the data. During a NAS outage both the app data AND its freshest tier-1 dump are on the dead device — the tier-2 cross-drive copy to a local drive is the off-NAS leg that saves them, and only after it has run. This locality was kept deliberately in the operator fork (over retargeting tier-1 to a local drive, which would have moved restore-point resolution off the drive); the tier-2 copy is the mitigation. Documented here so the outage window is never a surprise.
Auto-target selection (selectTier2Target, tier2.go:54), in order:
- A customer-pinned target (
PreferredTargetfrom the config panel) if it is still registered, schedulable, and off-disk (tier2.go:62-83). - Another registered user-data drive on a different physical disk — can hold bulk userdata (
tier2.go:86-102). - The internal SSD (system data path) — small units only, headroom-guarded.
Off-disk-ness is decided by system.SamePhysicalDevice (a Stat_t.Dev compare). If the only candidate is the same physical disk, errNoOffDiskTarget is returned.
The rootfs-headroom guard is the key safety. The internal SSD is the ~8 GB guest rootfs, so option 3 refuses rather than fills: tier2FitsSystemDrive (tier2.go:121) → tier2FitsHeadroom (tier2.go:43) requires the copy to leave a reserve of max(2 GB, 20% of total) free, else returns errSSDNoHeadroom. A non-fitting or single-drive case is recorded as an honest no_target status (recordTier2NoTarget, tier2.go:331) with a Hungarian "needs a 2nd HDD" reason — the rootfs is never filled. When the SSD target is used, it is labelled DB/config-only (tier2.go:116, log suffix [SSD: DB/config only]).
RunAllTier2 (tier2.go:199) iterates deployed stacks, processes only those with an HDD_PATH (non-HDD apps are skipped — they are already in PBS), and skips disconnected/decommissioned drives. Status is persisted into settings.CrossDriveBackup (method rsync), with the customer-preference fields (UserDisabled, PreferredTarget) preserved across runner writes by withTier2Prefs (tier2.go:290). The config-panel view is Tier2Info (tier2.go:245, read-only).
6. Concurrency, scheduling, and the quiesce loop
Single-flight / running mutex. Manager guards a running flag with a mutex; acquireRunning/releaseRunning (backup.go:373) reject a second backup or restore with "already in progress". RestoreApp and RestoreFromRecoveryUnit take the same flag (restore.go:31, restore_unit.go:79).
Scheduling (wired in cmd/controller/main.go, Europe/Budapest):
db-dumpdaily atcfg.Backup.DBDumpSchedule(default02:30,config.go:271) →RunDBDumps(which also refreshes recovery units) (main.go:328).tier2-backupdaily at03:30→RunAllTier2(main.go:360).RefreshCacheruns on the 5-minute status refresh, re-scanning dump files and (idempotently) re-capturing recovery units (backup.go:477).
Manual triggers exist via the API router (RunDBDumps, RunAllTier2 launched as goroutines, router.go:764/781).
The quiesce loop (internal/quiesce/quiesce.go) drives the whole-guest backup app-consistently. The agent's vzdump is crash-consistent only (an LXC has no fsfreeze), so the controller stops the app stacks first. Loop.Run polls GET /backup/due; when due, quiesceAndPoll (quiesce.go:205):
- Writes a persisted marker (atomic,
0600) listing the stacks it is about to stop — before stopping anything (quiesce.go:207). - Stops the running app stacks.
POST /backup, records the job id, pollsGET /backup/status.- Resumes early at the
snapshottedphase (8B.2 downtime optimization — the storage snapshot has captured the stopped state, so the app may come back up; the loop keeps polling todone/failed), or atdonein stop/downgraded mode (quiesce.go:256-279).
Unquiesce is guaranteed: a deferred closure restarts exactly the stopped stacks and clears the marker on every exit path — backup error, status-poll error, the MaxQuiesce bound (default 30 min, restarts the app while the backup continues on the agent), or context cancellation (quiesce.go:213-225, quiesce.go:243-249). On startup, Recover (quiesce.go:113) restarts any stacks left stopped by a mid-quiesce crash, then clears the marker. Single-flight is enforced both within the process (a TryLock mutex shared by the scheduled loop and the manual TriggerNow, quiesce.go:149/quiesce.go:181) and across restarts (the active marker — a cycle refuses to start on top of one, quiesce.go:157). TriggerNow runs the same flow asynchronously for the manual "Mentés most" action, returning ErrBackupInProgress if a cycle is already running.
Restic is gone from the controller. All disk-tier backup (restic snapshots, cross-drive-to-other-disks, drive recovery, infra backup) is the agent's; the controller's only remaining off-drive copy is the Tier-2 rsync mirror in §5.