From 5929b8e345e53d9341c0f0971b8f9cd14a2dde33 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 21 Oct 2026 15:46:10 +0200 Subject: [PATCH] kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased --- CHANGELOG.md | 40 +- REUSE.md | 2 + controller/README.md | 11 + controller/cmd/controller/main.go | 44 +- .../cmd/controller/r1176_ack_order_test.go | 59 ++ controller/internal/appexport/provider.go | 3 + controller/internal/appexport/restore.go | 5 + .../internal/appexport/roundtrip_test.go | 2 + .../internal/backup/r1175_shares_kept_test.go | 168 ++++ .../internal/backup/r1179_r1188c_test.go | 52 + .../backup/r1181_app_legs_kept_test.go | 61 ++ controller/internal/backup/tier2.go | 23 +- controller/internal/backup/tier2_capture.go | 16 +- controller/internal/backup/tier2_kept.go | 174 ++++ controller/internal/backup/tier2_shares.go | 94 +- controller/internal/backup/tier2_whole.go | 8 + controller/internal/i18n/locales/en.json | 20 +- controller/internal/i18n/locales/hu.json | 20 +- controller/internal/settings/kept_copies.go | 81 ++ controller/internal/settings/settings.go | 4 + controller/internal/stacks/deploy_errors.go | 4 +- controller/internal/stacks/import_record.go | 97 ++ .../internal/stacks/r1191_import_gate_test.go | 67 ++ controller/internal/web/fab_export_test.go | 2 + controller/internal/web/handlers.go | 2 + controller/internal/web/i18n_cases_b_test.go | 10 + controller/internal/web/kept_copies.go | 70 ++ controller/internal/web/kept_copies_test.go | 97 ++ controller/internal/web/server.go | 3 + .../web/templates/backups_restore.html | 27 +- controller/internal/web/templates/deploy.html | 2 +- .../i18n_parity/backups_restore_kept.html | 908 ++++++++++++++++++ .../web/testdata/i18n_parity/deploy_new.html | 3 +- controller/scripts/i18n_go_keys.json | 17 +- 34 files changed, 2152 insertions(+), 44 deletions(-) create mode 100644 controller/cmd/controller/r1176_ack_order_test.go create mode 100644 controller/internal/backup/r1175_shares_kept_test.go create mode 100644 controller/internal/backup/r1179_r1188c_test.go create mode 100644 controller/internal/backup/r1181_app_legs_kept_test.go create mode 100644 controller/internal/backup/tier2_kept.go create mode 100644 controller/internal/settings/kept_copies.go create mode 100644 controller/internal/stacks/import_record.go create mode 100644 controller/internal/stacks/r1191_import_gate_test.go create mode 100644 controller/internal/web/kept_copies.go create mode 100644 controller/internal/web/kept_copies_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/backups_restore_kept.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b0beb62..c97b074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ -## Unreleased (2026-10-20/21) — the monitoring page's storage labels follow the reader's language (W26); a finished off-site abandonment closes on the hub's word (R-1182) +## Unreleased (2026-10-20/21) — kept second-drive copies (D25: R-1175, R-1181, R-1179); a `.fab` import keeps the family gate (R-1191); a stale restore temp no longer blocks the retry (R-1188 c); the overcommit sentence made true (R-1192); escrow recorded before the floor (R-1176); a finished off-site abandonment closes on the hub's word (R-1182); storage labels follow the language (W26) +- **D25 / R-1175 / R-1181 (operator ruling 2026-10-21) — kept second-drive copies.** Shares on two drives that copy to + one second drive no longer delete each other's copies: each group reconciles only its own drive key (before: every key + on disk with only its own legs — after every run only the last group had a copy, and the night a drive died its only + second copy went). A copy whose source is gone (drive away/decommissioned, a registered share's folder or an app's + declared folder missing) is KEPT and recorded (`settings.KeptCopy`); the restore page lists it („Megőrzött + másolatok") with „Eltávolítás" (`POST /backup/kept/remove`: the record's path, inside its tree, same device + inode — + never by size or pattern). A share deleted or renamed on a present drive is still pruned. An EMPTIED present folder is + not mirrored over a copy that holds files (`rsync --delete` would empty it): the leg is kept and the run warns + („note.tier2.emptied_kept"). Tests `TestR1175_*` (3), `TestR1181_*` (2), `TestD25_*` (3) — red-proved: the old + reconcile line → both copies tests fail; the guard off with the real `rsync -a --delete` mirror → the emptied copy is + gone; the old tier-2 code → `the MISSING folder's second-drive copy was deleted`. i18n: a NEW parity case + `backups_restore_kept`; every existing restore page renders byte-identically (the section and its script are inside + `{{if .KeptCopies}}`). +- **R-1179:** a drive marked away is never chosen as the second-drive target. `TestR1179_AnAwayDriveIsNotChosenAsTarget` + (red-proved: the copy landed on the away drive). +- **R-1188 (c):** a leftover `.felhom-restoring` from an interrupted whole restore is removed before the copy, so + the retry brings the file back (before: every retry failed „file exists"). `TestR1188c_ALeftoverTempDoesNotBlockTheRetry` + (red-proved with the data-loss hunt's own C3 case). +- **R-1191 — a `.fab` import keeps the family gate.** The import wrote a bare record and the gate reconcile then removed + the gate file: a family-only app came back open to anyone (proven live on 9202). Now `stacks.SaveImportedAppConfig` + builds the record like the unit restore (locked fields, desired state, the PRIOR record's life records and pins) and + `PrepareImportedApp` — called by the import after the config step and before `StartStack` — writes the family gate and, + for an app with no record before, the sign-up lock, reusing `prepareFamilyGate` / `restoreSignupLock`; the in-memory + record is updated so the reconcile keeps the file. Tests `TestR1191_AnImportedFamilyAppKeepsItsDoor` (Glance, MeTube, + Grimmory stanzas) and `TestR1191_AnImportOverAnInstalledAppKeepsItsRecords` — red-proved (no gate step → „no door + after the import"; no carry → the installed app's records lost). +- **R-1192 — the overcommit sentence.** „Normál használat mellett ez nem okoz problémát." was false: the 8 GB box froze + under its first uploads (felhom.eu `audits/fixes-2026-10-21/P4-freeze/`). New keys `deploy.memory_overcommit_slow` / + `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." (EN twin). The fix itself is the agent's (swap = RAM, felhom-agent Unreleased); the install page's + parity fixture carries the new sentence. +- **R-1176 — the ACK order.** `OnPushResponse` records the hub's escrow status BEFORE `SetFloor`: the off-site + apply-bridge GOes when the floor is known, and a rebuilt box must already know the hub holds the household's package + or its mint guard cannot hold. Pinned by `TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown` (AST; red-proved on the old + order). Measured live 2026-10-21 with hub v0.163.0 (felhom.eu `audits/fixes-2026-10-21/P1-live/`): the hub carried + the package at the agent's first report, two minutes before the controller's first ACK, so the box (still 0.336.0) + logged „NOT minting a repository password" — this order change is the belt for a hub that carries later. - **R-1182 (2026-10-21, with hub v0.163.0):** the hub now purges only the recovery packages that open the set-aside copy it deleted, and keeps the packages of other stored copies, so `superseded_present` can stay true after a finished abandonment. The ACK field `escrow.abandon_purge_done` now reaches `report.EscrowStatus`; the confirmer's new hook diff --git a/REUSE.md b/REUSE.md index f413d2c..237127c 100644 --- a/REUSE.md +++ b/REUSE.md @@ -112,6 +112,8 @@ | `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.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 | | `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) | | `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) | diff --git a/controller/README.md b/controller/README.md index 0b6c006..a1d891d 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1624,6 +1624,17 @@ backups/primary// maradtak."). **Every one is a claim about the BACKUP, never about the app** — see CONTEXT.md's ruling and 07-backup-architecture §6.3. +### Kept second-drive copies — a gone source keeps its copy (D25, operator ruling 2026-10-21; R-1175, R-1181) + +A second-drive copy whose source is gone — the drive is away or decommissioned, or a declared folder is missing — is +**kept until the household releases it**. No night job removes it. The restore page („Visszaállítás") lists it under +„Megőrzött másolatok" („a(z) meghajtóról megőrzött másolat · ") with „Eltávolítás" (a confirm, then that +one copy is removed by its record — path inside the copy tree, same device and inode). A share the household deleted or +renamed on a present drive is still pruned. Each share group reconciles only its own drive key (R-1175). An EMPTIED +present folder is not mirrored over a copy that holds files: the run warns and keeps the copy (R-1181). An away drive is +never chosen as the target (R-1179). Code: `internal/backup/tier2_kept.go`, `internal/settings/kept_copies.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 df0726c..cf879c6 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1167,6 +1167,11 @@ func main() { // Hand it to the updater and reconcile — if the box is below the floor it auto-updates to // the floor (reusing the Phase 1 swap). This rides the existing report cycle; no new timer. // latest_version stays informational (the customer's opt-in button), NOT the auto-target. + // SLICE 3: run the escrow auto-confirm on the same ACK. R-1176 (2026-10-21): it runs BEFORE SetFloor, because + // the off-site apply-bridge's settle gate GOes the moment the floor is known — and a rebuilt box must already + // have recorded „the hub holds the household's package" by then, or its mint guard (R-241) cannot stop a + // fresh repository password. Pinned by TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown. + escrowConfirmer.Reconcile(resp.Escrow) if updater != nil { updater.SetFloor(resp.MinControllerVersion) updater.MaybeAutoUpdate() @@ -1196,9 +1201,6 @@ func main() { Logger: logger, } cr.Reconcile(resp.ConfigVersion) - // SLICE 3: run the escrow auto-confirm on the same ACK (after the config refresh decision — - // a refresh-restart re-enters here anyway on the next cycle). - escrowConfirmer.Reconcile(resp.Escrow) // v0.111.0: operator log-tail requests ride the same ACK (pull pattern — the hub // never reaches in). The NEXT report cycle collects + ships the tails; the hub // clears its pending request on receipt. An empty list clears any stale local set. @@ -3380,6 +3382,9 @@ func (a *geoStackAdapter) GetDeployedHostnames() map[string]string { type exportAdapter struct { mgr *stacks.Manager encKey []byte + // R-1191: whether the app had no record before the import (SaveEncryptedAppConfig → PrepareImportedApp). + importFreshMu sync.Mutex + importFresh map[string]bool } func (a *exportAdapter) GetStackDir(name string) (string, bool) { @@ -3522,20 +3527,27 @@ func (a exportStopGuard) End() { a.g.End() } func (a exportStopGuard) ReleaseFailed(stacks ...string) { a.g.ReleaseFailed(stacks...) } func (a *exportAdapter) SaveEncryptedAppConfig(stackDir string, env map[string]string) error { - meta := stacks.LoadMetadata(stackDir) - sensitiveVars := stacks.SensitiveEnvVars(&meta) - cfg := &stacks.AppConfig{ - Deployed: true, - DeployedAt: time.Now().Format(time.RFC3339), - Env: env, - // R-166 — a CUSTOMER-INTENT POINT, and the one that is not the API action switch. Importing - // a `.fab` bundle is the customer installing that app on this box, and the import path starts - // it (appexport/restore.go). Without this the app would come back from a restore with NO - // recorded intent and fall to legacy boot behaviour — meaning a power cut days later would - // strand it, which is exactly the failure this release exists to remove. - DesiredState: stacks.DesiredStateRunning, + // R-1191: the record the unit restore would build (locked fields, the prior record's life records, desired state + // running — R-166), not a bare one; the gates follow in PrepareImportedApp, once the compose file is in place. + fresh, err := a.mgr.SaveImportedAppConfig(stackDir, env) + if err != nil { + return err } - return stacks.SaveAppConfig(stackDir, cfg, a.encKey, sensitiveVars) + a.importFreshMu.Lock() + if a.importFresh == nil { + a.importFresh = map[string]bool{} + } + a.importFresh[stackDir] = fresh + a.importFreshMu.Unlock() + return nil +} + +func (a *exportAdapter) PrepareImportedApp(stackDir string, env map[string]string) error { + a.importFreshMu.Lock() + fresh := a.importFresh[stackDir] + delete(a.importFresh, stackDir) + a.importFreshMu.Unlock() + return a.mgr.PrepareImportedApp(stackDir, env, fresh) } func (a *exportAdapter) RefreshStacks() error { diff --git a/controller/cmd/controller/r1176_ack_order_test.go b/controller/cmd/controller/r1176_ack_order_test.go new file mode 100644 index 0000000..5e762c8 --- /dev/null +++ b/controller/cmd/controller/r1176_ack_order_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// R-1176 (2026-10-21): the off-site apply-bridge waits only until the FLOOR is known (offsiteapply.AwaitSettle), and +// then may mint a repository password unless the box has recorded that the hub holds the household's sealed package +// (R-241's mint guard reads that record). Both facts arrive in the same report ACK, so inside OnPushResponse the +// escrow must be recorded BEFORE updater.SetFloor makes the floor known — otherwise a bridge poll landing between the +// two mints a fresh key on a rebuilt box and the household's history is set aside on its first night. +// Walks main.go's AST with comments dropped, so a commented-out call cannot satisfy it. +// COMPANION RED-PROOF: move escrowConfirmer.Reconcile below updater.SetFloor → this fails. +func TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var escrowPos, floorPos token.Pos + ast.Inspect(f, func(n ast.Node) bool { + as, ok := n.(*ast.AssignStmt) + if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 { + return true + } + sel, ok := as.Lhs[0].(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "OnPushResponse" { + return true + } + ast.Inspect(as.Rhs[0], func(m ast.Node) bool { + call, ok := m.(*ast.CallExpr) + if !ok { + return true + } + if s, ok := call.Fun.(*ast.SelectorExpr); ok { + if x, ok := s.X.(*ast.Ident); ok { + switch { + case x.Name == "escrowConfirmer" && s.Sel.Name == "Reconcile" && escrowPos == 0: + escrowPos = call.Pos() + case x.Name == "updater" && s.Sel.Name == "SetFloor" && floorPos == 0: + floorPos = call.Pos() + } + } + } + return true + }) + return true + }) + if escrowPos == 0 || floorPos == 0 { + t.Fatalf("OnPushResponse no longer calls both escrowConfirmer.Reconcile (%v) and updater.SetFloor (%v) — the order is unasserted", escrowPos, floorPos) + } + if escrowPos > floorPos { + t.Fatalf("escrowConfirmer.Reconcile (%s) runs AFTER updater.SetFloor (%s): a rebuilt box can mint a fresh key before it learns the hub holds the household's package", + fset.Position(escrowPos), fset.Position(floorPos)) + } +} diff --git a/controller/internal/appexport/provider.go b/controller/internal/appexport/provider.go index 36b3877..1bbfb3d 100644 --- a/controller/internal/appexport/provider.go +++ b/controller/internal/appexport/provider.go @@ -53,6 +53,9 @@ type ExportStackProvider interface { // SaveEncryptedAppConfig saves app.yaml with re-encrypted sensitive fields. // env is the plaintext env map from the bundle; encryption uses the current server key. SaveEncryptedAppConfig(stackDir string, env map[string]string) error + // PrepareImportedApp gives the imported app its family gate and sign-up lock BEFORE it starts (R-1191): called + // after the config step (the compose file is in place) and before StartStack. An error means „do not start". + PrepareImportedApp(stackDir string, env map[string]string) error // RefreshStacks rescans all stacks and refreshes container state. RefreshStacks() error // RemoveStackVolumes stops the stack and removes its named Docker volumes. diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go index 80fd964..54eec26 100644 --- a/controller/internal/appexport/restore.go +++ b/controller/internal/appexport/restore.go @@ -459,6 +459,11 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) { job.setStep(step, "running", "") stepStart = time.Now() + // R-1191: the doors first — a family-only app must never start open (the import used to drop its gate). + if err := e.provider.PrepareImportedApp(stackDir, env); err != nil { + e.failJob(job, step, fmt.Sprintf("Alkalmazás előkészítése sikertelen: %v", err)) + return + } e.debugf("starting stack %s", manifest.AppName) if err := e.provider.StartStack(manifest.AppName); err != nil { e.failJob(job, step, fmt.Sprintf("Alkalmazás indítása sikertelen: %v", err)) diff --git a/controller/internal/appexport/roundtrip_test.go b/controller/internal/appexport/roundtrip_test.go index a7818b5..576db10 100644 --- a/controller/internal/appexport/roundtrip_test.go +++ b/controller/internal/appexport/roundtrip_test.go @@ -188,3 +188,5 @@ func TestFabRoundTrip_ExportImportContentEquality(t *testing.T) { t.Error("import must start the restored app") } } + +func (p *rtProvider) PrepareImportedApp(string, map[string]string) error { return nil } diff --git a/controller/internal/backup/r1175_shares_kept_test.go b/controller/internal/backup/r1175_shares_kept_test.go new file mode 100644 index 0000000..6f51905 --- /dev/null +++ b/controller/internal/backup/r1175_shares_kept_test.go @@ -0,0 +1,168 @@ +package backup + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// R-1175 + D25 (operator ruling 2026-10-21), scenario B. The first two tests are the data-loss hunt's C3 cases +// (felhom.eu audits/dataloss-hunt-2026-10-20/C3-D1-shares-tier2/), now asserting instead of logging. + +func sharesCopy(env *sharesEnv, target, srcDrive, share string) string { + return filepath.Join(NamespaceRoot(env.drives[target], true), "backups", "secondary", SharesPseudoStack, + sharesDriveKey(env.drives[srcDrive]), share) +} + +func mustExist(t *testing.T, p, why string) { + t.Helper() + if _, err := os.Stat(p); err != nil { + t.Fatalf("%s: %s is gone (%v)", why, p, err) + } +} + +// Shares on hdd_2 and hdd_3 both copy to hdd_1: after EVERY run both groups' copies exist. Before the fix each group's +// reconcile walked every drive key and removed the other group's copy. +func TestR1175_TwoDrivesSharingOneTargetKeepBothCopies(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") + env.addShare(t, "hdd_2", "b_share", true) + env.addShare(t, "hdd_3", "c_share", true) + for run := 1; run <= 2; run++ { + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "run "+string(rune('0'+run))+": hdd_2's share copy") + mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_3", "c_share"), "c_share.txt"), "run "+string(rune('0'+run))+": hdd_3's share copy") + } +} + +// The night a drive is away its copy is NOT deleted: it stays, and it is listed as kept (drive_away). +func TestR1175_AnAwayDrivesShareCopyIsKeptAndListed(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") + env.addShare(t, "hdd_2", "b_share", true) + env.addShare(t, "hdd_3", "csaladi_fotok", true) + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + cp := sharesCopy(env, "hdd_1", "hdd_3", "csaladi_fotok") + mustExist(t, filepath.Join(cp, "csaladi_fotok.txt"), "the away drive's only second copy") + kept := env.m.KeptCopies() + if len(kept) != 1 || kept[0].Source != "csaladi_fotok" || kept[0].Reason != "drive_away" || kept[0].Path != filepath.Clean(cp) { + t.Fatalf("kept copies = %+v, want csaladi_fotok (drive_away) at %s", kept, cp) + } + // The drive comes back: the copy is live again and its record goes. + if err := env.sett.SetDisconnected(env.drives["hdd_3"], false, nil); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + if k := env.m.KeptCopies(); len(k) != 0 { + t.Fatalf("after the drive came back, kept copies = %+v, want none", k) + } +} + +// A registered share whose FOLDER is missing (drive present) keeps its copy; a share the household DELETED is pruned. +func TestR1175_MissingFolderKeptDeletedSharePruned(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + gone := env.addShare(t, "hdd_1", "eltunt", true) + env.addShare(t, "hdd_1", "torolt", true) + env.addShare(t, "hdd_1", "marad", true) + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(gone); err != nil { + t.Fatal(err) + } + if err := env.sett.RemoveSMBShare("torolt"); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "eltunt"), "eltunt.txt"), "a missing folder's copy") + if _, err := os.Stat(sharesCopy(env, "hdd_2", "hdd_1", "torolt")); !os.IsNotExist(err) { + t.Fatalf("a deleted share's copy was not pruned (stat err %v)", err) + } + if k := env.m.KeptCopies(); len(k) != 1 || k[0].Source != "eltunt" || k[0].Reason != "folder_missing" { + t.Fatalf("kept copies = %+v, want eltunt (folder_missing)", k) + } +} + +// An EMPTIED present folder is not mirrored over a copy that holds files: the copy stays and the run warns. +func TestR1181_AnEmptiedShareDoesNotEmptyItsCopy(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2") + if _, err := exec.LookPath("rsync"); err == nil { + env.m.tier2Mirror = rsyncMirror // the production mirror: `rsync -a --delete` is what empties a copy + } + p := env.addShare(t, "hdd_1", "fotok", true) + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(p, "fotok.txt")); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "fotok"), "fotok.txt"), "an emptied folder's copy") + st := env.m.SharesTier2Status() + if st == nil || !strings.Contains(st.LastWarning, "fotok") { + t.Fatalf("the run did not warn about the emptied folder: %+v", st) + } +} + +// „Eltávolítás" removes ONE kept copy by its record, and refuses a directory that is not the recorded one. +func TestD25_RemovingAKeptCopyIsByRecordAndIdentity(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") + env.addShare(t, "hdd_2", "b_share", true) + env.addShare(t, "hdd_3", "c_share", true) + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + if err := env.sett.SetDecommissioned(env.drives["hdd_3"], ""); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + k := env.m.KeptCopies() + if len(k) != 1 || k[0].Reason == "" { + t.Fatalf("kept = %+v", k) + } + // Swap the directory for another one at the same path: same name, different identity → refused, nothing removed. + cp := k[0].Path + if err := os.Rename(cp, cp+".old"); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cp, 0o755); err != nil { + t.Fatal(err) + } + if err := env.m.RemoveKeptCopy(k[0].ID); err == nil { + t.Fatal("a directory that is not the recorded one was removed") + } + mustExist(t, cp, "the refused removal") + // Put the recorded one back → removed, and only it. + _ = os.Remove(cp) + if err := os.Rename(cp+".old", cp); err != nil { + t.Fatal(err) + } + if err := env.m.RemoveKeptCopy(k[0].ID); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(cp); !os.IsNotExist(err) { + t.Fatalf("the kept copy is still there: %v", err) + } + mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "the other drive's copy") + if len(env.m.KeptCopies()) != 0 { + t.Fatal("the record outlived the removal") + } +} diff --git a/controller/internal/backup/r1179_r1188c_test.go b/controller/internal/backup/r1179_r1188c_test.go new file mode 100644 index 0000000..9de6b29 --- /dev/null +++ b/controller/internal/backup/r1179_r1188c_test.go @@ -0,0 +1,52 @@ +package backup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// R-1179: a drive marked AWAY is never the second-drive target. Seen in the R-1175 unit test of the data-loss hunt +// (a share copied to a drive marked disconnected). COMPANION RED-PROOF: drop the Disconnected skip → the copy lands +// on hdd_1. +func TestR1179_AnAwayDriveIsNotChosenAsTarget(t *testing.T) { + env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") + env.addShare(t, "hdd_2", "b_share", true) + if err := env.sett.SetDisconnected(env.drives["hdd_1"], true, nil); err != nil { + t.Fatal(err) + } + if err := env.m.RunSharesTier2(); err != nil { + t.Fatal(err) + } + for _, call := range env.mirrored { + if strings.Contains(call, "=>"+env.drives["hdd_1"]+string(os.PathSeparator)) { + t.Fatalf("a copy went to the AWAY drive hdd_1: %s", call) + } + } + if _, err := os.Stat(filepath.Join(sharesCopy(env, "hdd_3", "hdd_2", "b_share"), "b_share.txt")); err != nil { + t.Fatalf("no copy on the present drive hdd_3: %v", err) + } +} + +// R-1188 (c): the data-loss hunt's C3 case (felhom.eu audits/dataloss-hunt-2026-10-20/C3-R12-leftover-temp/): a power +// cut mid-copy leaves `.felhom-restoring`; the retry must bring the file back instead of failing „file exists". +// COMPANION RED-PROOF: drop the leftover removal in copyFilePreserving → the retry fails and p1.jpg stays missing. +func TestR1188c_ALeftoverTempDoesNotBlockTheRetry(t *testing.T) { + f, _, liveLeg := wholeFixture(t) + if err := os.MkdirAll(liveLeg, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(liveLeg, "p1.jpg.felhom-restoring"), []byte("half"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := f.m.RestoreTier2Whole("app"); err != nil { + t.Fatalf("the retry failed: %v", err) + } + if _, err := os.Stat(filepath.Join(liveLeg, "p1.jpg")); err != nil { + t.Fatalf("p1.jpg is not back: %v", err) + } + if _, err := os.Stat(filepath.Join(liveLeg, "p1.jpg.felhom-restoring")); !os.IsNotExist(err) { + t.Fatalf("the leftover temp is still there: %v", err) + } +} diff --git a/controller/internal/backup/r1181_app_legs_kept_test.go b/controller/internal/backup/r1181_app_legs_kept_test.go new file mode 100644 index 0000000..b766c58 --- /dev/null +++ b/controller/internal/backup/r1181_app_legs_kept_test.go @@ -0,0 +1,61 @@ +package backup + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// R-1181 + D25 for app folders — the data-loss hunt's C3 case (felhom.eu audits/dataloss-hunt-2026-10-20/ +// C3-D2-missing-leg/), asserting: a MISSING declared folder keeps its second-drive copy and is listed as kept; an +// EMPTIED folder is not mirrored over its full copy (the production `rsync -a --delete` mirror is used when present). +func TestR1181_MissingAndEmptiedAppFoldersKeepTheirCopies(t *testing.T) { + m, src, target, prov := newTier2V2(t, "app") + if _, err := exec.LookPath("rsync"); err == nil { + m.tier2Mirror = rsyncMirror + } + prov.has["app"] = true + prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos"), mHDD("appdata/app/docs")} + mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO") + mustWrite(t, filepath.Join(src, "appdata", "app", "docs", "contract.pdf"), "PDF") + if err := m.RunTier2("app"); err != nil { + t.Fatal(err) + } + destBase := filepath.Join(target, "backups", "secondary", "app") + photo := filepath.Join(destBase, "hdd", "appdata", "app", "photos", "family.jpg") + doc := filepath.Join(destBase, "hdd", "appdata", "app", "docs", "contract.pdf") + if !exists(photo) || !exists(doc) { + t.Fatal("precondition: run 1 did not copy both folders") + } + if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(src, "appdata", "app", "docs", "contract.pdf")); err != nil { + t.Fatal(err) + } + if err := m.RunTier2("app"); err != nil { + t.Fatal(err) + } + if !exists(photo) { + t.Fatal("the MISSING folder's second-drive copy was deleted (R-1181)") + } + if !exists(doc) { + t.Fatal("the EMPTIED folder was mirrored over its full copy (R-1181)") + } + kept := m.KeptCopies() + if len(kept) != 1 || kept[0].Kind != "app" || kept[0].Reason != "folder_missing" { + t.Fatalf("kept copies = %+v, want the photos folder", kept) + } + if cd := m.settings.GetCrossDriveConfig("app"); cd == nil || cd.LastWarning == "" { + t.Fatalf("the run did not warn: %+v", cd) + } + // The folder comes back: mirrored again, the record goes. + mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO") + if err := m.RunTier2("app"); err != nil { + t.Fatal(err) + } + if len(m.KeptCopies()) != 0 { + t.Fatalf("the record outlived the folder's return: %+v", m.KeptCopies()) + } +} diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index 3fbf97c..6e56b37 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -152,6 +152,12 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, if sp.Path == sourceDrive { continue } + // R-1179: a drive marked away is never a target. Its mount point is an empty folder on the guest's root + // disk (or the host's), so the night's copy would fill the wrong disk and not be where the household + // thinks. Decommissioned drives are already left out by GetSchedulableStoragePaths. + if sp.Disconnected { + continue + } if sp.IsNetwork() { sawNetworkCandidate = true // F-6C-1: never a tier-2 target continue @@ -350,7 +356,7 @@ func (m *Manager) RunTier2(stackName string) error { } // Class-driven legs (classified) / resolver legs (legacy) + loud gap warnings (§7-H). - legs, warns := m.tier2CaptureSet(stackName, sourceNsRoot) + legs, missingLegs, warns := m.tier2CaptureSetFull(stackName, sourceNsRoot) // Two sizes: full = unit + all legs; state-only = unit + mandatory legs (the SSD ceiling). unitSize := dirSizeBytes(unitDir) @@ -440,7 +446,13 @@ func (m *Manager) RunTier2(stackName string) error { legRels := make([]string, 0, len(legs)) mirroredSize := unitSize for _, lg := range legs { - if err := mirror(lg.Src, filepath.Join(destBase, filepath.FromSlash(lg.DestRel))); err != nil { + dst := filepath.Join(destBase, filepath.FromSlash(lg.DestRel)) + if m.emptiedSourceRefused(stackName+" "+lg.DestRel, lg.Src, dst) { + legRels = append(legRels, lg.DestRel) // kept as it is (R-1181) + warns = append(warns, m.note("note.tier2.emptied_kept", lg.DestRel)) + continue + } + if err := mirror(lg.Src, dst); err != nil { m.recordTier2Failure(stackName, target, err) if m.tier2Notify != nil { m.tier2Notify(stackName, target.Label, time.Since(start), err) @@ -449,6 +461,13 @@ func (m *Manager) RunTier2(stackName string) error { } legRels = append(legRels, lg.DestRel) mirroredSize += dirSizeBytes(lg.Src) + m.releaseKeptCopy(dst) + } + // D25 / R-1181: a declared folder that is MISSING keeps its copy (kept + listed), never reconciled away. + for _, lg := range missingLegs { + legRels = append(legRels, lg.DestRel) + m.keepCopy("app", stackName+": "+lg.DestRel, m.storageLabel(sourceDrive), "folder_missing", destBase, + filepath.Join(destBase, filepath.FromSlash(lg.DestRel))) } // Reconcile stale dest dirs (a bind removed / re-classed excluded), then write the marker LAST. diff --git a/controller/internal/backup/tier2_capture.go b/controller/internal/backup/tier2_capture.go index 95dd4a5..f8f571b 100644 --- a/controller/internal/backup/tier2_capture.go +++ b/controller/internal/backup/tier2_capture.go @@ -40,6 +40,14 @@ func tier2DestRel(root appbackup.BindRoot, rel string) string { // footprint promise), mapped into the same v2 layout so restore has ONE reader. Missing/refused // mandatory paths are stat-filtered + surfaced loudly (§7-H). func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns []string) { + legs, _, warns = m.tier2CaptureSetFull(stack, nsRoot) + return legs, warns +} + +// tier2CaptureSetFull is tier2CaptureSet plus the DECLARED legs that are missing on disk (mandatory and optional). +// D25 / R-1181 (2026-10-21): a missing declared folder keeps its second-drive copy — RunTier2 adds these to the +// reconcile's keep set and lists them as kept, instead of pruning the copy that may be the only one left. +func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tier2Leg, warns []string) { // Classified path. if m.stackProvider != nil { if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has { @@ -58,7 +66,8 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns m.logger.Printf("[WARN] [backup] Tier 2 %s: mandatory data path missing on disk, skipped: %s", stack, p.Abs) gaps = append(gaps, p.RelPath) } - continue // optional-missing is silent (not a gap) + missing = append(missing, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class}) + continue // optional-missing is silent (not a gap); its copy is kept (D25) } legs = append(legs, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class}) } @@ -66,7 +75,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a másodlagos mentésbe: %s.", stack, strings.Join(gaps, ", "))) } - return legs, warns + return legs, missing, warns } } // Legacy path: the resolver appdata dir(s) → hdd/appdata/ legs (mandatory-equivalent for SSD @@ -79,6 +88,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns if declared { m.logger.Printf("[WARN] [backup] Tier 2 %s: compose declares appdata dir %q but it is absent at %s — leg skipped", stack, name, src) + missing = append(missing, tier2Leg{Src: src, DestRel: filepath.ToSlash(filepath.Join("hdd", "appdata", name)), Class: appbackup.ClassMandatory}) } continue } @@ -88,5 +98,5 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns Class: appbackup.ClassMandatory, }) } - return legs, warns + return legs, missing, warns } diff --git a/controller/internal/backup/tier2_kept.go b/controller/internal/backup/tier2_kept.go new file mode 100644 index 0000000..f3ab4c8 --- /dev/null +++ b/controller/internal/backup/tier2_kept.go @@ -0,0 +1,174 @@ +package backup + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// ── D25 (operator ruling 2026-10-21; R-1175 + R-1181): a second-drive copy whose SOURCE is gone is KEPT ───────────── +// +// A source is gone when its drive is away or decommissioned, or a declared folder is missing. The night job never +// removes such a copy: it records it (settings.KeptCopy), the backups page lists it as kept, and only the household's +// „Eltávolítás" removes it — by its recorded path, identity-checked (device + inode), never by size or a name pattern. +// A share the household DELETED or RENAMED on a present drive is still pruned (that is the household's own act). +// +// 07 §8.2 chose „data legs are not guarded" for a leg that LEAVES the class set; a gone source is a different case, +// and D25 decides it the other way: the copy may be the only one left. + +// keepCopy records path (an existing directory inside destBase) as a kept copy. A missing path records nothing — there +// is nothing to keep. +func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) { + if m.settings == nil { + return + } + st, err := os.Stat(path) + if err != nil || !st.IsDir() { + return + } + dev, ino := statIdentity(st) + k := settings.KeptCopy{Kind: kind, Source: source, Drive: drive, Reason: reason, DestBase: filepath.Clean(destBase), + Path: filepath.Clean(path), Dev: dev, Ino: ino, SizeBytes: dirSizeBytes(path)} + if err := m.settings.UpsertKeptCopy(k); err != nil { + 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) +} + +// releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched. +func (m *Manager) releaseKeptCopy(path string) { + if m.settings == nil { + return + } + id := settings.KeptCopyID(filepath.Clean(path)) + for _, k := range m.settings.GetKeptCopies() { + if k.ID == id { + _ = m.settings.DropKeptCopy(id) + m.logger.Printf("[INFO] [backup] kept copy %s is live again (its source is back) — record dropped", path) + return + } + } +} + +// KeptCopies lists the kept copies for the backups page. +func (m *Manager) KeptCopies() []settings.KeptCopy { + if m.settings == nil { + return nil + } + return m.settings.GetKeptCopies() +} + +// ErrKeptCopyNotFound / ErrKeptCopyChanged are the refusals of RemoveKeptCopy. +var ( + ErrKeptCopyNotFound = errors.New("kept copy not found") + ErrKeptCopyChanged = errors.New("kept copy is not the recorded directory any more") +) + +// RemoveKeptCopy is the household's „Eltávolítás": it removes ONE kept copy by its record — the recorded path, still +// strictly inside the recorded copy tree, and still the same directory (device + inode) the night recorded. Anything +// else refuses and removes nothing. A copy already gone just drops its record. +func (m *Manager) RemoveKeptCopy(id string) error { + if m.settings == nil { + return ErrKeptCopyNotFound + } + var rec *settings.KeptCopy + for _, k := range m.settings.GetKeptCopies() { + if k.ID == id { + kk := k + rec = &kk + break + } + } + if rec == nil { + return ErrKeptCopyNotFound + } + st, err := os.Lstat(rec.Path) + if errors.Is(err, fs.ErrNotExist) { + m.logger.Printf("[INFO] [backup] kept copy %s is already gone — record dropped", rec.Path) + return m.settings.DropKeptCopy(id) + } + if err != nil { + return fmt.Errorf("kept copy %s: %w", rec.Path, err) + } + if !st.IsDir() { + return fmt.Errorf("%w: %s is not a directory", ErrKeptCopyChanged, rec.Path) + } + if dev, ino := statIdentity(st); dev != rec.Dev || ino != rec.Ino { + m.logger.Printf("[WARN] [backup] kept copy removal REFUSED: %s is not the recorded directory (identity changed)", rec.Path) + return fmt.Errorf("%w: %s", ErrKeptCopyChanged, rec.Path) + } + rel, err := filepath.Rel(rec.DestBase, rec.Path) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return fmt.Errorf("%w: %s is not inside %s", ErrKeptCopyChanged, rec.Path, rec.DestBase) + } + if err := tier2SafeRemove(rec.DestBase, rec.Path); err != nil { + return err + } + m.logger.Printf("[INFO] [backup] kept copy REMOVED by the household: %s %q (%s) at %s", rec.Kind, rec.Source, humanizeBytes(rec.SizeBytes), rec.Path) + return m.settings.DropKeptCopy(id) +} + +func statIdentity(st os.FileInfo) (dev, ino uint64) { + if s, ok := st.Sys().(*syscall.Stat_t); ok { + return uint64(s.Dev), s.Ino + } + return 0, 0 +} + +// ── R-1181 second half: an EMPTIED present folder is not mirrored over a full copy ──────────────────────────────── + +// hasRegularFile reports whether dir holds at least one regular file anywhere below it (stops at the first). +func hasRegularFile(dir string) bool { + found := false + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.Type().IsRegular() { + found = true + return fs.SkipAll + } + return nil + }) + return found +} + +// countRegularFiles counts the regular files below dir (for the log line only). +func countRegularFiles(dir string) int { + n := 0 + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err == nil && d.Type().IsRegular() { + n++ + } + return nil + }) + return n +} + +// emptiedSourceRefused reports whether mirroring src over dst would turn a copy that holds files into an empty one +// (`rsync --delete` from an emptied folder). Such a leg is refused for this run: the copy stays, the run warns. +func (m *Manager) emptiedSourceRefused(what, src, dst string) bool { + if hasRegularFile(src) || !hasRegularFile(dst) { + return false + } + m.logger.Printf("[WARN] [backup] Tier 2: %s — the source folder is EMPTY (0 files) and its second-drive copy holds %d file(s); NOT mirrored, the copy is kept: %s", + what, countRegularFiles(dst), dst) + return true +} + +// storageLabel is the label the pages show for a registered drive path (its path when it has none). +func (m *Manager) storageLabel(path string) string { + if m.settings != nil { + if l := m.settings.GetStorageLabel(path); l != "" { + return l + } + } + return path +} diff --git a/controller/internal/backup/tier2_shares.go b/controller/internal/backup/tier2_shares.go index 2d03e37..f7dfff2 100644 --- a/controller/internal/backup/tier2_shares.go +++ b/controller/internal/backup/tier2_shares.go @@ -128,6 +128,18 @@ func (m *Manager) RunSharesTier2() error { } g.shares = append(g.shares, sh) } + // D25: every REGISTERED share per source drive, available or not — a registered share whose folder is missing + // keeps its copy (and is listed as kept); only a share the household deleted or renamed is pruned. + registeredByDrive := map[string][]settings.SMBShare{} + if m.settings != nil { + for _, sh := range m.settings.GetSMBShares() { + if d := m.shareSourceDrive(sh.Path); d != "" { + registeredByDrive[d] = append(registeredByDrive[d], sh) + } + } + } + touched := map[string]map[string]bool{} // destBase → the drive keys handled there this run + var groups []*sharesTier2Group for _, g := range byDrive { groups = append(groups, g) @@ -202,7 +214,13 @@ func (m *Manager) RunSharesTier2() error { legRels := make([]string, 0, len(legs)+1) for _, sh := range legs { rel := g.key + "/" + sh.Name - if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil { + dst := filepath.Join(destBase, filepath.FromSlash(rel)) + if m.emptiedSourceRefused("share "+sh.Name, sh.Path, dst) { + legRels = append(legRels, rel) // kept as it is + warns = append(warns, m.note("note.tier2.emptied_kept", sh.Name)) + continue + } + if err := mirror(sh.Path, dst); err != nil { m.recordTier2Failure(SharesPseudoStack, target, err) if m.tier2Notify != nil { // DISPLAY BOUNDARY: the notifier feeds Hungarian customer/operator copy, so the @@ -215,6 +233,16 @@ func (m *Manager) RunSharesTier2() error { legRels = append(legRels, rel) totalSize += dirSizeBytes(sh.Path) mirrored++ + m.releaseKeptCopy(dst) + } + // D25: a registered share of this drive whose folder is missing keeps its copy (and is listed as kept). + for _, sh := range registeredByDrive[g.sourceDrive] { + if m.shareBackupAvailable(sh.Path) { + continue + } + rel := g.key + "/" + sh.Name + legRels = append(legRels, rel) + m.keepCopy("share", sh.Name, m.storageLabel(g.sourceDrive), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel))) } // The payload rides every target so each one is independently restorable. if payloadDir != "" { @@ -227,7 +255,15 @@ func (m *Manager) RunSharesTier2() error { // Prune dest dirs no share covers any more (a share deleted or renamed since the last run // stops occupying the secondary drive within one run), then the marker LAST — a half-written // dest therefore has no marker and the restore path refuses it until the next good run. - m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels) + // + // R-1175: ONLY this group's own drive key. Walking every key on disk with only this group's legs removed + // every OTHER group's copies on a shared target (after each run only the last group had a copy) and, on the + // night a drive died, its only second copy. Keys no group handled this run are dealt with once, below. + m.tier2ReconcileRoots(destBase, []string{g.key}, legRels) + if touched[destBase] == nil { + touched[destBase] = map[string]bool{} + } + touched[destBase][g.key] = true if err := m.writeTier2Marker(destBase); err != nil { m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err) } @@ -236,6 +272,10 @@ func (m *Manager) RunSharesTier2() error { len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize)) } + for destBase, done := range touched { + m.sharesTier2OtherKeys(destBase, done, registeredByDrive) + } + dur := time.Since(start) if lastTarget == nil { reason := strings.Join(noTargetWhy, "; ") @@ -260,9 +300,7 @@ func (m *Manager) RunSharesTier2() error { return nil } -// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may -// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that -// is no longer registered still gets visited (and pruned) instead of lingering forever. +// sharesTier2DestRoots lists the top-level drive-key dirs under a shares destBase (the payload dir is not one). func sharesTier2DestRoots(destBase string) []string { entries, err := os.ReadDir(destBase) if err != nil { @@ -270,7 +308,7 @@ func sharesTier2DestRoots(destBase string) []string { } var roots []string for _, e := range entries { - if e.IsDir() { + if e.IsDir() && e.Name() != sharesPayloadDestRel { roots = append(roots, e.Name()) } } @@ -278,6 +316,50 @@ func sharesTier2DestRoots(destBase string) []string { return roots } +// sharesTier2OtherKeys handles the drive keys on destBase that no group mirrored this run (D25, R-1175): +// - the key of a registered drive that is PRESENT: its shares were deleted, renamed or have missing folders — a +// registered share's copy is kept (listed), a deleted/renamed share's copy is pruned (the household's own act); +// - the key of a drive that is AWAY or DECOMMISSIONED, or of no registered drive at all: every copy under it is +// kept and listed. Before 2026-10-21 these were pruned on the first run without them. +func (m *Manager) sharesTier2OtherKeys(destBase string, done map[string]bool, registeredByDrive map[string][]settings.SMBShare) { + byKey := map[string]settings.StoragePath{} + if m.settings != nil { + for _, sp := range m.settings.GetStoragePaths() { + byKey[sharesDriveKey(sp.Path)] = sp + } + } + for _, key := range sharesTier2DestRoots(destBase) { + if done[key] { + continue + } + keyDir := filepath.Join(destBase, key) + sp, known := byKey[key] + if known && !sp.Disconnected && !sp.Decommissioned { + var keep []string + for _, sh := range registeredByDrive[sp.Path] { + rel := key + "/" + sh.Name + keep = append(keep, rel) + m.keepCopy("share", sh.Name, m.storageLabel(sp.Path), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel))) + } + m.tier2ReconcileRoots(destBase, []string{key}, keep) + continue + } + reason, drive := "drive_gone", key + if known { + drive = m.storageLabel(sp.Path) + if sp.Disconnected { + reason = "drive_away" + } + } + entries, _ := os.ReadDir(keyDir) + for _, e := range entries { + if e.IsDir() { + m.keepCopy("share", e.Name(), drive, reason, destBase, filepath.Join(keyDir, e.Name())) + } + } + } +} + // writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its // presence means "every leg and the reconcile completed". func (m *Manager) writeTier2Marker(destBase string) error { diff --git a/controller/internal/backup/tier2_whole.go b/controller/internal/backup/tier2_whole.go index ebbcc28..64a62d7 100644 --- a/controller/internal/backup/tier2_whole.go +++ b/controller/internal/backup/tier2_whole.go @@ -84,6 +84,14 @@ func copyFilePreserving(src, dst string, fi fs.FileInfo) error { } defer in.Close() tmp := dst + ".felhom-restoring" + // R-1188 (c): an interrupted earlier restore (a power cut mid-copy) leaves this exact temp behind, and O_EXCL then + // failed EVERY retry with „file exists" — the file never came back. The temp is ours by name; a leftover REGULAR + // file of that one name is removed before the copy. Anything else at that name (a directory, a link) still refuses. + if st, lerr := os.Lstat(tmp); lerr == nil && st.Mode().IsRegular() { + if rerr := os.Remove(tmp); rerr != nil { + return fmt.Errorf("leftover %s from an interrupted restore could not be removed: %w", filepath.Base(tmp), rerr) + } + } out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fi.Mode().Perm()) if err != nil { return err diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 038bfba..f4fffb8 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1138,7 +1138,6 @@ "deploy.az_alkalmazas_elindult_de_meg": "The app started, but it is still initialising.", "deploy.az_alkalmazas_email_jelenleg_ki": "App e-mail is turned off globally right now. Turn it on on the\n Settings page for it to work here too.", "deploy.az_alkalmazas_sikeresen_telepitve_es": "The app is installed and running!", - "deploy.az_alkalmazasok_csucsterhelese_meghaladh": "The apps’ peak load can go above the available memory.\n In normal use this causes no problem.", "deploy.beallitasok": "Settings", "deploy.beallitasok_es_jelszavak_megtekintese": "View settings and passwords", "deploy.bejelentkezes": "Sign-in", @@ -1419,7 +1418,6 @@ "err.stacks.ismeretlen_migracios_hatokor": "unknown migration scope: %s", "err.stacks.masolas_sikertelen_appdata": "the copy failed (%s app data): %s", "err.stacks.masolas_sikertelen_mentes": "the copy failed (%s backup): %s", - "err.stacks.memory_overcommit_warning": "At peak, the apps together may need more memory than there is. In normal use this causes no trouble.", "err.stacks.migracio_mar_folyamatban": "a migration is already running", "err.stacks.migracios_naplo_irasa": "writing the migration log: %s", "err.stacks.nincs_eleg_hely_a_celtarolon_kb": "not enough space on the target storage (about %d GB needed, %.1f GB free)", @@ -2804,5 +2802,21 @@ "host_storage.pbs.purpose": "Encrypted backups kept away from your home.", "host_storage.nfs.label": "Network backup (NFS)", "host_storage.cifs.label": "Network backup (SMB)", - "host_storage.network.purpose": "Backups kept on a network storage." + "host_storage.network.purpose": "Backups kept on a network storage.", + "backups_restore.megorzott_masolatok": "Kept copies", + "backups_restore.megorzott_masolatok_hint": "These second-drive copies belong to folders that cannot be reached now: the drive is not connected, is no longer in the box, or the folder is missing. We keep them until you remove them.", + "backups_restore.eltavolitas": "Remove", + "backups_restore.megorzott_eltavolitas_megerosites": "Remove this kept copy? It is deleted from the second drive for good.", + "kept.from_drive": "a copy kept from the %s drive", + "kept.reason.drive_away": "the drive is not connected", + "kept.reason.drive_gone": "the drive is no longer in the box", + "kept.reason.folder_missing": "the folder is missing", + "note.tier2.emptied_kept": "The folder %s is empty, so its second-drive copy was not overwritten — the earlier copy is kept.", + "flash.kept.removed": "The kept copy was removed.", + "flash.kept.needs_confirmation": "Confirm the question to remove it.", + "flash.kept.not_found": "This kept copy is no longer there.", + "flash.kept.changed": "Another folder is in the copy's place now, so it was not removed.", + "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.", + "err.stacks.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." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 5a3784b..4ce9430 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1133,7 +1133,6 @@ "deploy.az_alkalmazas_elindult_de_meg": "Az alkalmazás elindult, de még inicializálódik.", "deploy.az_alkalmazas_email_jelenleg_ki": "Az alkalmazás-email jelenleg ki van kapcsolva globálisan. Kapcsold be a\n Beállítások oldalon, hogy itt is működjön.", "deploy.az_alkalmazas_sikeresen_telepitve_es": "Az alkalmazás sikeresen telepítve és fut!", - "deploy.az_alkalmazasok_csucsterhelese_meghaladh": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát.\n Normál használat mellett ez nem okoz problémát.", "deploy.beallitasok": "Beállítások", "deploy.beallitasok_es_jelszavak_megtekintese": "Beállítások és jelszavak megtekintése", "deploy.bejelentkezes": "Bejelentkezés", @@ -1410,7 +1409,6 @@ "err.stacks.ismeretlen_migracios_hatokor": "ismeretlen migrációs hatókör: %s", "err.stacks.masolas_sikertelen_appdata": "másolás sikertelen (%s appdata): %s", "err.stacks.masolas_sikertelen_mentes": "másolás sikertelen (%s mentés): %s", - "err.stacks.memory_overcommit_warning": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Normál használat mellett ez nem okoz problémát.", "err.stacks.migracio_mar_folyamatban": "migráció már folyamatban", "err.stacks.migracios_naplo_irasa": "migrációs napló írása: %s", "err.stacks.nincs_eleg_hely_a_celtarolon_kb": "nincs elég hely a céltárolón (kb. %d GB szükséges, %.1f GB szabad)", @@ -2792,5 +2790,21 @@ "host_storage.pbs.purpose": "Titkosított, telephelyen kívüli biztonsági mentések.", "host_storage.nfs.label": "Hálózati mentés (NFS)", "host_storage.cifs.label": "Hálózati mentés (SMB)", - "host_storage.network.purpose": "Hálózati tárolón őrzött biztonsági mentések." + "host_storage.network.purpose": "Hálózati tárolón őrzött biztonsági mentések.", + "backups_restore.megorzott_masolatok": "Megőrzött másolatok", + "backups_restore.megorzott_masolatok_hint": "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.", + "backups_restore.eltavolitas": "Eltávolítás", + "backups_restore.megorzott_eltavolitas_megerosites": "Biztosan eltávolítod ezt a megőrzött másolatot? A második meghajtóról véglegesen törlődik.", + "kept.from_drive": "a(z) %s meghajtóról megőrzött másolat", + "kept.reason.drive_away": "a meghajtó nincs csatlakoztatva", + "kept.reason.drive_gone": "a meghajtó már nincs a gépen", + "kept.reason.folder_missing": "a mappa hiányzik", + "note.tier2.emptied_kept": "A(z) %s mappa üres, ezért a másodlagos másolatát nem írtuk felül — a korábbi példány megmaradt.", + "flash.kept.removed": "A megőrzött másolat eltávolítva.", + "flash.kept.needs_confirmation": "Az eltávolításhoz erősítsd meg a kérdést.", + "flash.kept.not_found": "Ez a megőrzött másolat már nincs meg.", + "flash.kept.changed": "A másolat helyén már egy másik mappa van, ezért nem távolítottuk el.", + "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.", + "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." } diff --git a/controller/internal/settings/kept_copies.go b/controller/internal/settings/kept_copies.go new file mode 100644 index 0000000..53f4c7e --- /dev/null +++ b/controller/internal/settings/kept_copies.go @@ -0,0 +1,81 @@ +package settings + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// KeptCopy is one second-drive copy whose source is gone (D25, operator ruling 2026-10-21). The record is what the +// backups page lists and what „Eltávolítás" removes — by Path, identity-checked against Dev/Ino, never by size or a +// name pattern (07 §8.2 as amended by D25). +type KeptCopy struct { + ID string `json:"id"` // stable: sha256 of Path, first 16 hex + Kind string `json:"kind"` // "share" | "app" + Source string `json:"source"` // the share's name, or ": " + Drive string `json:"drive"` // the SOURCE drive's label (or its path when the drive is no longer registered) + Reason string `json:"reason"` // "drive_away" | "drive_gone" | "folder_missing" + DestBase string `json:"dest_base"` // the copy tree it lives in; Path must stay inside it + Path string `json:"path"` // the copy's absolute directory + Dev uint64 `json:"dev"` // identity at record time + Ino uint64 `json:"ino"` // identity at record time + SizeBytes int64 `json:"size_bytes"` // at the latest run + Since string `json:"since"` // RFC3339, first time it was kept +} + +// KeptCopyID is the record's id for a copy path. +func KeptCopyID(path string) string { + h := sha256.Sum256([]byte(path)) + return hex.EncodeToString(h[:8]) +} + +// GetKeptCopies returns a copy of the kept-copy records. +func (s *Settings) GetKeptCopies() []KeptCopy { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]KeptCopy(nil), s.KeptCopies...) +} + +// UpsertKeptCopy records a kept copy (or refreshes its size and identity), keeping the first Since. +func (s *Settings) UpsertKeptCopy(k KeptCopy) error { + s.mu.Lock() + defer s.mu.Unlock() + if k.Path == "" || k.DestBase == "" { + return fmt.Errorf("kept copy: empty path or dest base") + } + k.ID = KeptCopyID(k.Path) + for i := range s.KeptCopies { + if s.KeptCopies[i].ID == k.ID { + since := s.KeptCopies[i].Since + k.Since = since + if s.KeptCopies[i] == k { + return nil // nothing changed — no write + } + s.KeptCopies[i] = k + return s.save() + } + } + if k.Since == "" { + k.Since = time.Now().UTC().Format(time.RFC3339) + } + s.KeptCopies = append(s.KeptCopies, k) + if s.log != nil { + s.log.Printf("[INFO] [settings] kept copy recorded: kind=%s source=%q reason=%s path=%s", k.Kind, k.Source, k.Reason, k.Path) + } + return s.save() +} + +// DropKeptCopy forgets the record of a copy path (the source came back, or the household removed the copy). The +// copy's data is not touched here. +func (s *Settings) DropKeptCopy(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.KeptCopies { + if s.KeptCopies[i].ID == id { + s.KeptCopies = append(s.KeptCopies[:i], s.KeptCopies[i+1:]...) + return s.save() + } + } + return nil +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 10126f4..82b21c1 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -270,6 +270,10 @@ type Settings struct { // SMBShares is the ordered registry of exported folders. Each Path is an absolute host path under // a registered storage root; the smb.conf + compose + backup classification all ride this list. SMBShares []SMBShare `json:"smb_shares,omitempty"` + // KeptCopies (D25, operator ruling 2026-10-21; R-1175 + R-1181): second-drive copies whose SOURCE is gone (a drive + // away or decommissioned, a declared folder missing). They are kept until the household releases one with + // „Eltávolítás"; no night job removes them. See internal/settings/kept_copies.go. + KeptCopies []KeptCopy `json:"kept_copies,omitempty"` } // AppEmail holds the global app-email toggle and an optional household display name. diff --git a/controller/internal/stacks/deploy_errors.go b/controller/internal/stacks/deploy_errors.go index d08af06..d441966 100644 --- a/controller/internal/stacks/deploy_errors.go +++ b/controller/internal/stacks/deploy_errors.go @@ -38,4 +38,6 @@ var ( // memoryVerdict through DeployStack to the API, which renders it in the request's language (R-575 — // it used to be rendered here in Hungarian, so an English page showed a Hungarian line). Pinned by // TestR575_OvercommitWarningFollowsTheLanguage. -const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_warning" +// R-1192 (2026-10-21): the key changed with the text — its old second sentence („Normál használat mellett ez nem okoz +// problémát.") was false; the 8 GB box froze under its first uploads. +const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_slow" diff --git a/controller/internal/stacks/import_record.go b/controller/internal/stacks/import_record.go new file mode 100644 index 0000000..3f9483f --- /dev/null +++ b/controller/internal/stacks/import_record.go @@ -0,0 +1,97 @@ +package stacks + +import ( + "fmt" + "path/filepath" + "time" +) + +// ── R-1191 (2026-10-21): a `.fab` import builds the app record the way the unit restore does ───────────────────── +// +// The import used to write a bare record (Deployed, DeployedAt, Env, DesiredState) and start the app; the family-gate +// reconcile then removed the gate file of an app with no FamilyGate record, so a family-only app (Glance, Grimmory, +// MeTube) came back open to anyone (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-…). The +// sign-up lock (R-773) and the locked fields were dropped the same way. +// +// Now, in two steps around the import's own order (the bundle's compose file is in place only after the config step): +// - SaveImportedAppConfig — the record: locked fields, desired state running, and the PRIOR record's life records +// (carryLifeRecords: an installed app keeps its gates, pins and update history; an import never un-gates it); +// - PrepareImportedApp — right before the start: the family gate (when the template is a family app and the record +// has none) and, for an app that had no record before, the sign-up lock — the same helpers the unit restore calls +// (prepareFamilyGate, restoreSignupLock), never a second copy of them. + +// SaveImportedAppConfig writes an imported app's record. Returns whether the app had NO record before (a fresh import), +// which PrepareImportedApp needs for the sign-up lock rule (decision 49: a restore never adds a lock to an installed +// app the household has not closed). +func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string) (fresh bool, err error) { + name := filepath.Base(stackDir) + meta := LoadMetadata(stackDir) + priorRaw := LoadAppConfig(stackDir) + cfg := &AppConfig{ + Deployed: true, + DeployedAt: time.Now().UTC().Format(time.RFC3339), + Env: env, + // R-166: importing a bundle is the household installing that app — it is meant to run. + DesiredState: DesiredStateRunning, + } + for _, f := range meta.DeployFields { + if f.LockedAfterDeploy { + cfg.LockedFields = append(cfg.LockedFields, f.EnvVar) + } + } + carryLifeRecords(m.logger, name, priorRaw, cfg) + if priorRaw != nil { + cfg.PinnedImages = priorRaw.PinnedImages + cfg.InstalledImages = priorRaw.InstalledImages + cfg.PreviousImages = priorRaw.PreviousImages + } + if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { + return false, err + } + m.logger.Printf("[INFO] [stacks] %s: import record saved (fresh=%v, family gate carried=%v, sign-up lock carried=%v)", + name, priorRaw == nil, cfg.FamilyGate != nil, cfg.SetupGate != nil) + return priorRaw == nil, nil +} + +// PrepareImportedApp gives an imported app its doors BEFORE it starts. Called by the import after the bundle's +// config files (the compose file among them) are in place and before StartStack. An error means „do not start". +func (m *Manager) PrepareImportedApp(stackDir string, env map[string]string, fresh bool) error { + name := filepath.Base(stackDir) + composePath := filepath.Join(stackDir, "docker-compose.yml") + meta := LoadMetadata(stackDir) + cfg := LoadAppConfigDecrypted(stackDir, m.encKey) + if cfg == nil { + return fmt.Errorf("the imported app's record could not be read") + } + changed := false + if cfg.FamilyGate == nil && meta.FamilyGate { + rec, err := m.prepareFamilyGate(name, composePath, env, &meta) + if err != nil { + return fmt.Errorf("the family gate could not be prepared (the app was not started): %w", err) + } + cfg.FamilyGate, changed = rec, true + } + if fresh && cfg.SetupGate == nil { + rec, err := m.restoreSignupLock(name, composePath, env, &meta) + if err != nil { + return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err) + } + if rec != nil { + cfg.SetupGate, changed = rec, true + } + } + if changed { + if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { + return fmt.Errorf("saving app config: %w", err) + } + } + // The in-memory record is what the gate reconcile reads: without this the next tick would remove the file again. + m.mu.Lock() + if s, ok := m.stacks[name]; ok { + s.Deployed = true + s.AppConfig = cfg + } + m.mu.Unlock() + m.logger.Printf("[INFO] [stacks] %s: import prepared before the start (family gate=%v, sign-up lock=%v)", name, cfg.FamilyGate != nil, cfg.SetupGate != nil) + return nil +} diff --git a/controller/internal/stacks/r1191_import_gate_test.go b/controller/internal/stacks/r1191_import_gate_test.go new file mode 100644 index 0000000..7ee9f10 --- /dev/null +++ b/controller/internal/stacks/r1191_import_gate_test.go @@ -0,0 +1,67 @@ +package stacks + +import ( + "os" + "path/filepath" + "testing" +) + +// R-1191 (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-fab-family-gate/): a `.fab` import of +// a family-only app must come back WITH its door — the gate file before the first start, the record carrying it, and +// the reconcile loop keeping it. The three catalog family apps' stanzas (Glance, MeTube: family_gate only; Grimmory: +// with exceptions) are the fixtures. COMPANION RED-PROOF: make PrepareImportedApp skip prepareFamilyGate → each case +// fails „no door after the import"; and the old bare record (SaveImportedAppConfig without carryLifeRecords) fails the +// installed-app case. +func TestR1191_AnImportedFamilyAppKeepsItsDoor(t *testing.T) { + base := "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" + for name, yml := range map[string]string{ + "glance": "display_name: Glance\nfamily_gate: true\n" + base, + "metube": "display_name: MeTube\nfamily_gate: true\n" + base, + "grimmory": "display_name: Grimmory\nfamily_gate: true\nfamily_gate_except: [\"/api/v1/opds\", \"/api/v2/opds\", \"/api/kobo\", \"/api/koreader\", \"/komga/api\"]\n" + base, + } { + t.Run(name, func(t *testing.T) { + m := gateManager(t, yml) + dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp") + env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"} + // a REMOVED app imported from its bundle (no record before) + fresh, err := m.SaveImportedAppConfig(dir, env) + must(t, err) + if !fresh { + t.Fatal("an app with no record must import as fresh") + } + must(t, m.PrepareImportedApp(dir, env, fresh)) + if _, err := os.Stat(m.familyGatePath("gapp")); err != nil { + t.Fatal("no door after the import — the app would start open to anyone (R-1191)") + } + cfg := LoadAppConfig(dir) + if cfg == nil || cfg.FamilyGate == nil || cfg.DesiredState != DesiredStateRunning { + t.Fatalf("record after the import: %+v", cfg) + } + m.SetupGateTick() // the reconcile that removed the file of a gate-less record + if _, err := os.Stat(m.familyGatePath("gapp")); err != nil { + t.Fatal("the reconcile loop removed the imported app's door") + } + }) + } +} + +// An INSTALLED app re-imported over itself keeps the records it had (the gate's „since", an update's history) — the +// import never replaces them with a bare record. +func TestR1191_AnImportOverAnInstalledAppKeepsItsRecords(t *testing.T) { + m := gateManager(t, familyYml) + dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp") + env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"} + prior := &AppConfig{Deployed: true, Env: env, FamilyGate: &FamilyGateRecord{Since: "2026-10-01T00:00:00Z", Hosts: []string{"gapp.example.hu"}}, + PinnedImages: map[string]string{"gapp": "example/gapp:1.2.3"}} + must(t, SaveAppConfig(dir, prior, m.encKey, nil)) + fresh, err := m.SaveImportedAppConfig(dir, env) + must(t, err) + if fresh { + t.Fatal("an installed app must not import as fresh") + } + must(t, m.PrepareImportedApp(dir, env, fresh)) + cfg := LoadAppConfig(dir) + if cfg == nil || cfg.FamilyGate == nil || cfg.FamilyGate.Since != "2026-10-01T00:00:00Z" || cfg.PinnedImages["gapp"] != "example/gapp:1.2.3" { + t.Fatalf("the installed app's records were not kept: %+v", cfg) + } +} diff --git a/controller/internal/web/fab_export_test.go b/controller/internal/web/fab_export_test.go index 1f736ff..b5ceba0 100644 --- a/controller/internal/web/fab_export_test.go +++ b/controller/internal/web/fab_export_test.go @@ -212,3 +212,5 @@ func hasSuffixIn(ss []string, suffix string) bool { } return false } + +func (p *fabWebProvider) PrepareImportedApp(string, map[string]string) error { return nil } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 469bbf5..741d7f7 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1328,6 +1328,8 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { // page listed these, so they accumulated invisibly and the only way to find them was SSH. 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)) // R-487: removed apps whose unit was kept join the local restore picker. data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits() } diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go index 684b8a9..6af6703 100644 --- a/controller/internal/web/i18n_cases_b_test.go +++ b/controller/internal/web/i18n_cases_b_test.go @@ -227,6 +227,16 @@ func i18nCasesB() []i18nCase { d["OffboxApps"] = []m{{"Slug": "immich", "DisplayName": "Immich Photos", "Name": "immich"}} return d }}, + // D25 (2026-10-21): the kept-copies list (a NEW case, so no existing fixture changes). Line is composed in Go in + // the request's language; the fixture carries only a drive label so it is the same in both renders. + {"backups_restore_kept", "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: "0123456789abcdef", Source: "csaladi_fotok", Line: "hdd_3", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}} + 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 new file mode 100644 index 0000000..380398a --- /dev/null +++ b/controller/internal/web/kept_copies.go @@ -0,0 +1,70 @@ +package web + +import ( + "errors" + "net/http" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is +// gone and lets the household release one with „Eltávolítás". Nothing else removes them. + +// keptCopyRow is one line of the „Megőrzött másolatok" list. +type keptCopyRow struct { + ID string + Source string + Line string // „a(z) meghajtóról megőrzött másolat · " + SizeHuman string + Since string +} + +func (s *Server) keptCopyRows(lang string) []keptCopyRow { + if s.backupMgr == nil { + return nil + } + var out []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, + }) + } + return out +} + +// keptCopyRemoveHandler is „Eltávolítás": POST /backup/kept/remove (id, confirm=1). It removes ONE kept copy by its +// record (backup.RemoveKeptCopy: the recorded path, inside its copy tree, same device + inode) and nothing else. +func (s *Server) keptCopyRemoveHandler(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.needs_confirmation", true) + return + } + if msg, blocked := s.restoreOpBlocked(); blocked { + s.logger.Printf("[WARN] [web] kept-copy removal refused (%s): a backup/restore op is running", id) + offboxRedirectTo(w, r, "/backups/restore", msg, true) + return + } + switch err := s.backupMgr.RemoveKeptCopy(id); { + case err == nil: + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.removed", false) + case errors.Is(err, backup.ErrKeptCopyNotFound): + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.not_found", true) + case errors.Is(err, backup.ErrKeptCopyChanged): + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.changed", true) + default: + s.logger.Printf("[WARN] [web] kept-copy removal %s failed: %v", id, err) + offboxRedirectTo(w, r, "/backups/restore", "flash.kept.remove_failed", true, err.Error()) + } +} diff --git a/controller/internal/web/kept_copies_test.go b/controller/internal/web/kept_copies_test.go new file mode 100644 index 0000000..e3175a8 --- /dev/null +++ b/controller/internal/web/kept_copies_test.go @@ -0,0 +1,97 @@ +package web + +import ( + "io" + "log" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// D25 (operator ruling 2026-10-21): the restore page lists kept second-drive copies with „Eltávolítás" — and shows no +// section at all when there are none. Render test per branch of the {{if .KeptCopies}} gate (the seam-wiring rule). +func TestD25_RestorePageListsKeptCopiesWithRemove(t *testing.T) { + base := 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" + return d + } + with := base() + with["KeptCopies"] = []keptCopyRow{{ID: "0123456789abcdef", 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"}} + html := renderBackupPage(t, "backups_restore", with) + for _, want := range []string{"Megőrzött másolatok", "csaladi_fotok", "a(z) hdd_3 meghajtóról megőrzött másolat", `action="/backup/kept/remove"`, + `name="id" value="0123456789abcdef"`, `name="confirm" value="1"`, "Eltávolítás", "confirmRemoveKeptCopy(this)"} { + if !strings.Contains(html, want) { + t.Errorf("restore page with a kept copy lacks %q", want) + } + } + if html := renderBackupPage(t, "backups_restore", base()); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "/backup/kept/remove") { + t.Error("restore page with NO kept copy shows the kept-copies section") + } +} + +// „Eltávolítás" through the handler: no confirm → refused, nothing removed; an unknown id → its own refusal; the +// recorded copy → removed, and ONLY it. Real settings + manager + disk (the record is the only selector). +func TestD25_RemoveHandlerRemovesOnlyTheRecordedCopy(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) + } + destBase := filepath.Join(tmp, "hdd_1", "backups", "secondary", "_shares") + kept := filepath.Join(destBase, "hdd_3", "csaladi_fotok") + other := filepath.Join(destBase, "hdd_2", "b_share") + for _, d := range []string{kept, other} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "f.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + st, _ := os.Stat(kept) + sys := st.Sys().(*syscall.Stat_t) + if err := sett.UpsertKeptCopy(settings.KeptCopy{Kind: "share", Source: "csaladi_fotok", Drive: "hdd_3", Reason: "drive_away", + DestBase: destBase, Path: kept, Dev: uint64(sys.Dev), Ino: sys.Ino}); err != nil { + t.Fatal(err) + } + id := settings.KeptCopyID(kept) + 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/remove", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.keptCopyRemoveHandler(rr, req) + return rr.Header().Get("Location") + } + if loc := post("id=" + id); !strings.Contains(loc, "flash.kept.needs_confirmation") { + t.Fatalf("no confirm → %q", loc) + } + if _, err := os.Stat(kept); err != nil { + t.Fatal("removed without a confirm") + } + if loc := post("id=ffffffffffffffff&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") { + t.Fatalf("unknown id → %q", loc) + } + if loc := post("id=" + id + "&confirm=1"); !strings.Contains(loc, "flash.kept.removed") { + t.Fatalf("recorded copy → %q", loc) + } + if _, err := os.Stat(kept); !os.IsNotExist(err) { + t.Fatalf("the kept copy is still there: %v", err) + } + if _, err := os.Stat(filepath.Join(other, "f.txt")); err != nil { + t.Fatalf("another copy was touched: %v", err) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 45261b9..9fe4702 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -834,6 +834,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // DeleteOffsiteRestoreCopy for the prefix guard. case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost: s.offboxVerifyCopyDeleteHandler(w, r) + // 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) // 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 5330ba2..ca3b2ed 100644 --- a/controller/internal/web/templates/backups_restore.html +++ b/controller/internal/web/templates/backups_restore.html @@ -167,7 +167,32 @@ + so the copy can be found, opened, and cleaned up. -->{{/* D25 (operator ruling 2026-10-21; R-1175, R-1181): second-drive copies whose source is gone (a drive away or removed, a folder missing). No night job removes them; only „Eltávolítás" here, after a confirm. On one line with the comment above so a page without kept copies renders byte-identically. */}}{{if .KeptCopies}} +
+

