From 02daebe9c507b73ba498a20a97052c211a3f759b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 25 Oct 2026 09:27:48 +0100 Subject: [PATCH] =?UTF-8?q?D47=20/=20R-1269=20(P2):=20a=20new=20install=20?= =?UTF-8?q?never=20writes=20into=20an=20earlier=20install's=20backup=20?= =?UTF-8?q?=E2=80=94=20every=20install=20has=20an=20identity=20(install=5F?= =?UTF-8?q?id),=20the=20unit=20records=20it,=20every=20unit=20writer=20ask?= =?UTF-8?q?s=20first;=20another=20install's=20unit=20is=20set=20aside=20wh?= =?UTF-8?q?ole=20(backups/set-aside//,=20a=20D25=20kept=20copy?= =?UTF-8?q?,=20=E2=80=9EVissza=C3=A1ll=C3=ADt=C3=A1s=E2=80=9D=20+=20?= =?UTF-8?q?=E2=80=9EElt=C3=A1vol=C3=ADt=C3=A1s=E2=80=9D=20on=20the=20resto?= =?UTF-8?q?re=20page);=20a=20move=20that=20fails=20refuses=20the=20write.?= =?UTF-8?q?=20Red-proved.=20Commit=20only.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 32 + REUSE.md | 1 + controller/README.md | 12 + controller/cmd/controller/main.go | 14 + controller/internal/api/router.go | 7 +- controller/internal/appbackup/appdata.go | 3 + controller/internal/backup/backup.go | 21 + .../internal/backup/offbox_reconstitute.go | 8 + controller/internal/backup/recovery_unit.go | 22 +- controller/internal/backup/tier2.go | 4 + controller/internal/backup/tier2_kept.go | 2 +- controller/internal/backup/unit_owner.go | 319 ++++++ controller/internal/backup/unit_owner_test.go | 304 ++++++ controller/internal/backup/update_guard.go | 3 + controller/internal/i18n/locales/en.json | 6 +- controller/internal/i18n/locales/hu.json | 6 +- controller/internal/stacks/deploy.go | 60 ++ controller/internal/stacks/import_record.go | 1 + controller/internal/stacks/install_id_test.go | 71 ++ controller/internal/stacks/life_records.go | 1 + controller/internal/stacks/manager.go | 1 + controller/internal/stacks/restore_gate.go | 11 +- controller/internal/web/handlers.go | 14 +- controller/internal/web/i18n_cases_b_test.go | 11 + controller/internal/web/kept_copies.go | 60 +- controller/internal/web/kept_copies_test.go | 68 ++ controller/internal/web/server.go | 3 + .../web/templates/backups_restore.html | 17 +- .../backups_restore_kept_unit.html | 921 ++++++++++++++++++ controller/scripts/i18n_go_keys.json | 4 +- 30 files changed, 1990 insertions(+), 17 deletions(-) create mode 100644 controller/internal/backup/unit_owner.go create mode 100644 controller/internal/backup/unit_owner_test.go create mode 100644 controller/internal/stacks/install_id_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/backups_restore_kept_unit.html diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3e148c..6edd9934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,38 @@ brief 2026-10-24, Part 4 (commit only; the release rides the next normal train). `…OnlyWhenTheAgentWaitsAndAnAppRuns`, `…QBLimitRuleSkipsAHeldLimit`, `…TheTwoAppsReadAndWriteTheirLimit`, `TestLineTestRunOutlastsTheClientTimeout`, `TestStartUpwatchRecoversTheHoldFirst`, `TestUpwatchHoldGetsBothAgentCalls`. +### A new install never writes into an earlier install's backup (D47, R-1269, P2) — 2026-10-25, commit only + +Operator ruling D47 (2026-10-25 08:38, `09` §3): a recovery unit's definition and its data are written only together, +by a backup of the same install; a unit an earlier install left is set aside whole and stays restorable. MinAgent +unchanged. Design: felhom.eu `07` §6.3 / §8.2a. + +- **Measured before (9202, 0.342.0 = `e1613ae` for these files; `audits/restore-truth-2026-10-25/before/`):** Tandoor, + one account, „Mentés most", remove keeping data and backups, install again → 08:00:36 the five-minute refresh + rewrote the kept unit's `app.yaml` (checksum `cbfcb538…` → `e19d2610…`) beside the old data; the restore paired the + new password with the old data directory (`password authentication failed` ×44 app / ×23 postgres), the app stayed + `starting`, the account was gone. Cause: `captureRecoveryUnit`'s one „same definition" test is the pins (`frozen`, + `recovery_unit.go:194`); a reinstall of the same version has the same pins. +- **Every install has an identity** (`stacks.AppConfig.InstallID`, `install_id:`): minted at a fresh install and at an + import, carried by every load-then-save, taken from the unit by a restore (the app then holds that unit's data), and + minted once for an app from before D47 (`EnsureInstallID`). The unit records it in its `app.yaml`. +- **`internal/backup/unit_owner.go` — the guard in every writer:** the capture and refresh, the night database dump, + the pre-backup dump, the update's pre-backup, the volume dump, the second drive's unit copy and the stale-drive prune + ask `guardUnitWrite`/`guardUnitDir` first. A unit of another install (different ids; or, for a unit from before D47, + a carried secret that differs) is MOVED whole to `/backups/set-aside///` and recorded as + a kept copy (D25's record); the writer then writes the new install's own unit. A move that fails refuses the write + with an ERROR naming the app and both identities; the unit stays byte for byte. Never during that app's restore. +- **The restore page lists a set-aside unit under „Megőrzött másolatok" with „Visszaállítás"** (`POST + /backup/kept/restore`, by its record only — path inside its tree, same device + inode) beside „Eltávolítás". Remove + with „delete its backups too" deletes the app's set-aside units too (the D25 / R-1252 H9 rule). +- A put-back writes the live install's identity back (the live definition copy carries it). +- Tests (each seen failing with the guard off — `audits/restore-truth-2026-10-25/redproof/`): + `TestD47_TheSweepOrderNeverRewritesTheKeptUnit`, `…AUnitFromBeforeD47IsSetAsideWhenItsPasswordDiffers`, + `…TheSetAsideUnitRestoresWithItsOwnPassword`, `…TheWriterRefusesWhenTheUnitCannotBeSetAside`, + `…TheNightDatabaseDumpSetsTheEarlierUnitAsideFirst`, `…NoSetAsideWhileTheAppIsRestored`; the controls + `…ASameInstallBackupStillRefreshesTheUnit`, `…OwnerVerdict`; stacks `TestD47_ARestoreTakesTheUnitsInstall` (+3); + web `TestD47_RestorePageOffersRestoreOnlyForASetAsideUnit`, `…KeptUnitRestoreHandlerRefusals`. + ### An export never replaces another operation's restore record (R-1253, P2; review round 2 HUNT-1 H3) — 2026-10-24 night, commit only - **An export with „stop the app first" no longer overwrites a running restore's crash record.** The app-stop marker diff --git a/REUSE.md b/REUSE.md index 347a192d..1964dd58 100644 --- a/REUSE.md +++ b/REUSE.md @@ -115,6 +115,7 @@ | `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate | | `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone | | `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only | +| `Manager.guardUnitWrite` / `guardUnitDir` + `unitOwnerVerdict` + `setAsideUnit` (D47, R-1269, unreleased) | controller/internal/backup/unit_owner.go | `guardUnitWrite(stack, nsRoot, what) error` (nil = write) | **The one question every unit writer asks first**: is this unit this install's? Another install's unit is moved whole to `backups/set-aside//` and recorded as a kept copy (kind `unit`, reason `earlier_install`); a move that fails refuses the write | **A new writer of `backups/primary/` (or of a second-drive unit copy) calls it before its first byte** — never write and check later. Identity: `stacks.AppConfig.InstallID` via `RecoveryInfo.InstallID`; `EnsureInstallID` (optional provider method) mints one for an older app. Restore of a set-aside unit: `RestoreSetAsideUnit(id)` by its record only. Pinned by `unit_owner_test.go` | | `Manager.keepCopy` / `releaseKeptCopy` / `RemoveKeptCopy` / `KeptCopies` + `settings.KeptCopy` (D25, R-1175/R-1181, 2026-10-21) | controller/internal/backup/tier2_kept.go, controller/internal/settings/kept_copies.go | see files | **A second-drive copy whose SOURCE is gone** (drive away/decommissioned, declared folder missing) — recorded, listed on the restore page, removed only by the household's „Eltávolítás" | **Never remove a kept copy by size, name or pattern** — `RemoveKeptCopy` takes the RECORD and checks path-inside-tree + device/inode; a swapped directory refuses. A share/leg reconcile walks ONLY its own key/legs (R-1175: walking every key with one group's legs deleted the other groups' copies). `emptiedSourceRefused` stops `rsync --delete` from emptying a full copy | | `stacks.SaveImportedAppConfig` + `PrepareImportedApp` (R-1191, 2026-10-21) | controller/internal/stacks/import_record.go | `(stackDir, env) (fresh bool, error)`; `(stackDir, env, fresh) error` | The `.fab` import's app record + doors (family gate, sign-up lock) BEFORE the start | Reuses `carryLifeRecords` / `prepareFamilyGate` / `restoreSignupLock` — do not copy their logic. `PrepareImportedApp` must also update the IN-MEMORY record, or the gate reconcile removes the file on its next tick | | `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback | diff --git a/controller/README.md b/controller/README.md index 2d304a60..26edf45c 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1680,6 +1680,18 @@ present folder is not mirrored over a copy that holds files: the run warns and k never chosen as the target (R-1179). Code: `internal/backup/tier2_kept.go`, `internal/settings/kept_copies.go`, `internal/web/kept_copies.go`. +### An earlier install's backup is set aside, never rewritten (D47, operator ruling 2026-10-25; R-1269) + +Every install has an identity (`app.yaml` `install_id:` — minted at a fresh install and at an import, taken from the unit +by a restore, minted once for an older app). The recovery unit records it. Before any backup writes into an app's unit — +the capture and its five-minute refresh, the database and volume dumps, the update's pre-backup, the second drive's unit +copy, the stale-drive prune — it asks whose unit it is. A unit of another install (a reinstall after „remove, keep +backups") is moved whole to `/backups/set-aside///` and listed on the restore page under +„Megőrzött másolatok" with „Visszaállítás" (it restores with its own database password) and „Eltávolítás"; the new +install then gets its own unit. A move that cannot be done refuses the write and leaves the old unit as it is (an ERROR +line names both identities). Remove with „delete its backups too" deletes the set-aside units as well. Code: +`internal/backup/unit_owner.go`, `internal/web/kept_copies.go`. + ### Off-site content proof (v0.231.0, R-87) **What it is:** every night the box restores ONE app's newest off-site snapshot into a throwaway diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index de8c2fde..3584db87 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -3286,9 +3286,23 @@ func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, b InstalledImages: installedRefs(s.AppConfig), // R-1057 (v0.314.0): the gate as it stands at this backup — read from the file, the record of truth. SetupGate: gateAtCapture(&meta, stackDir), + InstallID: installIDOf(stackDir), // D47: whose data this is — the unit records it }, true } +// installIDOf reads the app's install identity (D47) from its app.yaml on disk; "" when absent or unreadable. +func installIDOf(stackDir string) string { + if cfg := stacks.LoadAppConfig(stackDir); cfg != nil { + return cfg.InstallID + } + return "" +} + +// EnsureInstallID (D47) mints and saves an install identity for an app installed before D47 (backup.installIdentity). +func (a *stackAdapter) EnsureInstallID(name string) (string, error) { + return a.mgr.EnsureInstallID(name) +} + // gateAtCapture reads the app's setup-gate record from its app.yaml on disk for the backup (stacks.GateCapture). func gateAtCapture(meta *stacks.Metadata, stackDir string) *backup.GateState { var rec *stacks.SetupGateRecord diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 317fe89e..0848c316 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -1050,13 +1050,15 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri // volume tars) and its Tier-2 mirror, not only db-dumps. The mirror is on another drive, outside // RemoveStack's per-app base, so the backup manager removes it — and its location is read NOW, // before the cross-drive record is cleared below. Off-site snapshots are not touched by this path. - var backupPaths, mirrorDirs []string + var backupPaths, mirrorDirs, setAsideDirs []string if body.RemoveBackups && r.backupMgr != nil { nsRoot := r.backupMgr.AppNamespaceRoot(name) if nsRoot != "" { backupPaths = append(backupPaths, backup.RecoveryUnitPath(nsRoot, name), backup.AppDBDumpPath(nsRoot, name)) } mirrorDirs = r.backupMgr.Tier2MirrorDirsForApp(name) + // D47: the units of earlier installs set aside for this app are its backups too (the D25 kept-copy rule). + setAsideDirs = r.backupMgr.SetAsideDirsForApp(name) } resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths) @@ -1086,6 +1088,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 { resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...) } + if body.RemoveBackups && r.backupMgr != nil && len(setAsideDirs) > 0 { + resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveSetAsideUnits(name, setAsideDirs)...) + } if body.RemoveBackups { resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...) } diff --git a/controller/internal/appbackup/appdata.go b/controller/internal/appbackup/appdata.go index 5c8a06a1..9739ecda 100644 --- a/controller/internal/appbackup/appdata.go +++ b/controller/internal/appbackup/appdata.go @@ -91,6 +91,9 @@ type RecoveryInfo struct { // „open" (and by whom). Nil for an app whose template has no gate. The restore applies it, so a backup made before // the household's first setup never comes back open (`09` §3 decision 253). SetupGate *GateState + // InstallID (D47, R-1269) is the install the app's data belongs to (app.yaml `install_id:`); "" for an app installed + // before D47. The unit records it, and a backup of another install never writes into that unit (backup/unit_owner.go). + InstallID string } // GateState is the setup gate as a backup records it (the unit's app.yaml `setup_gate:`). The same two keys as the diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 054f99b0..cf748ada 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -663,6 +663,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error { continue } + // D47 (R-1269): never a dump into another install's unit — it is set aside whole first, or nothing is written. + if gErr := m.guardUnitWrite(db.StackName, m.namespaceRoot(drivePath), "database dump"); gErr != nil { + allOK = false + summary = append(summary, fmt.Sprintf("FAIL %s: %v", db.ContainerName, gErr)) + m.noteFailure(db.StackName, "database dump", gErr.Error()) + continue + } dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName) result := m.dumpOneOrDefault(ctx, db, dumpDir) @@ -870,6 +877,9 @@ func (m *Manager) DumpAppVolumes(stackName string) error { return fmt.Errorf("cannot determine drive path for %s", stackName) } + if err := m.guardUnitWrite(stackName, m.namespaceRoot(drivePath), "volume dump"); err != nil { // D47 (R-1269) + return err + } dumpDir := AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName) if err := os.MkdirAll(dumpDir, 0755); err != nil { return fmt.Errorf("creating volume dump dir: %w", err) @@ -1142,6 +1152,9 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error { if drivePath == "" || !filepath.IsAbs(drivePath) { return fmt.Errorf("cannot determine absolute drive path for %s (systemDataPath not configured?)", stackName) } + if err := m.guardUnitWrite(stackName, m.namespaceRoot(drivePath), "pre-backup database dump"); err != nil { // D47 + return err + } dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), stackName) m.logger.Printf("[INFO] [backup] Running pre-backup DB dump for %s (%d database(s)) → %s", stackName, len(stackDBs), dumpDir) @@ -1570,6 +1583,14 @@ func (m *Manager) pruneStalePrimaryDirs() { filepath.Clean(primaryDir)+string(filepath.Separator)) { continue } + // D47: a unit of ANOTHER install on the old drive is not this app's moved unit — it is set aside whole + // (never deleted); a guard that cannot decide leaves it as it is. + if gErr := m.guardUnitDir(app, stalePath, nsRoot, "stale-drive prune"); gErr != nil { + continue + } + if _, sErr := os.Stat(stalePath); os.IsNotExist(sErr) { + continue // set aside just now + } if err := os.RemoveAll(stalePath); err != nil { m.logger.Printf("[WARN] [backup] F5: could not remove stale primary dir for %s on old drive: %v", app, err) } else { diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index a90d538f..f7290406 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -494,6 +494,14 @@ func (m *Manager) captureLiveDefinition(stack string) (*liveDefinition, error) { for k, v := range m.stackProvider.RecoverStackSecrets(stack, info.SecretEnvVars) { d.env[k] = v } + // D47: a put-back writes the live definition back through the same writer as a restore, which takes the + // install identity from the folder's app.yaml — so the live install keeps its own (no env, no secret here). + if info.InstallID != "" { + if wErr := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("install_id: "+info.InstallID+"\n"), 0o600); wErr != nil { + d.cleanup() + return nil, wErr + } + } } return d, nil } diff --git a/controller/internal/backup/recovery_unit.go b/controller/internal/backup/recovery_unit.go index 7fe00bf0..2e3a0dff 100644 --- a/controller/internal/backup/recovery_unit.go +++ b/controller/internal/backup/recovery_unit.go @@ -100,7 +100,10 @@ func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time // // v0.275.0 (R-696): CaptureRecoveryUnit is the capture AFTER a data run (it follows the legs in // RunAppBackupNow). The periodic refresh calls captureRecoveryUnit(…, false), which never moves the -// unit's data time and never rewrites the definition away from the data it belongs to. +// unit's data time and never rewrites the definition away from the data it belongs to — for the SAME install +// (the pins below). A unit of ANOTHER install (a reinstall after „remove, keep backups") is never written at +// all: guardUnitWrite sets it aside whole first (D47, R-1269 — measured: the refresh wrote the new install's +// password beside the old data). Pinned by TestD47_* (unit_owner_test.go). func (m *Manager) CaptureRecoveryUnit(stackName string) error { return m.captureRecoveryUnit(stackName, true) } @@ -118,6 +121,11 @@ func (m *Manager) captureRecoveryUnit(stackName string, dataRun bool) error { return fmt.Errorf("cannot determine absolute drive path for %s", stackName) } nsRoot := m.namespaceRoot(drivePath) + // D47 (R-1269): the unit of another install is set aside whole before this install writes its own — or this + // capture writes nothing. Before the in-memory build, so the frozen/current checks below read THIS install's unit. + if err := m.guardUnitWrite(stackName, nsRoot, "recovery-unit capture"); err != nil { + return err + } // Build the captured config CONTENT in memory (no writes yet) so we can checksum-compare. type capFile struct { @@ -463,6 +471,14 @@ func (m *Manager) captureAllRecoveryUnits(dataRun bool) { if m.isHeld(stack.Name) { continue } + // D47: a restore of this app is reading its unit (and may be bringing back another install's) — the refresh + // waits for it; the restore's own end decides whose unit the app writes next. + if !dataRun && m.restoreRunningFor(stack.Name) { + if m.isDebug() { + m.logger.Printf("[DEBUG] [backup] recovery-unit refresh skipped for %s — a restore of it runs", stack.Name) + } + continue + } // R-645 (09 §3 decision 142): the unit of an app that is not running its pinned version stays // as it is — the measured case wrote a just-failed definition over the good unit within seconds // of a hand-lifted hold. Said at WARN on a data run; the periodic refresh repeats it only at DEBUG. @@ -506,6 +522,8 @@ type strippedAppYaml struct { // SetupGate (v0.314.0, R-1057): the gate at this backup; absent for an app with no gate, so an ungated app's // unit bytes are unchanged (the checksum-skip guard). SetupGate *GateState `yaml:"setup_gate,omitempty"` + // InstallID (D47): the install this unit's data belongs to — the unit's owner. Absent in a unit written before D47. + InstallID string `yaml:"install_id,omitempty"` } // buildUnitAppYaml renders the unit's app.yaml as bytes: the non-secret env PLUS the portable secret @@ -524,7 +542,7 @@ func buildUnitAppYaml(info RecoveryInfo) []byte { for k, v := range info.PortableSecrets { env[k] = v } - body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate}) + body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate, InstallID: info.InstallID}) if err != nil { body = []byte("deployed: true\nenv: {}\n") } diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index a3a5b76d..90cf066a 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -449,6 +449,10 @@ func (m *Manager) RunTier2(stackName string) error { } else if m.restoreRunningFor(stackName) { m.logger.Printf("[WARN] [backup] Tier 2 %s stopped before the unit leg — a restore of it began (R-1180)", stackName) return nil + } else if gErr := m.guardUnitDir(stackName, destUnit, target.NamespaceRoot, "second-drive copy of the unit"); gErr != nil { + // D47: the second drive's copy of an EARLIER install's unit is set aside whole before this install's is + // mirrored — or the leg is skipped and that copy stays as it is. + warns = append(warns, m.note("note.tier2.unit_other_install_kept")) } else if err := mirror(unitDir, destUnit); err != nil { m.recordTier2Failure(stackName, target, err) if m.tier2Notify != nil { diff --git a/controller/internal/backup/tier2_kept.go b/controller/internal/backup/tier2_kept.go index 3ac7c9a9..1cdfcb98 100644 --- a/controller/internal/backup/tier2_kept.go +++ b/controller/internal/backup/tier2_kept.go @@ -39,7 +39,7 @@ func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) { m.logger.Printf("[WARN] [backup] kept copy %s could not be recorded (the copy itself is kept): %v", path, err) return } - m.logger.Printf("[INFO] [backup] second-drive copy KEPT (source gone: %s): %s %q at %s", reason, kind, source, path) + m.logger.Printf("[INFO] [backup] copy KEPT (%s): %s %q at %s", reason, kind, source, path) } // releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched. diff --git a/controller/internal/backup/unit_owner.go b/controller/internal/backup/unit_owner.go new file mode 100644 index 00000000..de971416 --- /dev/null +++ b/controller/internal/backup/unit_owner.go @@ -0,0 +1,319 @@ +package backup + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gopkg.in/yaml.v3" +) + +// ── D47 (operator ruling 2026-10-25 08:38; R-1269): a new install never writes into an earlier install's unit ────── +// +// MEASURED BEFORE (catalog sweep 2026-10-24, three times; `audits/catalog-sweep-2026-10-24/_unitprobe/tandoor/`): +// install Tandoor, one account, „Mentés most", remove keeping data and backups, install again. Five minutes later the +// periodic refresh (captureRecoveryUnit, dataRun=false) rewrote ONLY the unit's compose/app.yaml — the new install's +// database password — beside the OLD install's data. Its one „same definition" test is the pins (`frozen` below), and +// a reinstall of the same version has the same pins, so it passed. A restore then paired the new password with the old +// data directory: „password authentication failed", the app never started, the account gone from view. The next +// night's data legs would have overwritten the old data too. +// +// THE RULE. A unit carries the identity of the install its data belongs to (its app.yaml `install_id:`, minted at a +// fresh install, taken over by a restore — stacks.AppConfig.InstallID). EVERY writer of a unit — the capture and +// refresh, the database dump legs, the volume dump leg, the update's pre-backup dump, the second drive's mirror, the +// stale-drive prune — asks guardUnitWrite first. A unit of ANOTHER install is SET ASIDE WHOLE (a rename on the same +// drive, never a copy, never an edit) into /backups/set-aside///, recorded as a kept copy +// (D25's record: listed on the restore page with „Visszaállítás" and „Eltávolítás"), and the writer then writes the new +// install's own unit. A set-aside that cannot be done REFUSES the write and leaves the old unit exactly as it was, with +// an ERROR line naming the app and both identities. Nothing deletes a set-aside unit but the household: its +// „Eltávolítás", or the app's remove with „delete its backups too" (the D25 / R-1252 H9 rule for kept copies). +// +// A UNIT WRITTEN BEFORE D47 records no install. Then the secrets decide: a reinstall mints new ones, a restore writes +// the unit's into the app, so a carried secret that differs on both sides means another install. With no differing +// secret it is this install's (the old behaviour) and the next write records the identity. + +// setAsideDirName is the folder beside backups/primary that holds the set-aside units. +const setAsideDirName = "set-aside" + +// KeptReasonEarlierInstall is the kept-copy reason of a set-aside unit (the page's „kept.reason.earlier_install"). +const KeptReasonEarlierInstall = "earlier_install" + +// KeptKindUnit is the kept-copy kind of a set-aside recovery unit (restorable from the restore page). +const KeptKindUnit = "unit" + +// errUnitOfAnotherInstall is the refusal of a write into another install's unit that could not be set aside. +var errUnitOfAnotherInstall = errors.New("the recovery unit belongs to another install and could not be set aside") + +// setAsideRoot is /backups/set-aside. +func setAsideRoot(nsRoot string) string { + return filepath.Join(nsRoot, "backups", setAsideDirName) +} + +// installIdentity is the optional provider method that mints an identity for an app installed before D47. +type installIdentity interface { + EnsureInstallID(name string) (string, error) +} + +type unitOwner int + +const ( + ownerNoUnit unitOwner = iota // nothing to protect + ownerSame // this install's unit (or no signal that it is not) + ownerOther // another install's unit — set it aside before writing +) + +// unitOwnerVerdict is the rule as a pure function. Both identities known → they decide. Otherwise (a unit or an app +// from before D47) the carried secrets decide: a name both sides hold with different values means another install. +// why names the reason, never a value. +func unitOwnerVerdict(unitID, liveID string, unitSecrets, liveSecrets map[string]string) (unitOwner, string) { + if unitID != "" && liveID != "" { + if unitID == liveID { + return ownerSame, "same install id" + } + return ownerOther, "the install ids differ" + } + var differ []string + for n, uv := range unitSecrets { + if lv := liveSecrets[n]; uv != "" && lv != "" && uv != lv { + differ = append(differ, n) + } + } + if len(differ) > 0 { + sort.Strings(differ) + return ownerOther, "carried secret(s) " + strings.Join(differ, ", ") + " differ (a unit from before D47)" + } + return ownerSame, "no install id on one side and no differing secret" +} + +// unitOwnerOf reads a unit's recorded install and the secrets it carries (names from its manifest). exists=false when +// the directory holds nothing a restore could use (no manifest, no compose/, no data folder). +func unitOwnerOf(unitDir string) (exists bool, installID string, secrets map[string]string) { + secrets = map[string]string{} + any := false + for _, p := range []string{UnitManifestFile(unitDir), UnitComposeDir(unitDir), UnitDBDumpDir(unitDir), UnitVolumeDumpDir(unitDir)} { + if _, err := os.Stat(p); err == nil { + any = true + break + } + } + if !any { + return false, "", secrets + } + var portable []string + if man := readManifest(UnitManifestFile(unitDir)); man != nil { + portable = man.PortableSecretEnvVars + } + appy := filepath.Join(UnitComposeDir(unitDir), "app.yaml") + _, secrets = readUnitEnv(appy, portable) + if b, err := os.ReadFile(appy); err == nil { + var doc struct { + InstallID string `yaml:"install_id"` + } + if yaml.Unmarshal(b, &doc) == nil { + installID = doc.InstallID + } + } + return true, installID, secrets +} + +// liveInstall is the app's identity and carried secrets now; it mints an identity for an app from before D47 when the +// provider can. err = the identity could not be read or written (the caller refuses to decide). +func (m *Manager) liveInstall(stack string) (string, map[string]string, error) { + info, ok := m.stackProvider.GetStackRecoveryInfo(stack) + if !ok { + // No record to compare at all (a provider that does not know the app): no signal — the old behaviour. + return "", nil, nil + } + id := info.InstallID + if id == "" { + if e, ok := m.stackProvider.(installIdentity); ok { + got, err := e.EnsureInstallID(stack) + if err != nil { + return "", nil, err + } + id = got + } + } + return id, info.PortableSecrets, nil +} + +// guardUnitWrite is asked by EVERY writer before it writes into /backups/primary/ (what names the +// writer, for the log). nil = write; an error = do not write — the unit is untouched. +func (m *Manager) guardUnitWrite(stack, nsRoot, what string) error { + if m.stackProvider == nil { + return nil + } + return m.guardUnitDir(stack, RecoveryUnitPath(nsRoot, stack), nsRoot, what) +} + +// guardUnitDir is guardUnitWrite for any unit directory (the second drive's mirror too); setAsideNs is the namespace +// root whose backups/set-aside receives it (the unit's own drive, so the move is a rename). +func (m *Manager) guardUnitDir(stack, unitDir, setAsideNs, what string) error { + exists, unitID, unitSecrets := unitOwnerOf(unitDir) + if !exists { + return nil + } + liveID, liveSecrets, err := m.liveInstall(stack) + if err != nil { + m.logger.Printf("[ERROR] [backup] %s: the %s is NOT written into %s — this install's identity cannot be read (%v); the unit is untouched (D47)", stack, what, unitDir, err) + return fmt.Errorf("%w: %v", errUnitOfAnotherInstall, err) + } + verdict, why := unitOwnerVerdict(unitID, liveID, unitSecrets, liveSecrets) + if verdict != ownerOther { + return nil + } + // A restore of this app reads that unit right now (its install becomes the unit's when the definition is written): + // never move it under the restore — refuse this write instead. + if m.restoreRunningFor(stack) { + m.logger.Printf("[ERROR] [backup] %s: the %s is NOT written into the unit of install %s (this install %s): a restore of the app runs — the unit is untouched (D47)", stack, what, orUnknown(unitID), orUnknown(liveID)) + return errUnitOfAnotherInstall + } + dest, err := m.setAsideUnit(stack, unitDir, setAsideNs) + if err != nil { + m.logger.Printf("[ERROR] [backup] %s: REFUSED to write the %s into the recovery unit of another install (unit install %s, this install %s; %s): it could not be set aside: %v — the unit is untouched (D47)", + stack, what, orUnknown(unitID), orUnknown(liveID), why, err) + return fmt.Errorf("%w: %v", errUnitOfAnotherInstall, err) + } + m.logger.Printf("[INFO] [backup] %s: the recovery unit of an earlier install (%s; %s) is SET ASIDE whole → %s; this install (%s) gets its own unit from its first backup (D47)", + stack, orUnknown(unitID), why, dest, orUnknown(liveID)) + return nil +} + +func orUnknown(id string) string { + if id == "" { + return "unrecorded" + } + return id +} + +// renameUnitFn is the set-aside's move (tests inject a failure); production is os.Rename. +var renameUnitFn = os.Rename + +// setAsideUnit MOVES unitDir whole to /backups/set-aside// and records it as a kept +// copy. A rename only: never a copy, never across drives (EXDEV refuses, nothing moved). +func (m *Manager) setAsideUnit(stack, unitDir, setAsideNs string) (string, error) { + base := filepath.Join(setAsideRoot(setAsideNs), stack) + if err := os.MkdirAll(base, 0o755); err != nil { + return "", err + } + dest := filepath.Join(base, time.Now().UTC().Format("20060102T150405Z")) + for i := 1; ; i++ { + if _, err := os.Lstat(dest); os.IsNotExist(err) { + break + } + dest = filepath.Join(base, fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102T150405Z"), i)) + } + if err := renameUnitFn(unitDir, dest); err != nil { + _ = os.Remove(base) // only when empty + return "", err + } + m.keepCopy(KeptKindUnit, stack, m.storageLabel(m.driveOfNamespace(setAsideNs)), KeptReasonEarlierInstall, setAsideRoot(setAsideNs), dest) + return dest, nil +} + +// driveOfNamespace names the drive a namespace root sits on (for the record's label): the registered storage path +// whose namespace it is, else the namespace root itself. +func (m *Manager) driveOfNamespace(nsRoot string) string { + if m.settings != nil { + for _, sp := range m.settings.GetStoragePaths() { + if sp.Path != "" && filepath.Clean(NamespaceRootFor(sp.Path, m.systemDataPath)) == filepath.Clean(nsRoot) { + return sp.Path + } + } + } + if m.systemDataPath != "" && filepath.Clean(NamespaceRootFor(m.systemDataPath, m.systemDataPath)) == filepath.Clean(nsRoot) { + return m.systemDataPath + } + return nsRoot +} + +// SetAsideDirsForApp lists the app's set-aside folders (/backups/set-aside/) that exist, on every +// namespace root a unit of it can sit on. +func (m *Manager) SetAsideDirsForApp(stack string) []string { + if m == nil || !validMirrorStackName(stack) { + return nil + } + var out []string + for _, root := range m.tier2MirrorRoots(stack) { + d := filepath.Join(setAsideRoot(root), stack) + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + out = append(out, d) + } + } + return out +} + +// RemoveSetAsideUnits is the app's remove with „delete its backups too": each dir only if it is exactly +// /backups/set-aside/ for one of the app's roots, and the kept-copy records inside it are dropped. +func (m *Manager) RemoveSetAsideUnits(stack string, dirs []string) []string { + if m == nil || !validMirrorStackName(stack) { + return nil + } + allowed := map[string]bool{} + for _, root := range m.tier2MirrorRoots(stack) { + allowed[filepath.Join(setAsideRoot(root), stack)] = true + } + var removed []string + for _, d := range dirs { + if !allowed[d] { + m.logger.Printf("[WARN] [backup] remove %s: refusing to delete %q — not this app's set-aside folder", stack, d) + continue + } + size := humanizeBytes(dirSizeBytes(d)) + if err := os.RemoveAll(d); err != nil { + m.logger.Printf("[ERROR] [backup] remove %s: deleting the set-aside units %s failed: %v", stack, d, err) + continue + } + m.logger.Printf("[INFO] [backup] remove %s: the set-aside units of earlier installs deleted with its backups: %s (%s)", stack, d, size) + m.dropKeptCopiesUnder(d) + removed = append(removed, fmt.Sprintf("%s (%s)", d, size)) + } + return removed +} + +// SetAsideUnitFor returns the kept-copy record of a set-aside unit by id, still the recorded directory (device + +// inode) and still inside its set-aside tree — the only way the restore page names one. +func (m *Manager) SetAsideUnitFor(id string) (settings.KeptCopy, error) { + if m.settings == nil { + return settings.KeptCopy{}, ErrKeptCopyNotFound + } + for _, k := range m.settings.GetKeptCopies() { + if k.ID != id { + continue + } + if k.Kind != KeptKindUnit || !validMirrorStackName(k.Source) { + return settings.KeptCopy{}, ErrKeptCopyNotFound + } + rel, err := filepath.Rel(filepath.Clean(k.DestBase), filepath.Clean(k.Path)) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.Base(k.DestBase) != setAsideDirName { + return settings.KeptCopy{}, ErrKeptCopyChanged + } + st, err := os.Lstat(k.Path) + if err != nil || !st.IsDir() { + return settings.KeptCopy{}, ErrKeptCopyChanged + } + if dev, ino := statIdentity(st); dev != k.Dev || ino != k.Ino { + return settings.KeptCopy{}, ErrKeptCopyChanged + } + return k, nil + } + return settings.KeptCopy{}, ErrKeptCopyNotFound +} + +// RestoreSetAsideUnit restores the app from a set-aside unit (the restore page's „Visszaállítás" on a kept copy): +// the one unit-restore body, with the unit named explicitly. The data then belongs to that unit's install, so its +// backups follow it (the app's own unit, of the install it replaced, is set aside at the next backup — never lost). +func (m *Manager) RestoreSetAsideUnit(id string) (string, UnitRestoreResult, error) { + k, err := m.SetAsideUnitFor(id) + if err != nil { + return "", UnitRestoreResult{}, err + } + m.logger.Printf("[INFO] [backup] restore %s from the set-aside unit %s (an earlier install, D47)", k.Source, k.Path) + res, err := m.RestoreFromRecoveryUnitAt(k.Source, k.Path) + return k.Source, res, err +} diff --git a/controller/internal/backup/unit_owner_test.go b/controller/internal/backup/unit_owner_test.go new file mode 100644 index 00000000..5021cbe8 --- /dev/null +++ b/controller/internal/backup/unit_owner_test.go @@ -0,0 +1,304 @@ +package backup + +import ( + "bytes" + "context" + "errors" + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// D47 (operator ruling 2026-10-25 08:38; R-1269) — a new install never writes into an earlier install's unit. +// +// These tests replay the catalog sweep's order (`audits/catalog-sweep-2026-10-24/_unitprobe/tandoor/`): install A, +// a backup, „remove — keep backups", install B of the same version (new database password), then the five-minute +// refresh — and assert the CONSEQUENCE: the old unit's bytes, where they are, what a restore of it gets. +// +// COMPANION RED-PROOF (REPORT.md): on `e1613ae` (no guardUnitWrite) TestD47_TheSweepOrderNeverRewritesTheKeptUnit +// fails — the primary unit's app.yaml carries install B's password beside install A's data, and nothing is set aside. + +const ( + d47PwA = "PASSWORD-OF-INSTALL-A-the-data-was-made-with" + d47PwB = "PASSWORD-OF-INSTALL-B-a-fresh-reinstall" +) + +// d47Info is the recovery info of one install of "tandoor" (a Postgres app on the system drive). +func d47Info(stackDir, drive, installID, pw string) RecoveryInfo { + return RecoveryInfo{ + StackDir: stackDir, + DisplayName: "Tandoor", + ImagePins: []string{"example/tandoor:2.6.15"}, + NonSecretEnv: map[string]string{"SUBDOMAIN": "recipes"}, + SecretEnvVars: []string{"DB_PASSWORD"}, + PortableSecretEnvVars: []string{"DB_PASSWORD"}, + PortableSecrets: map[string]string{"DB_PASSWORD": pw}, + InstallID: installID, + } +} + +type d47Rig struct { + m *Manager + prov *fakeRecoveryProvider + drive string + stackDir string + logs *bytes.Buffer + sett *settings.Settings +} + +// d47Setup: install A with a database dump and a volume tar, captured after its data run (the „Mentés most" order). +func d47Setup(t *testing.T, idA string) *d47Rig { + t.Helper() + tmp := t.TempDir() + r := &d47Rig{stackDir: filepath.Join(tmp, "stack"), drive: filepath.Join(tmp, "drive"), logs: &bytes.Buffer{}} + mustWrite(t, filepath.Join(r.stackDir, "docker-compose.yml"), + "services:\n tandoor:\n image: example/tandoor:2.6.15\n tandoor-postgres:\n image: postgres:17-alpine\n") + mustWrite(t, filepath.Join(r.stackDir, ".felhom.yml"), "display_name: Tandoor\n") + mustWrite(t, filepath.Join(r.stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: recipes\n") + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + r.sett = sett + r.prov = &fakeRecoveryProvider{info: d47Info(r.stackDir, r.drive, idA, d47PwA), hdd: r.drive, running: true, + secrets: map[string]string{"DB_PASSWORD": d47PwA}} + r.m = &Manager{ + logger: log.New(r.logs, "", 0), + systemDataPath: filepath.Join(tmp, "system"), + stackProvider: r.prov, + settings: sett, + version: "vtest", + } + mustWrite(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql"), pgDump(1)+"-- the household's account\n") + mustWrite(t, filepath.Join(AppVolumeDumpPath(r.drive, "tandoor"), "tandoor_postgres_data.tar"), "DATA DIRECTORY OF INSTALL A") + if err := r.m.CaptureRecoveryUnit("tandoor"); err != nil { + t.Fatalf("install A's capture: %v", err) + } + return r +} + +// reinstall is „remove — keep backups" + a fresh install of the same version: a new identity and a new password. +func (r *d47Rig) reinstall(idB string) { + r.prov.info = d47Info(r.stackDir, r.drive, idB, d47PwB) + r.prov.secrets = map[string]string{"DB_PASSWORD": d47PwB} +} + +func (r *d47Rig) unitDir() string { return RecoveryUnitPath(r.drive, "tandoor") } + +func (r *d47Rig) setAside(t *testing.T) []string { + t.Helper() + entries, _ := os.ReadDir(filepath.Join(setAsideRoot(r.drive), "tandoor")) + var out []string + for _, e := range entries { + out = append(out, filepath.Join(setAsideRoot(r.drive), "tandoor", e.Name())) + } + return out +} + +// The sweep's measured order. Today (`e1613ae`) the refresh rewrites the kept unit's app.yaml with install B's +// password beside install A's data. After D47 the kept unit is set aside byte for byte, it is listed, and the +// primary place holds install B's own unit with none of A's data. +func TestD47_TheSweepOrderNeverRewritesTheKeptUnit(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + before := treeFingerprint(t, r.unitDir()) + r.reinstall("bbbb000000000002") + + if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { // the five-minute refresh + t.Fatalf("refresh: %v", err) + } + + aside := r.setAside(t) + if len(aside) != 1 { + t.Fatalf("install A's unit must be set aside whole (one folder), found %v; primary app.yaml now: %q", + aside, mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml"))) + } + if after := treeFingerprint(t, aside[0]); after != before { + t.Fatalf("the set-aside unit must be install A's unit byte for byte\nbefore:\n%s\nafter:\n%s", before, after) + } + primaryAppYaml := mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml")) + if strings.Contains(primaryAppYaml, d47PwA) || !strings.Contains(primaryAppYaml, "bbbb000000000002") { + t.Fatalf("the primary place must hold install B's own unit: %q", primaryAppYaml) + } + if fileExists(filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql")) { + t.Fatal("install A's database copy must not stay under install B's unit") + } + var rec *settings.KeptCopy + for _, k := range r.sett.GetKeptCopies() { + if k.Path == aside[0] { + kk := k + rec = &kk + } + } + if rec == nil || rec.Kind != KeptKindUnit || rec.Reason != KeptReasonEarlierInstall || rec.Source != "tandoor" { + t.Fatalf("the set-aside unit must be recorded as a kept copy (listed, removable only by the household): %+v", r.sett.GetKeptCopies()) + } +} + +// A unit written BEFORE D47 records no install: the carried database password decides (a reinstall mints a new one). +func TestD47_AUnitFromBeforeD47IsSetAsideWhenItsPasswordDiffers(t *testing.T) { + r := d47Setup(t, "") + before := treeFingerprint(t, r.unitDir()) + r.reinstall("") + if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { + t.Fatalf("refresh: %v", err) + } + aside := r.setAside(t) + if len(aside) != 1 || treeFingerprint(t, aside[0]) != before { + t.Fatalf("an unrecorded unit with another install's password must be set aside whole, found %v", aside) + } +} + +// The set-aside unit restores with ITS OWN password (the household's „Visszaállítás" on it), never install B's. +func TestD47_TheSetAsideUnitRestoresWithItsOwnPassword(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + r.reinstall("bbbb000000000002") + if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { + t.Fatal(err) + } + ks := r.sett.GetKeptCopies() + if len(ks) != 1 { + t.Fatalf("one kept copy expected, got %+v", ks) + } + r.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { + return []DiscoveredDB{{StackName: "tandoor", ContainerName: "tandoor-postgres", DBType: DBTypePostgres}}, nil + } + var imported []string + r.m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error { imported = append(imported, p); return nil } + r.m.volumeReplayFrom = func(string, string) (int, error) { return 1, nil } + r.m.undoVolumeProbeFn = func(string) (int64, bool, error) { return 10, true, nil } + r.m.undoVolumeTarFn = func(vol, dir string) error { + return os.WriteFile(filepath.Join(dir, vol+".tar"), []byte("live"), 0o600) + } + + app, res, err := r.m.RestoreSetAsideUnit(ks[0].ID) + if err != nil || app != "tandoor" { + t.Fatalf("restore of the set-aside unit: app=%q err=%v", app, err) + } + if got := r.prov.gotEnv["DB_PASSWORD"]; got != d47PwA { + t.Fatalf("the restored app must get install A's password (the one its data opens with), got %q", got) + } + if res.DBsReplayed != 1 || len(imported) != 1 || !strings.Contains(imported[0], filepath.Join(setAsideDirName, "tandoor")) { + t.Fatalf("the database copy must come from the set-aside unit: %v (replayed %d)", imported, res.DBsReplayed) + } +} + +// The guard is in the WRITER: a set-aside that cannot be done refuses the write, the unit stays byte for byte, and the +// ERROR line names the app and both identities. +func TestD47_TheWriterRefusesWhenTheUnitCannotBeSetAside(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + before := treeFingerprint(t, r.unitDir()) + r.reinstall("bbbb000000000002") + renameUnitFn = func(string, string) error { return errors.New("injected: rename refused") } + defer func() { renameUnitFn = os.Rename }() + + err := r.m.captureRecoveryUnit("tandoor", false) + if !errors.Is(err, errUnitOfAnotherInstall) { + t.Fatalf("the capture must refuse, got %v", err) + } + if after := treeFingerprint(t, r.unitDir()); after != before { + t.Fatalf("a refused write must leave install A's unit untouched\nbefore:\n%s\nafter:\n%s", before, after) + } + line := r.logs.String() + if !strings.Contains(line, "[ERROR]") || !strings.Contains(line, "tandoor") || !strings.Contains(line, "aaaa000000000001") || !strings.Contains(line, "bbbb000000000002") { + t.Fatalf("the ERROR line must name the app and both identities: %s", line) + } + if strings.Contains(line, d47PwA) || strings.Contains(line, d47PwB) { + t.Fatal("SECRET LEAK: a password value reached the log") + } +} + +// The data legs ask too: install B's night database dump never lands in install A's unit. +func TestD47_TheNightDatabaseDumpSetsTheEarlierUnitAsideFirst(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + before := treeFingerprint(t, r.unitDir()) + r.reinstall("bbbb000000000002") + r.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { + return []DiscoveredDB{{StackName: "tandoor", ContainerName: "tandoor-postgres", DBType: DBTypePostgres}}, nil + } + r.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult { + p := filepath.Join(dir, "tandoor-postgres.sql") + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(p, []byte(pgDump(1)+"-- install B, empty\n"), 0o644) + return DumpResult{DB: db, FilePath: p, Size: 10} + } + if err := r.m.guardUnitWrite("tandoor", r.drive, "database dump"); err != nil { + t.Fatal(err) + } + res := r.m.dumpOneOrDefault(context.Background(), r.m.mustDB(t), AppDBDumpPath(r.drive, "tandoor")) + if res.Error != nil { + t.Fatal(res.Error) + } + aside := r.setAside(t) + if len(aside) != 1 || treeFingerprint(t, aside[0]) != before { + t.Fatalf("install A's unit must be set aside whole before install B's dump is written, found %v", aside) + } +} + +func (m *Manager) mustDB(t *testing.T) DiscoveredDB { + t.Helper() + dbs, err := m.discoverDBs(context.Background()) + if err != nil || len(dbs) == 0 { + t.Fatal("no db") + } + return dbs[0] +} + +// The other side: a normal backup of the SAME install still refreshes the unit whole, and nothing is set aside. +func TestD47_ASameInstallBackupStillRefreshesTheUnit(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + mustWrite(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql"), pgDump(1)+"-- tonight's rows\n") + if err := r.m.CaptureRecoveryUnit("tandoor"); err != nil { + t.Fatal(err) + } + if aside := r.setAside(t); len(aside) != 0 { + t.Fatalf("a backup of the same install must not set anything aside: %v", aside) + } + appy := mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml")) + if !strings.Contains(appy, d47PwA) || !strings.Contains(appy, "install_id: aaaa000000000001") { + t.Fatalf("the same install's unit carries its own password and identity: %q", appy) + } + if got := mustRead(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql")); !strings.Contains(got, "tonight's rows") { + t.Fatal("tonight's dump must be in the unit") + } +} + +// A restore of the app runs: the refresh never moves the unit it reads (refused, untouched). +func TestD47_NoSetAsideWhileTheAppIsRestored(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + before := treeFingerprint(t, r.unitDir()) + r.reinstall("bbbb000000000002") + r.m.BeginRestoreOp("restore", "tandoor") + if err := r.m.guardUnitWrite("tandoor", r.drive, "database dump"); !errors.Is(err, errUnitOfAnotherInstall) { + t.Fatalf("a write during the app's restore must refuse, got %v", err) + } + if after := treeFingerprint(t, r.unitDir()); after != before { + t.Fatal("the unit under a restore must stay as it is") + } +} + +// The verdict as a pure function: ids decide when both are known; otherwise a differing carried secret. +func TestD47_OwnerVerdict(t *testing.T) { + cases := []struct { + name string + unitID, id string + us, ls map[string]string + want unitOwner + }{ + {"same id", "a", "a", nil, nil, ownerSame}, + {"other id", "a", "b", map[string]string{"P": "x"}, map[string]string{"P": "x"}, ownerOther}, + {"unrecorded unit, same password", "", "b", map[string]string{"P": "x"}, map[string]string{"P": "x"}, ownerSame}, + {"unrecorded unit, other password", "", "b", map[string]string{"P": "x"}, map[string]string{"P": "y"}, ownerOther}, + {"unrecorded unit, no secrets", "", "b", nil, nil, ownerSame}, + {"empty value never decides", "", "", map[string]string{"P": ""}, map[string]string{"P": "y"}, ownerSame}, + } + for _, c := range cases { + if got, _ := unitOwnerVerdict(c.unitID, c.id, c.us, c.ls); got != c.want { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} diff --git a/controller/internal/backup/update_guard.go b/controller/internal/backup/update_guard.go index 6552587c..55f85f33 100644 --- a/controller/internal/backup/update_guard.go +++ b/controller/internal/backup/update_guard.go @@ -406,6 +406,9 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error { if err != nil { return util.MsgError("err.backup.adatbazis_felderites_sikertelen", err) } + if gErr := m.guardUnitWrite(stackName, nsRoot, "update pre-backup"); gErr != nil { // D47 (R-1269) + return util.MsgError("err.backup.adatbazis_mentes_sikertelen", stackName, gErr) + } dumped := 0 for _, db := range dbs { if db.StackName != stackName { diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 721c3a28..60360fc5 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2856,5 +2856,9 @@ "flash.kept.remove_failed": "The kept copy could not be removed: %s", "deploy.memory_overcommit_slow": "The apps' peak load can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app.", "deploy.memory_swap_short": "The machine has too little swap space, so during large uploads the box can slow down and not answer for minutes. Tell Felhom; we will fix it.", - "err.stacks.memory_overcommit_slow": "The peak load of the apps can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app." + "err.stacks.memory_overcommit_slow": "The peak load of the apps can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app.", + "kept.reason.earlier_install": "the backup of an earlier install (made before a reinstall)", + "backups_restore.megorzott_visszaallitas_megerosites": "This loads the backup of the earlier install into the app. It replaces the data the app has now. Continue?", + "flash.kept.restore_needs_confirmation": "Confirm the question to restore it.", + "note.tier2.unit_other_install_kept": "The second drive holds the data package of an earlier install, and we could not set it aside now, so we did not overwrite it. The data package of the copy is therefore older than this backup." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 7d9a76e3..8aec4f21 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2844,5 +2844,9 @@ "flash.kept.remove_failed": "A megőrzött másolatot nem sikerült eltávolítani: %s", "deploy.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást.", "deploy.memory_swap_short": "A gépen nincs elég cserehely (swap), ezért nagy feltöltéskor a doboz lelassulhat, és percekig sem válaszolhat. Szólj a Felhomnak, mi rendbe tesszük.", - "err.stacks.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást." + "err.stacks.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást.", + "kept.reason.earlier_install": "egy korábbi telepítés mentése (újratelepítés előtt készült)", + "backups_restore.megorzott_visszaallitas_megerosites": "Ezzel a korábbi telepítés mentését töltjük vissza az alkalmazásba. A mostani adatai helyére ez kerül. Folytatod?", + "flash.kept.restore_needs_confirmation": "A visszaállításhoz erősítsd meg a kérdést.", + "note.tier2.unit_other_install_kept": "A második meghajtón egy korábbi telepítés adatcsomagja van, és most nem tudtuk félretenni, ezért nem írtuk felül. A másolat adatcsomagja ezért régebbi, mint ez a mentés." } diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 8f8baeb4..27fdfdba 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -201,6 +201,21 @@ type AppConfig struct { AfterSetup *AfterInstallRecord `yaml:"after_setup,omitempty" json:"after_setup,omitempty"` // AfterUncleanBoot (R-1007) is what the template's after_unclean_boot last found and did (after_unclean_boot.go). AfterUncleanBoot *AfterUncleanBootRecord `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"` + // InstallID (D47, operator ruling 2026-10-25; R-1269) names the install the app's DATA belongs to: minted at a + // fresh install and at an import, taken from the unit at a restore (the data is that unit's now), and carried by + // every load-then-save. The recovery unit records it (its app.yaml `install_id:`), and a backup of another install + // never writes into that unit (internal/backup/unit_owner.go). Absent = an app installed before D47 (minted at + // its first backup). + InstallID string `yaml:"install_id,omitempty" json:"install_id,omitempty"` +} + +// NewInstallID mints an install identity (D47): 16 random hex characters. Never a secret, never derived from one. +func NewInstallID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("t%015x", time.Now().UnixNano()&0xfffffffffffffff) // a clock fallback, still unique per box + } + return hex.EncodeToString(b) } // DefaultLoginRecord is app.yaml's `default_login:`. @@ -541,6 +556,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { SetupGate: gate, InstallHold: hold, FamilyGate: family, + InstallID: NewInstallID(), // D47: a fresh install is a new install — its backups are its own } diskCfg := *appCfg @@ -841,6 +857,18 @@ func (m *Manager) PersistUnitRedeployConfigGate(name string, env map[string]stri cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env) priorRaw := LoadAppConfig(stackDir) carryLifeRecords(m.logger, name, priorRaw, cfg) + // D47 (R-1269): the app now holds the data of the unit it was restored from, so it IS that unit's install — a + // later backup then writes into that unit, and a unit of the install it replaced is set aside, never rewritten. + // A unit that records no install (written before D47) keeps the app's own (carried above), else a new one. + switch { + case gate.InstallID != "": + if priorRaw != nil && priorRaw.InstallID != "" && priorRaw.InstallID != gate.InstallID { + m.logger.Printf("[INFO] [stacks] %s: the restored data belongs to install %s (was %s) — its backups follow it (D47)", name, gate.InstallID, priorRaw.InstallID) + } + cfg.InstallID = gate.InstallID + case cfg.InstallID == "": + cfg.InstallID = NewInstallID() + } reopen, err := m.applyGateAtBackup(name, stack.ComposePath, env, &meta, cfg, priorRaw == nil, gate) if err != nil { return err @@ -1623,6 +1651,38 @@ func restoredLoginFields(app string, meta Metadata, prior *AppConfig, env map[st return out } +// EnsureInstallID (D47) returns the app's install identity, minting and saving one (load-then-save, nothing else +// changes) for an app installed before D47. "" with an error when the record cannot be read or written — the caller +// (the backup) then refuses to decide whose unit it would write. +func (m *Manager) EnsureInstallID(name string) (string, error) { + stack, ok := m.GetStack(name) + if !ok { + return "", fmt.Errorf("stack %q not found", name) + } + stackDir := filepath.Dir(stack.ComposePath) + m.installIDMu.Lock() + defer m.installIDMu.Unlock() + cfg := LoadAppConfig(stackDir) + if cfg == nil { + return "", fmt.Errorf("app.yaml of %s not readable", name) + } + if cfg.InstallID != "" { + return cfg.InstallID, nil + } + meta := LoadMetadata(stackDir) + cfg.InstallID = NewInstallID() + if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { + return "", fmt.Errorf("saving the install id of %s: %w", name, err) + } + m.mu.Lock() + if s, ok := m.stacks[name]; ok { + s.AppConfig = LoadAppConfig(stackDir) + } + m.mu.Unlock() + m.logger.Printf("[INFO] [stacks] %s: install id %s recorded (an app installed before D47)", name, cfg.InstallID) + return cfg.InstallID, nil +} + // SetStackSecretValues (R-1106) writes the named values into an installed app's app.yaml and NOTHING else: load the // file as it is (records, pin, gates, locked fields all kept — the R-697 lesson: a non-install writer loads, then // saves), replace the named env values, save with the sensitive fields encrypted. Used by the off-site restore to put diff --git a/controller/internal/stacks/import_record.go b/controller/internal/stacks/import_record.go index 3f9483f4..3413f620 100644 --- a/controller/internal/stacks/import_record.go +++ b/controller/internal/stacks/import_record.go @@ -40,6 +40,7 @@ func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string) } } carryLifeRecords(m.logger, name, priorRaw, cfg) + cfg.InstallID = NewInstallID() // D47: the bundle's data is no existing unit's data — a new install (after the carry) if priorRaw != nil { cfg.PinnedImages = priorRaw.PinnedImages cfg.InstalledImages = priorRaw.InstalledImages diff --git a/controller/internal/stacks/install_id_test.go b/controller/internal/stacks/install_id_test.go new file mode 100644 index 00000000..4cfc909a --- /dev/null +++ b/controller/internal/stacks/install_id_test.go @@ -0,0 +1,71 @@ +package stacks + +import ( + "os" + "path/filepath" + "testing" +) + +// D47 (operator ruling 2026-10-25; R-1269) — the app's record names the install its data belongs to. Through the +// PRODUCTION writers: the restore write (PersistUnitRedeployConfigGate), a later load-then-save, EnsureInstallID. +// COMPANION RED-PROOF: drop the `case gate.InstallID != ""` branch in PersistUnitRedeployConfigGate → +// TestD47_ARestoreTakesTheUnitsInstall fails (the app keeps the replaced install's id, and its next backup would +// set aside the very unit it was restored from). + +// A restore from a unit that records an install: the app now holds that unit's data, so it IS that install. +func TestD47_ARestoreTakesTheUnitsInstall(t *testing.T) { + m := gateManager(t, gatedYml) + writeAppYaml(t, gappDir(m), "deployed: true\ninstall_id: bbbb000000000002\nenv:\n SUBDOMAIN: gapp\n") + must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{InstallID: "aaaa000000000001"})) + if got := LoadAppConfig(gappDir(m)); got == nil || got.InstallID != "aaaa000000000001" { + t.Fatalf("the restored app must be the unit's install, got %+v", got) + } +} + +// A unit that records no install (before D47): the app keeps its own identity — a restore is not a new install. +func TestD47_ARestoreOfAnUnrecordedUnitKeepsTheAppsInstall(t *testing.T) { + m := gateManager(t, gatedYml) + writeAppYaml(t, gappDir(m), "deployed: true\ninstall_id: bbbb000000000002\nenv:\n SUBDOMAIN: gapp\n") + must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{})) + if got := LoadAppConfig(gappDir(m)); got == nil || got.InstallID != "bbbb000000000002" { + t.Fatalf("an unrecorded unit's restore keeps the app's own install, got %+v", got) + } +} + +// A removed app restored from an unrecorded unit gets an identity (never empty after a restore). +func TestD47_ARemovedAppRestoredGetsAnInstall(t *testing.T) { + m := gateManager(t, gatedYml) + must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{})) + if got := LoadAppConfig(gappDir(m)); got == nil || len(got.InstallID) != 16 { + t.Fatalf("a restored app must have an install id, got %+v", got) + } +} + +// An app installed before D47 gets one identity once; a second call returns the same one. +func TestD47_EnsureInstallIDMintsOnce(t *testing.T) { + m := gateManager(t, gatedYml) + writeAppYaml(t, gappDir(m), "deployed: true\nenv:\n SUBDOMAIN: gapp\ndesired_state: running\n") + m.mu.Lock() + m.stacks["gapp"].Deployed = true + m.mu.Unlock() + a, err := m.EnsureInstallID("gapp") + must(t, err) + b, err := m.EnsureInstallID("gapp") + must(t, err) + if len(a) != 16 || a != b { + t.Fatalf("one identity, minted once: %q then %q", a, b) + } + if got := LoadAppConfig(gappDir(m)); got.DesiredState != DesiredStateRunning { + t.Fatalf("minting the identity must change nothing else: %+v", got) + } +} + +func writeAppYaml(t *testing.T, dir, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/controller/internal/stacks/life_records.go b/controller/internal/stacks/life_records.go index b15afd87..b8a5007e 100644 --- a/controller/internal/stacks/life_records.go +++ b/controller/internal/stacks/life_records.go @@ -40,6 +40,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) { cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced cfg.DefaultLogin = prior.DefaultLogin cfg.AfterSetup = prior.AfterSetup + cfg.InstallID = prior.InstallID // D47: a rewrite of the record is not a new install (a restore then sets the unit's) if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 { cur := "" if prior.ConversionCopy != nil { diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index f16198f0..37fda838 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -259,6 +259,7 @@ type Manager struct { // Migration engine (B1): single-flight + live job + deps wired via SetMigrationDeps. migrateMu sync.Mutex + installIDMu sync.Mutex // D47: one EnsureInstallID at a time (two backups racing must not mint two ids) migrating bool migJob *MigrationJob settings *settings.Settings diff --git a/controller/internal/stacks/restore_gate.go b/controller/internal/stacks/restore_gate.go index 93f26832..ddc62b79 100644 --- a/controller/internal/stacks/restore_gate.go +++ b/controller/internal/stacks/restore_gate.go @@ -29,6 +29,9 @@ type GateAtBackup struct { Known bool // the backup carries a record Closed bool // the gate was closed (the first setup not done) OpenedBy string // who opened it, when open + // InstallID (D47) is the install the backup's data belongs to — the unit's app.yaml `install_id:`. "" = not + // recorded (a unit written before D47). Independent of Known (which is about the gate). + InstallID string } // GateCapture is the record a backup writes for an app: nil for a template with no gate. An app whose template gates @@ -53,11 +56,15 @@ func ReadGateAtBackup(appYamlPath string) GateAtBackup { } var doc struct { SetupGate *appbackup.GateState `yaml:"setup_gate"` + InstallID string `yaml:"install_id"` } - if yaml.Unmarshal(b, &doc) != nil || doc.SetupGate == nil { + if yaml.Unmarshal(b, &doc) != nil { return GateAtBackup{} } - return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy} + if doc.SetupGate == nil { + return GateAtBackup{InstallID: doc.InstallID} + } + return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy, InstallID: doc.InstallID} } // applyGateAtBackup decides the restored app's gate (R-1057). removed = the app had no app.yaml before this restore. diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index cc139580..497db476 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1342,7 +1342,13 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr != nil { data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies() // D25: second-drive copies whose source is gone — kept until the household removes one. - data["KeptCopies"] = s.keptCopyRows(s.langFor(r)) + rows := s.keptCopyRows(s.langFor(r)) + data["KeptCopies"] = rows + for _, k := range rows { + if k.Restorable { + data["KeptUnitRestorable"] = true // D47: the confirm script only when a row offers „Visszaállítás" + } + } // R-487: removed apps whose unit was kept join the local restore picker. data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits() } @@ -1909,6 +1915,12 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound) } +// restoreFailedNote is the household's sentence for a restore that returned an error: key's „…sikertelen: %s" +// around the error's own sentence. +func (s *Server) restoreFailedNote(key string, err error) string { + return s.note(key, s.noteErr(err)) +} + // unitRestoreOutcomeMsg builds the OUTCOME sentence for a completed LOCAL recovery-unit restore. Pure, // so the wording is unit-testable — this string is the customer's only evidence that the operation did // what its label promised. diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go index c35c346c..2d551234 100644 --- a/controller/internal/web/i18n_cases_b_test.go +++ b/controller/internal/web/i18n_cases_b_test.go @@ -244,6 +244,17 @@ func i18nCasesB() []i18nCase { d["KeptCopies"] = []keptCopyRow{{ID: "0123456789abcdef", Source: "csaladi_fotok", Line: "hdd_3", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}} return d }}, + // D47 (2026-10-25): a set-aside unit of an earlier install — the row with „Visszaállítás" (a NEW case, so no existing + // fixture changes). + {"backups_restore_kept_unit", "backups_restore", func() map[string]interface{} { + d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") + d["Backup"] = i18nBackupStatus(false, false) + d["OffboxConfigured"] = true + d["OffsiteStoreState"] = "known" + d["KeptCopies"] = []keptCopyRow{{ID: "fedcba9876543210", Source: "tandoor", Line: "belso", SizeHuman: "150 MB", Since: "2026-10-25T08:00:00Z", Restorable: true}} + d["KeptUnitRestorable"] = true + return d + }}, {"backups_restore_known_empty", "backups_restore", func() map[string]interface{} { d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") d["Backup"] = i18nBackupStatus(false, false) diff --git a/controller/internal/web/kept_copies.go b/controller/internal/web/kept_copies.go index 380398a5..fea5904d 100644 --- a/controller/internal/web/kept_copies.go +++ b/controller/internal/web/kept_copies.go @@ -3,6 +3,7 @@ package web import ( "errors" "net/http" + "net/url" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" @@ -18,6 +19,8 @@ type keptCopyRow struct { Line string // „a(z) meghajtóról megőrzött másolat · " SizeHuman string Since string + // Restorable (D47): a set-aside recovery unit of an earlier install — the row offers „Visszaállítás" too. + Restorable bool } func (s *Server) keptCopyRows(lang string) []keptCopyRow { @@ -28,11 +31,12 @@ func (s *Server) keptCopyRows(lang string) []keptCopyRow { for _, k := range s.backupMgr.KeptCopies() { why := s.msgLang(lang, "kept.reason."+k.Reason) out = append(out, keptCopyRow{ - ID: k.ID, - Source: k.Source, - Line: s.msgLang(lang, "kept.from_drive", k.Drive) + " · " + why, - SizeHuman: backup.HumanizeBytes(k.SizeBytes), - Since: k.Since, + ID: k.ID, + Source: k.Source, + Line: s.msgLang(lang, "kept.from_drive", k.Drive) + " · " + why, + SizeHuman: backup.HumanizeBytes(k.SizeBytes), + Since: k.Since, + Restorable: k.Kind == backup.KeptKindUnit, }) } return out @@ -68,3 +72,49 @@ func (s *Server) keptCopyRemoveHandler(w http.ResponseWriter, r *http.Request) { offboxRedirectTo(w, r, "/backups/restore", "flash.kept.remove_failed", true, err.Error()) } } + +// keptUnitRestoreHandler is „Visszaállítás" on a set-aside unit (D47): POST /backup/kept/restore (id, confirm=1). The +// unit is named only by its record (backup.SetAsideUnitFor: the recorded folder, inside its set-aside tree, same device +// + inode), and restored through the one unit-restore body, in the background like every restore. +func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil { + offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true) + return + } + _ = r.ParseForm() + id := strings.TrimSpace(r.FormValue("id")) + if r.FormValue("confirm") != "1" { + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.restore_needs_confirmation", true) + return + } + if msg, blocked := s.restoreOpBlocked(); blocked { + http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound) + return + } + k, err := s.backupMgr.SetAsideUnitFor(id) + switch { + case errors.Is(err, backup.ErrKeptCopyNotFound): + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.not_found", true) + return + case err != nil: + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.changed", true) + return + } + if legs := s.backupMgr.DeclaredDriveFileLegs(k.Source); len(legs) > 0 { + msg := s.missingFileLegsRefusal(r.Context(), s.langFor(r), k.Source) + http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound) + return + } + s.logger.Printf("[WARN] [web] Restore of %s from a set-aside unit requested (async): %s from %s", k.Source, k.Path, r.RemoteAddr) + s.backupMgr.BeginRestoreOp("restore", k.Source) + go func() { + app, res, err := s.backupMgr.RestoreSetAsideUnit(id) + if err != nil { + s.logger.Printf("[ERROR] [web] Restore from the set-aside unit failed (async): stack=%s: %v", app, err) + s.backupMgr.EndRestoreOp(false, s.restoreFailedNote("note.restore.unit_failed", err)) + return + } + s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(app, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(app, res)) + }() + http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound) +} diff --git a/controller/internal/web/kept_copies_test.go b/controller/internal/web/kept_copies_test.go index e3175a80..9b97b00a 100644 --- a/controller/internal/web/kept_copies_test.go +++ b/controller/internal/web/kept_copies_test.go @@ -95,3 +95,71 @@ func TestD25_RemoveHandlerRemovesOnlyTheRecordedCopy(t *testing.T) { t.Fatalf("another copy was touched: %v", err) } } + +// D47 (2026-10-25): a set-aside unit of an earlier install is a kept copy with „Visszaállítás" beside „Eltávolítás"; +// a second-drive copy is not restorable from this list (render test per branch of {{if .Restorable}}). +func TestD47_RestorePageOffersRestoreOnlyForASetAsideUnit(t *testing.T) { + d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") + d["Backup"] = i18nBackupStatus(false, false) + d["OffboxConfigured"] = true + d["OffsiteStoreState"] = "known" + d["KeptCopies"] = []keptCopyRow{ + {ID: "aaaaaaaaaaaaaaaa", Source: "tandoor", Line: "a(z) belso meghajtóról megőrzött másolat · egy korábbi telepítés mentése (újratelepítés előtt készült)", SizeHuman: "150 MB", Since: "2026-10-25T08:00:00Z", Restorable: true}, + {ID: "bbbbbbbbbbbbbbbb", Source: "csaladi_fotok", Line: "a(z) hdd_3 meghajtóról megőrzött másolat · a meghajtó nincs csatlakoztatva", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}, + } + d["KeptUnitRestorable"] = true + html := renderBackupPage(t, "backups_restore", d) + if n := strings.Count(html, `action="/backup/kept/restore"`); n != 1 { + t.Fatalf("exactly the set-aside unit's row offers the restore, found %d", n) + } + i := strings.Index(html, `action="/backup/kept/restore"`) + if !strings.Contains(html[i:i+300], `value="aaaaaaaaaaaaaaaa"`) || !strings.Contains(html, "confirmRestoreKeptUnit(this)") { + t.Fatal("the restore form must name the set-aside unit's record and ask first") + } + if strings.Count(html, `action="/backup/kept/remove"`) != 2 { + t.Fatal("both rows keep the remove button") + } +} + +// The restore handler names a set-aside unit ONLY by its record: no confirm → refused; an unknown id → not found; a +// second-drive copy's id is not a unit → not found (nothing starts). +func TestD47_KeptUnitRestoreHandlerRefusals(t *testing.T) { + tmp := t.TempDir() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + share := filepath.Join(tmp, "hdd_1", "backups", "secondary", "_shares", "hdd_3", "csaladi_fotok") + if err := os.MkdirAll(share, 0o755); err != nil { + t.Fatal(err) + } + st, _ := os.Stat(share) + sys := st.Sys().(*syscall.Stat_t) + if err := sett.UpsertKeptCopy(settings.KeptCopy{Kind: "share", Source: "csaladi_fotok", Drive: "hdd_3", Reason: "drive_away", + DestBase: filepath.Dir(filepath.Dir(share)), Path: share, Dev: uint64(sys.Dev), Ino: sys.Ino}); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = tmp + s := &Server{cfg: cfg, backupMgr: backup.NewManager(cfg, sett, lg), logger: lg} + post := func(form string) string { + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/backup/kept/restore", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.keptUnitRestoreHandler(rr, req) + return rr.Header().Get("Location") + } + if loc := post("id=x"); !strings.Contains(loc, "flash.kept.restore_needs_confirmation") { + t.Fatalf("no confirm → %q", loc) + } + if loc := post("id=ffffffffffffffff&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") { + t.Fatalf("unknown id → %q", loc) + } + if loc := post("id=" + settings.KeptCopyID(share) + "&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") { + t.Fatalf("a second-drive copy is not a restorable unit → %q", loc) + } + if s.backupMgr.RestoreStatus().Running { + t.Fatal("a refusal must start nothing") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 92e9f2dd..ef9ee2af 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -844,6 +844,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // D25 (2026-10-21): „Eltávolítás" of ONE kept second-drive copy whose source is gone. case path == "/backup/kept/remove" && r.Method == http.MethodPost: s.keptCopyRemoveHandler(w, r) + // D47 (2026-10-25): „Visszaállítás" from a set-aside recovery unit of an earlier install. + case path == "/backup/kept/restore" && r.Method == http.MethodPost: + s.keptUnitRestoreHandler(w, r) // R-7b: „Megosztások" restore — a sibling of the per-app pair above. case path == "/backup/shares/restore" && r.Method == http.MethodPost: s.sharesRestoreHandler(w, r) diff --git a/controller/internal/web/templates/backups_restore.html b/controller/internal/web/templates/backups_restore.html index ca3b2edf..3fde7d9a 100644 --- a/controller/internal/web/templates/backups_restore.html +++ b/controller/internal/web/templates/backups_restore.html @@ -173,7 +173,14 @@

{{T "backups_restore.megorzott_masolatok_hint"}}

{{range .KeptCopies}} - {{template "app_list_row" dict "Slug" "" "Name" .Source "Secondary" (printf "%s · %s · %s" .Line (numText .SizeHuman) (fmtTimeStr .Since))}} + {{template "app_list_row" dict "Slug" "" "Name" .Source "Secondary" (printf "%s · %s · %s" .Line (numText .SizeHuman) (fmtTimeStr .Since))}}{{if .Restorable}} +
+ {{$.CSRFField}} + + + +
{{end}}
{{$.CSRFField}} @@ -185,7 +192,13 @@ {{end}}
+ + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

Visszaállítás

+
+
+ + +
+
+ + +
+ + +
+ A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával. + Az alkalmazás a folyamat során automatikusan leáll és újraindul. +
+
+ +
+
+ +
+ +
+
+ + + + +
+

Visszaállítás a távoli tárolóból

+

Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.

+ + + + + + +

A távoli tároló üres — nincs mit visszaállítani.

+ + + + + + +
+

Megőrzött másolatok

+

Ezek a második meghajtón lévő másolatok olyan mappákhoz tartoznak, amelyek most nem érhetők el: a meghajtó nincs csatlakoztatva, már nincs a gépen, vagy a mappa hiányzik. Megőrizzük őket, amíg te el nem távolítod őket.

+
+ + +
+ +
+ tandoor + belso · 150 MB · 2026. 10. 25. 09:00 +
+
+ +
+ + + + +
+
+ + + + +
+ +
+
+ + +
+ + +
+

Meglévő ellenőrző másolatok

+ +

Nincs ellenőrző másolat a meghajtón.

+ +
+ + + + +
+

Hordozható mentéscsomag (.fab)

+

Hordozható pillanatfelvétel — bárhol tárolhatod, és bármikor visszatöltheted egy meghajtóról. A folyamatos védelmet az 1–3. szintű mentés adja.

+ +

Nincs telepített alkalmazás.

+ +
+ + + + + + +
+ + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 490255e2..7953381e 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -320,7 +320,9 @@ "note.offsite.fail_apps": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.", "note.offsite.others_ok": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.", "note.offsite.retry_next_night": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.", - "note.offsite.shares_name": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold." + "note.offsite.shares_name": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.", + "flash.kept.restore_needs_confirmation": "BORN AS A KEY (D47, operator ruling 2026-10-25; R-1269) -- the unit of an earlier install is set aside whole and restorable from the restore page; its confirm flash and the second-drive note when it cannot be set aside. Pinned by internal/web/kept_copies_test.go TestD47_* and internal/backup/unit_owner_test.go.", + "note.tier2.unit_other_install_kept": "BORN AS A KEY (D47, operator ruling 2026-10-25; R-1269) -- the unit of an earlier install is set aside whole and restorable from the restore page; its confirm flash and the second-drive note when it cannot be set aside. Pinned by internal/web/kept_copies_test.go TestD47_* and internal/backup/unit_owner_test.go." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",