{{T "backups_restore.megorzott_masolatok"}}

+

{{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))}} +
+ {{$.CSRFField}} + + + +
+ {{template "app_list_row_end"}} + {{end}} +
+ + {{end}}

{{T "backups_restore.meglevo_ellenorzo_masolatok"}}

{{if .OffsiteRestoreCopies}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 0d30ef9..3db291c 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -55,7 +55,7 @@ {{if .OvercommitWarn}}
- {{T "deploy.az_alkalmazasok_csucsterhelese_meghaladh"}} + {{T "deploy.memory_overcommit_slow"}}
{{end}} {{end}} diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_kept.html b/controller/internal/web/testdata/i18n_parity/backups_restore_kept.html new file mode 100644 index 0000000..5b36e56 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_kept.html @@ -0,0 +1,908 @@ + + + + + + + + Visszaállítás a mentésből — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

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.

+
+ + +
+ +
+ csaladi_fotok + hdd_3 · 1,2 GB · 2026. 10. 21. 14: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/internal/web/testdata/i18n_parity/deploy_new.html b/controller/internal/web/testdata/i18n_parity/deploy_new.html index 7b23f85..24dae52 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new.html @@ -214,8 +214,7 @@
- Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. - Normál használat mellett ez nem okoz problémát. + 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.
diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 2096a4f..1fd3062 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -289,7 +289,18 @@ "err.backup.restore_failed_put_back_not_started": "BORN AS A KEY (R-893 option A, D20 = A, 2026-10-20) -- the data is put back but the app did not start; the household is told to start it. Pinned by internal/backup offbox_restore_undo.go putBackAfterFailedRestore (the restart branch).", "err.backup.restore_undo_copy_failed": "BORN AS A KEY (R-893 option A, D20 = A, 2026-10-20) -- the copy of the live volumes failed, nothing was restored, the app runs again. Pinned by internal/backup TestR893A_AFailedCopyRestoresNothingAndStartsTheApp.", "err.backup.restore_undo_no_space": "BORN AS A KEY (R-893 option A, D20 = A, 2026-10-20) -- the restore and the copy of the live volumes do not fit; refused before anything stops. Pinned by internal/backup TestR893A_TheCopyMustFitBeforeAnythingStops.", - "err.backup.restore_undo_unmeasured": "BORN AS A KEY (R-893 option A, D20 = A, 2026-10-20) -- a live volume cannot be measured for its copy; refused before anything stops. Pinned by internal/backup TestR893A_AnUnmeasurableVolumeRefusesBeforeAnythingStops." + "err.backup.restore_undo_unmeasured": "BORN AS A KEY (R-893 option A, D20 = A, 2026-10-20) -- a live volume cannot be measured for its copy; refused before anything stops. Pinned by internal/backup TestR893A_AnUnmeasurableVolumeRefusesBeforeAnythingStops.", + "kept.from_drive": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "kept.reason.drive_away": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "kept.reason.drive_gone": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "kept.reason.folder_missing": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "note.tier2.emptied_kept": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "flash.kept.removed": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "flash.kept.needs_confirmation": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "flash.kept.not_found": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "flash.kept.changed": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "flash.kept.remove_failed": "BORN AS A KEY (D25, operator ruling 2026-10-21; R-1175/R-1181) -- kept second-drive copies: their list line, reasons, the emptied-folder note and the „Eltávolítás” flashes. Pinned by internal/web/kept_copies_test.go and internal/backup/r1175_shares_kept_test.go.", + "err.stacks.memory_overcommit_slow": "BORN AS A KEY (R-1192, 2026-10-21) -- replaces err.stacks.memory_overcommit_warning, whose second sentence („Normál használat mellett ez nem okoz problémát.”) was false: the rehearsal's 8 GB box froze for an hour under its first uploads (felhom.eu audits/fixes-2026-10-21/P4-freeze/). Pinned by TestR575_OvercommitWarningFollowsTheLanguage." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", @@ -1043,10 +1054,6 @@ ], "err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező", "err.backup.offsite_over_quota": "A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", - "err.stacks.memory_overcommit_warning": [ - "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. ", - "Normál használat mellett ez nem okoz problémát." - ], "err.backup.not_installed_known_drive": [ "a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait. ", "A mentése szerint az adatai itt voltak: %s. Telepítsd újra az alkalmazást (Alkalmazások) ",