From e3fce09d96f781ad84568c67b56aa2044b692bf5 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 29 Oct 2026 08:50:59 +0100 Subject: [PATCH] =?UTF-8?q?D62:=20restore=20copies=20kept=2030=20days,=20l?= =?UTF-8?q?isted=20with=20their=20date,=20deletable=20by=20hand=20(typed?= =?UTF-8?q?=20name,=20never=20a=20needed=20copy,=20operator=20event);=20D6?= =?UTF-8?q?3:=20a=20test-build-only=20fault=20switch=20for=20the=20R-1256?= =?UTF-8?q?=20H4/H5=20live=20proofs=20=E2=80=94=20tests=20red=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 24 + REUSE.md | 3 +- controller/README.md | 9 +- .../cmd/controller/d63_faultpoint_on_test.go | 34 + .../cmd/controller/d63_faultpoint_test.go | 40 + controller/cmd/controller/faultpoint_off.go | 9 + controller/cmd/controller/faultpoint_on.go | 64 ++ controller/cmd/controller/main.go | 6 + .../backup/d62_restore_copies_test.go | 185 ++++ .../backup/r1256_restore_edges_test.go | 4 +- controller/internal/backup/restore_copies.go | 182 ++++ .../internal/backup/restore_copy_sweep.go | 7 +- controller/internal/i18n/locales/en.json | 13 + controller/internal/i18n/locales/hu.json | 13 + .../notify/d62_restore_copy_deleted_test.go | 34 + controller/internal/notify/notifier.go | 19 + .../internal/web/d62_restore_copies_test.go | 233 +++++ controller/internal/web/handlers.go | 1 + controller/internal/web/i18n_cases_b_test.go | 15 + controller/internal/web/kept_copies.go | 135 +++ controller/internal/web/server.go | 3 + .../web/templates/backups_restore.html | 45 +- .../backups_restore_restore_copies.html | 915 ++++++++++++++++++ controller/scripts/i18n_go_keys.json | 10 + 24 files changed, 1992 insertions(+), 11 deletions(-) create mode 100644 controller/cmd/controller/d63_faultpoint_on_test.go create mode 100644 controller/cmd/controller/d63_faultpoint_test.go create mode 100644 controller/cmd/controller/faultpoint_off.go create mode 100644 controller/cmd/controller/faultpoint_on.go create mode 100644 controller/internal/backup/d62_restore_copies_test.go create mode 100644 controller/internal/backup/restore_copies.go create mode 100644 controller/internal/notify/d62_restore_copy_deleted_test.go create mode 100644 controller/internal/web/d62_restore_copies_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/backups_restore_restore_copies.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b12db45d..c8d8635e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## Unreleased (2026-10-29, attended) — D62: restore copies kept 30 days, listed, and deletable by hand; D63: a test-build fault switch for the H4/H5 live proofs; commit only, rides the next release + +**MinAgent: 0.158.1** (unchanged). Needs hub ≥ the release carrying `restore_copy_deleted` (felhom.eu `19afefab`): an +older hub answers that one event 400 and the record is lost (the delete itself is unaffected). + +- **D62 (operator ruling 2026-10-29 08:17) — the age: 30 days** (`restoreCopyUnusedAge`, was 14, the night's safest + reading). `TestD62_AnUnusedCopyIsKept30Days` (red at 14: the 20-day copy was swept). +- **D62 — the list (`backup.RestoreCopies`, `web.restoreCopyRows`, `backups_restore.html` „Megőrzött másolatok"):** each + restore copy shows the app, the date of the restore it protects, its size and „automatikusan törlődik: ". A copy + a hold or a pending put-back needs (a hold; the putback_pending hold; the app-stop marker naming it; an unreadable + marker) shows why in one line and its button is off — it never gets a date, because the sweep never takes it. +- **D62 — „Törlés most" (`POST /backup/restore-copy/delete`, `backup.DeleteRestoreCopy`):** the warning names the app + and the restore's date (the operator's text, hu + en); the household types the app's name. JSON answer: 200; 400 a + wrong name; 404 no such copy; 409 the copy is needed, or a backup/restore runs, or the app updates. The check and the + removal are one step under the backup manager's lock (no restore can begin in between). Path guard: both names one + path element, the copy a real directory (not a link) directly under `backups/restore-undo//`. One `[WARN]` log + line and one operator event `restore_copy_deleted` (who, app, restore date, size) per delete. CSRF as every form. + Tests `TestD62_*` (backup, web, notify) — each seen failing by a mutation of the check it pins. +- **D63 — the fault switch, TEST BUILDS ONLY (`cmd/controller/faultpoint_{on,off}.go`):** `go build -tags faultinject` + arms two points from a file (`/faults/` = „ "): `defwrite-after-compose` (the definition + writer fails after the compose was written — H4) and `stop-` (the app's stop fails — H5). Without the tag it + is a no-op that reads nothing; `TestD63_TheReleaseBuildHasNoFaultSwitch` pins that, and that build.sh and the + Dockerfile pass no build tag (red-proofed both ways). + ## Unreleased (2026-10-28 night) — four restore edges (felhom.eu R-1256, review round 2 HUNT-1 H4/H5/H6/H8); commit only, rides the next release **MinAgent: 0.158.1** (unchanged). Each edge: a test seen failing first, then the fix; live proof on 9202 in the felhom.eu diff --git a/REUSE.md b/REUSE.md index 8efafbb4..75dce7b9 100644 --- a/REUSE.md +++ b/REUSE.md @@ -83,7 +83,7 @@ | `Server.beginRestoreOp` → `backup.TryBeginRestoreOp` / `RestoreBusy` (R-1256 H6) | controller/internal/web/restore_wizard.go; controller/internal/backup/opstatus.go | `(op, stack) (msg string, ok bool)` / `(op, stack) (busy, mirrorApp string)` | THE way a restore op begins: the check for every other operation (a restore op, the backup run, a second-drive copy) and the record in ONE step under `m.mu` — the mirror asks under the same lock (`beginTier2Run`) | **Never call `BeginRestoreOp` from a handler** (`TestR1256_H6_NoHandlerBeginsARestoreUnchecked`): `restoreOpBlocked` asked early can be stale by the time the form is read, and a mirror that began in between ran beside the restore. `restoreOpBlocked` stays as the EARLY refusal; the kept loads begin inside `LoadKeptApp` / `LoadKeptOffsite` and return `ErrRestoreBusy` | | `Manager.RestoreFromRecoveryUnitAt` / `RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353 v0.226.0; R-102 v0.229.0) | controller/internal/backup/restore_unit.go | `(stack, unitDir string) (UnitRestoreResult, error)` / `(stack string) (…)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation. **R-102: `…At` holds the whole body and takes the unit DIRECTORY; the one-argument form is the thin caller naming the primary unit.** THE SOURCE MOVES; THE DESTINATION DOES NOT — `GetAppDrivePath` still resolves where the data lands. The R-47 mutation order, the unit-over-guest secret precedence, the fail-closed data-key gate and the no-unit `RestoreApp` fallback are all pinned and unchanged. The volume leg goes through the R-354 `volumeReplayFrom` seam so the source directory is assertable without Docker | | `Manager.putBackAfterFailedRestore` + `liveVolumeCopy` (R-893 option A, v0.336.0) | controller/internal/backup/offbox_restore_undo.go | `(stack, cause, live, copy, volumesMoved, filesPlaced, restart, holdKey) error` | THE restore undo — off-site (v0.336.0) and, since D22 (unreleased), the LOCAL unit restore (`RestoreFromRecoveryUnitAtWith`, so also the second drive's and a kept load): the live volumes it will replace, measured before the stop (`planLiveVolumeCopy`), copied right after it (`copyLiveVolumes`), poured back on any failure | One copy per restore under `/backups/restore-undo//` (no capture set reads `backups/`); the put-back uses the restore's own `volumeReplayFrom`/`restoreDockerVolumesFrom`, so there is one volume-replay implementation. A put-back that fails sets `keep` and HOLDS (option C) — never start on volumes that are not known to be back. Seams for tests without Docker: `undoVolumeProbeFn`, `undoVolumeTarFn`, `undoVolumeRemoveFn`; operator notice `SetRestorePutBackNotify` (leg `restore-put-back`). Pinned by `r893_putback_test.go` and `r1177_local_putback_test.go` | -| `Manager.SweepUnusedRestoreCopies` (R-1256 H8) | controller/internal/backup/restore_copy_sweep.go | `(now) []string` | THE only remover of a restore copy nothing names any more (`restore-copy-sweep`, every 6 h) | Removes a copy only when ALL hold: older than 14 days, the app has no restore hold of ANY kind, the app-stop marker is readable and does not name the app, no restore op runs. It answers „no" when it cannot answer — a held or waiting copy is support's way back | +| `Manager.SweepUnusedRestoreCopies` (R-1256 H8) | controller/internal/backup/restore_copy_sweep.go | `(now) []string` | THE only AUTOMATIC remover of a restore copy nothing names any more (`restore-copy-sweep`, every 6 h); the household's „Törlés most" is `DeleteRestoreCopy` (D62, restore_copies.go — same „needed" rule via `restoreCopyNeededFor`, check + removal under `m.mu`) | Removes a copy only when ALL hold: older than 30 days (D62), the app has no restore hold of ANY kind, the app-stop marker is readable and does not name the app, no restore op runs. It answers „no" when it cannot answer — a held or waiting copy is support's way back | | `CrashPutBack` + `Manager.persistUndoForCrash` + `AppStopGuard.SetUndo` (D22 / R-1195, v0.338.0) | controller/internal/backup/restore_crash_putback.go | `NewCrashPutBack(stop, writeDef, logger)`; `PutBackInterruptedRestore(stack, undoDir) error` | A restore cut by a power loss is put back at start: the copy (volumes + `definition/` + `undo.json`) is recorded in the app-stop marker once whole; `Recover` pours it back BEFORE the restart | Call `persistUndoForCrash` right after `copyLiveVolumes` in any new restore path. `Recover` runs before the backup manager exists, so the put-back is wired in main.go with `stackMgr.StopStack` and the stack adapter's definition writer. A failed put-back = a FAILED recovery (app not started, marker kept). The household reads `note.restore.interrupted_put_back` (`LoadRestoreRecordAfterRecovery`). Pinned by `TestR1195_*` | | `tier2CaptureSetFull(...) (legs, missing, warns, classified)` + `keptCopyRels` + `dropKeptCopiesUnder` (R-1252, unreleased) | controller/internal/backup/tier2_capture.go, tier2_kept.go | `classified` = the legs come from a VALID backup block | **The second-drive reconcile deletes only under a valid classification**; a rejected or absent block keeps every dest dir; kept copies (D25) are always in the keep set; a mirror deleted by Remove drops its kept records | A new reconcile caller must take `classified` and the kept rels too — never reconcile against the legacy legs. A rejected block is told by `Manager.BackupBlockRejected` (stacks; via the adapter's optional `BackupBlockRejected` method) and signalled once a day (`SetTier2SettingsUnreadableNotify`). Pinned by `r1252_rejected_block_keeps_copies_test.go` | | `holdreport.Reporter` + `settings.RestoreHold.Why` / `WireReason` (D53, unreleased) | controller/internal/holdreport/holdreport.go · controller/internal/settings/settings.go | `New(store, notifier.PushEvent, logger)`; `Tick(now)` from the scheduler | Tells the hub every hold in place (`app_held`), every hold gone (`app_hold_cleared`), each again daily | **One observer of the hold store — never a send inside a hold writer.** A new hold writer needs nothing here. The hold sentence is `RestoreHold.Why()` (one per reason) — never a second copy. Pinned by `internal/holdreport/holdreport_test.go` | @@ -359,6 +359,7 @@ |---|---|---|---| | `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go | | `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests | +| `faultPoint(point) error` (D63, TEST BUILDS ONLY) | controller/cmd/controller/faultpoint_{off,on}.go | a no-op without `-tags faultinject` (the release image); with it, a file `/faults/` = „ " fails the named point | `TestD63_TheReleaseBuildHasNoFaultSwitch` (release build: no-op; build.sh/Dockerfile pass no tag), `TestD63_TheTestBuildFailsTheArmedPointOnDemand` (`go test -tags faultinject`). Points: `defwrite-after-compose`, `stop-`. A live proof of a branch Docker cannot be made to take on demand goes here — never an env switch in the release binary | | `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` | | `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) | | `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated | diff --git a/controller/README.md b/controller/README.md index b95520dd..368cb07d 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1039,8 +1039,13 @@ Each app can define rich metadata in `.felhom.yml`: failed volume (never unpacked on top of the old content). An off-site restore whose definition write fails writes the LIVE definition back before the start, or holds the app. Every restore begins in one step with the check for a restore, the backup run and a second-drive copy (`TryBeginRestoreOp`, also both kept loads). A copy that nothing - needs (no hold of any kind, not named by the app-stop marker, no restore running) goes after 14 days, checked every - 6 h (`restore_copy_sweep.go`; decided by CC unattended — operator may reverse). + needs (no hold of any kind, not named by the app-stop marker, no restore running) goes after 30 days, checked every + 6 h (`restore_copy_sweep.go`; operator ruling D62, 2026-10-29). + - **Restore copies in the kept-copies list (unreleased, D62).** The restore page's „Megőrzött másolatok" lists each + restore copy: the app, the restore's date, the size, „automatikusan törlődik: ". „Törlés most" deletes one + earlier, after a warning naming the app and the date and the app's name typed (`POST /backup/restore-copy/delete`, + JSON; 409 while a backup/restore runs or the app updates). A copy a hold or a pending put-back needs: the button is + off, the reason on one line. Each delete: one log line and the operator event `restore_copy_deleted`. - **A restore cut by a power loss, on a drive that comes back late (unreleased, R-1251).** The copy of the live data lives on the app's drive. When it cannot be reached at start because the drive is not bound yet, the app is HELD (`putback_pending`; „…A visszaállítás előtti adatai egy meghajtón vannak, amely most nem érhető el…") and no diff --git a/controller/cmd/controller/d63_faultpoint_on_test.go b/controller/cmd/controller/d63_faultpoint_on_test.go new file mode 100644 index 00000000..0e6619bc --- /dev/null +++ b/controller/cmd/controller/d63_faultpoint_on_test.go @@ -0,0 +1,34 @@ +//go:build faultinject + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// D63 (test build only): " " lets passes through, fails the next , then removes the file. +func TestD63_TheTestBuildFailsTheArmedPointOnDemand(t *testing.T) { + dir := t.TempDir() + t.Setenv("FELHOM_FAULT_DIR", dir) + p := filepath.Join(dir, "stop-radicale") + if err := os.WriteFile(p, []byte("1 1\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := faultPoint("stop-radicale"); err != nil { + t.Fatalf("the first pass must go through: %v", err) + } + if err := faultPoint("stop-other"); err != nil { + t.Fatalf("another point is not armed: %v", err) + } + if err := faultPoint("stop-radicale"); err == nil { + t.Fatal("the second pass must fail") + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatalf("the spent file must be gone: %v", err) + } + if err := faultPoint("stop-radicale"); err != nil { + t.Fatalf("a spent point fails nothing: %v", err) + } +} diff --git a/controller/cmd/controller/d63_faultpoint_test.go b/controller/cmd/controller/d63_faultpoint_test.go new file mode 100644 index 00000000..217b7904 --- /dev/null +++ b/controller/cmd/controller/d63_faultpoint_test.go @@ -0,0 +1,40 @@ +//go:build !faultinject + +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// goBuildTag is a `go build -tags …` flag (build.sh's own `--print-tags` option is not one). +var goBuildTag = regexp.MustCompile(`\s-tags[ =]`) + +// D63: the fault switch exists only in a `-tags faultinject` test build. In this (the release) build an armed fault +// file injects nothing, and neither build.sh nor the Dockerfile passes a build tag. +// RED-PROOF: build the release build with the switch (drop the !faultinject line from faultpoint_off.go and the +// faultinject line from faultpoint_on.go) → the armed point fails → FAILS. +func TestD63_TheReleaseBuildHasNoFaultSwitch(t *testing.T) { + dir := t.TempDir() + t.Setenv("FELHOM_FAULT_DIR", dir) + for _, p := range []string{"defwrite-after-compose", "stop-radicale"} { + if err := os.WriteFile(filepath.Join(dir, p), []byte("0 5\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := faultPoint(p); err != nil { + t.Fatalf("the release build injected a fault at %s: %v", p, err) + } + } + for _, f := range []string{"../../build.sh", "../../Dockerfile"} { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if goBuildTag.MatchString(string(b)) || strings.Contains(string(b), "faultinject") { + t.Fatalf("%s passes a build tag — the release image could carry the fault switch", f) + } + } +} diff --git a/controller/cmd/controller/faultpoint_off.go b/controller/cmd/controller/faultpoint_off.go new file mode 100644 index 00000000..56fa7be5 --- /dev/null +++ b/controller/cmd/controller/faultpoint_off.go @@ -0,0 +1,9 @@ +//go:build !faultinject + +package main + +// faultPoint is the D63 live-proof fault switch (operator ruling 2026-10-29: prove R-1256 H4 and H5's failed stop live, +// with a fault switch in a TEST build only). In every build without the `faultinject` tag — the release image among +// them: build.sh and the Dockerfile pass no build tags — it does nothing and reads nothing. Pinned by +// TestD63_TheReleaseBuildHasNoFaultSwitch; the switch itself is faultpoint_on.go. +func faultPoint(string) error { return nil } diff --git a/controller/cmd/controller/faultpoint_on.go b/controller/cmd/controller/faultpoint_on.go new file mode 100644 index 00000000..d00da27e --- /dev/null +++ b/controller/cmd/controller/faultpoint_on.go @@ -0,0 +1,64 @@ +//go:build faultinject + +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// faultPoint (TEST BUILDS ONLY — `go build -tags faultinject`; never the release image, faultpoint_off.go) fails a named +// point on demand, for the D63 live proofs of R-1256 H4 (a definition write that fails after the compose was written) +// and H5 (a put-back whose stop fails). Armed by a file / holding " ": the next passes go +// through, the after them fail, and the file is removed when both are spent. is $FELHOM_FAULT_DIR, else +// /opt/docker/felhom-controller/faults (the controller's data volume). A file that does not parse fails nothing. +var faultMu sync.Mutex + +func faultPoint(point string) error { + faultMu.Lock() + defer faultMu.Unlock() + dir := os.Getenv("FELHOM_FAULT_DIR") + if dir == "" { + dir = "/opt/docker/felhom-controller/faults" + } + p := filepath.Join(dir, point) + data, err := os.ReadFile(p) + if err != nil { + return nil + } + f := strings.Fields(string(data)) + if len(f) != 2 { + log.Printf("[WARN] [faultinject] %s: %q does not parse as \" \" — nothing injected", p, strings.TrimSpace(string(data))) + return nil + } + skip, e1 := strconv.Atoi(f[0]) + fail, e2 := strconv.Atoi(f[1]) + if e1 != nil || e2 != nil || skip < 0 || fail < 0 { + return nil + } + write := func() { + if skip == 0 && fail == 0 { + _ = os.Remove(p) + return + } + _ = os.WriteFile(p, []byte(fmt.Sprintf("%d %d\n", skip, fail)), 0o600) + } + if skip > 0 { + skip-- + write() + log.Printf("[WARN] [faultinject] %s: passed (skip left %d, fail left %d)", point, skip, fail) + return nil + } + if fail > 0 { + fail-- + write() + log.Printf("[ERROR] [faultinject] %s: FAULT INJECTED (fail left %d)", point, fail) + return fmt.Errorf("FAULT INJECTED at %s (D63 test build)", point) + } + return nil +} diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index fff9a610..c16bdb30 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -3191,6 +3191,9 @@ func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary { } func (a *stackAdapter) StopStack(name string) error { + if err := faultPoint("stop-" + name); err != nil { // D63: a no-op outside a -tags faultinject test build + return err + } return a.mgr.StopStack(name) } @@ -3435,6 +3438,9 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin return fmt.Errorf("restoring %s from unit: %w", fname, err) } } + if err := faultPoint("defwrite-after-compose"); err != nil { // D63: a no-op outside a -tags faultinject test build + return err + } // R-1057 (v0.314.0): the gate as the backup recorded it — the unit's app.yaml, read BEFORE the write. gate := stacks.ReadGateAtBackup(filepath.Join(composeSrcDir, "app.yaml")) if err := a.mgr.PersistUnitRedeployConfigGate(name, fullEnv, gate); err != nil { diff --git a/controller/internal/backup/d62_restore_copies_test.go b/controller/internal/backup/d62_restore_copies_test.go new file mode 100644 index 00000000..5c8467f0 --- /dev/null +++ b/controller/internal/backup/d62_restore_copies_test.go @@ -0,0 +1,185 @@ +package backup + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// D62 (operator ruling 2026-10-29): an unused restore copy is kept 30 days; the household sees each copy (app, the +// restore's date, size, the day it goes) and may delete one earlier. A copy a hold or a pending put-back needs is never +// deleted, by the sweep or by hand. + +// RED-PROOF: with the age at 14 days the 20-day copy is swept → FAILS; at 30 days only the 31-day copy goes. +func TestD62_AnUnusedCopyIsKept30Days(t *testing.T) { + m, _ := newOffboxManager(t) + now := time.Now() + day29 := r1256Copy(t, m, "a", 29*24*time.Hour, now) + day20 := r1256Copy(t, m, "b", 20*24*time.Hour, now) + day31 := r1256Copy(t, m, "c", 31*24*time.Hour, now) + got := m.SweepUnusedRestoreCopies(now) + if len(got) != 1 || got[0] != day31 { + t.Fatalf("swept %v — want only the 31-day copy %s", got, day31) + } + if !r1256Exists(day29) || !r1256Exists(day20) { + t.Fatal("a copy younger than 30 days was swept") + } + if restoreCopyUnusedAge != 30*24*time.Hour { + t.Fatalf("restoreCopyUnusedAge = %s, D62 says 30 days", restoreCopyUnusedAge) + } +} + +// The list shows each copy with its restore date, its size and the day it goes; a needed copy has no such day and +// says why. RED-PROOF: drop the hold check from restoreCopyNeededFor → the held copy is offered → FAILS. +func TestD62_TheListShowsDateSizeAndWhyACopyIsNeeded(t *testing.T) { + m, sett := newOffboxManager(t) + now := time.Now().UTC().Truncate(time.Second) + free := r1256Copy(t, m, "free", 2*24*time.Hour, now) + r1256Copy(t, m, "held", 2*24*time.Hour, now) + r1256Copy(t, m, "waiting", 2*24*time.Hour, now) + r1256Copy(t, m, "named", 2*24*time.Hour, now) + if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "held", At: now.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil { + t.Fatal(err) + } + if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "waiting", At: now.Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil { + t.Fatal(err) + } + if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil { + t.Fatal(err) + } + list := m.RestoreCopies() + if len(list) != 4 { + t.Fatalf("listed %d copies, want 4: %+v", len(list), list) + } + want := map[string]string{"free": "", "held": RestoreCopyNeededHold, "waiting": RestoreCopyNeededPutBack, "named": RestoreCopyNeededPutBack} + for _, c := range list { + if c.Needed != want[c.App] { + t.Errorf("%s: needed %q, want %q", c.App, c.Needed, want[c.App]) + } + if !c.At.Equal(now.Add(-2 * 24 * time.Hour)) { + t.Errorf("%s: restore date %s, want the stamp's %s", c.App, c.At, now.Add(-2*24*time.Hour)) + } + if c.SizeBytes != int64(len("LIVE")) { + t.Errorf("%s: size %d", c.App, c.SizeBytes) + } + if c.Needed == "" && !c.DeleteOn.Equal(c.At.Add(30*24*time.Hour)) { + t.Errorf("%s: deleted on %s, want 30 days after the restore", c.App, c.DeleteOn) + } + if c.Needed != "" && !c.DeleteOn.IsZero() { + t.Errorf("%s: a needed copy shows a delete day %s — the sweep never takes it", c.App, c.DeleteOn) + } + if c.App == "free" && c.Path != free { + t.Errorf("path %s, want %s", c.Path, free) + } + } +} + +// A held or waiting copy cannot be deleted by hand. RED-PROOF: drop the Needed check in DeleteRestoreCopy → FAILS. +func TestD62_AHeldCopyCannotBeDeleted(t *testing.T) { + m, sett := newOffboxManager(t) + now := time.Now() + for _, tc := range []struct{ app, reason string }{ + {"held", settings.HoldReasonRestoreMixed}, + {"waiting", settings.HoldReasonPutBackPending}, + {"failed", settings.HoldReasonRestoreFailed}, + } { + dir := r1256Copy(t, m, tc.app, time.Hour, now) + if err := sett.SetRestoreHold(settings.RestoreHold{Stack: tc.app, At: now.Format(time.RFC3339), Reason: tc.reason}); err != nil { + t.Fatal(err) + } + if _, err := m.DeleteRestoreCopy(tc.app, filepath.Base(dir)); !errors.Is(err, ErrRestoreCopyNeeded) { + t.Errorf("%s: err %v, want ErrRestoreCopyNeeded", tc.app, err) + } + if !r1256Exists(dir) { + t.Errorf("%s: a held copy was deleted", tc.app) + } + } + // a copy a cut restore names (the marker) is needed too + named := r1256Copy(t, m, "named", time.Hour, now) + if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil { + t.Fatal(err) + } + if _, err := m.DeleteRestoreCopy("named", filepath.Base(named)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(named) { + t.Errorf("a copy the marker names: err %v, exists %v", err, r1256Exists(named)) + } + // an unreadable marker offers nothing + mustWrite(t, m.appStop.path, "{not json") + free := r1256Copy(t, m, "free", time.Hour, now) + if _, err := m.DeleteRestoreCopy("free", filepath.Base(free)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(free) { + t.Errorf("an unreadable marker: err %v, exists %v", err, r1256Exists(free)) + } +} + +// A delete removes ONLY that copy: the app's other copy, another app's copy and anything outside the copy tree stay; +// a name that is not one path element, or a link in the copy's place, is refused. +// RED-PROOF: remove the validCopyName check → "../.." reaches outside → FAILS; remove the Lstat link check → the +// link is followed/removed → FAILS. +func TestD62_ADeleteRemovesOnlyThatCopy(t *testing.T) { + m, _ := newOffboxManager(t) + now := time.Now() + one := r1256Copy(t, m, "app", 3*time.Hour, now) + two := r1256Copy(t, m, "app", 2*time.Hour, now) + other := r1256Copy(t, m, "other", 3*time.Hour, now) + got, err := m.DeleteRestoreCopy("app", filepath.Base(one)) + if err != nil { + t.Fatal(err) + } + if got.Path != one || got.SizeBytes != int64(len("LIVE")) || got.App != "app" { + t.Fatalf("returned %+v", got) + } + if r1256Exists(one) || !r1256Exists(two) || !r1256Exists(other) { + t.Fatalf("one gone=%v, two kept=%v, other kept=%v", !r1256Exists(one), r1256Exists(two), r1256Exists(other)) + } + // path guard + outside := filepath.Join(m.namespaceRoot(m.systemDataPath), "backups", "primary", "keep.txt") + mustWrite(t, outside, "UNIT") + for _, bad := range [][2]string{{"app", ".."}, {"..", "primary"}, {"app", "../../primary"}, {"", "x"}, {"app", ""}, {"app/x", "y"}} { + if _, err := m.DeleteRestoreCopy(bad[0], bad[1]); !errors.Is(err, ErrRestoreCopyNotFound) { + t.Errorf("DeleteRestoreCopy(%q,%q) = %v, want not found", bad[0], bad[1], err) + } + } + if !r1256Exists(outside) || !r1256Exists(two) { + t.Fatal("a bad name reached outside the copy") + } + // a link in a copy's place is not a copy + target := t.TempDir() + mustWrite(t, filepath.Join(target, "precious"), "X") + link := filepath.Join(filepath.Dir(two), "20260101T000000Z") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := m.DeleteRestoreCopy("app", "20260101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) { + t.Errorf("a link: %v, want not found", err) + } + if !r1256Exists(filepath.Join(target, "precious")) { + t.Fatal("the link's target was removed") + } + if _, err := m.DeleteRestoreCopy("app", "20990101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) { + t.Errorf("a missing copy: %v", err) + } +} + +// While a backup or restore runs, a delete is refused and nothing moves. RED-PROOF: drop the busy check → FAILS. +func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) { + m, _ := newOffboxManager(t) + c := r1256Copy(t, m, "app", time.Hour, time.Now()) + m.BeginRestoreOp("restore", "other") + if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) { + t.Fatalf("during a restore: err %v, exists %v", err, r1256Exists(c)) + } + m.EndRestoreOp(true, "ok") + if err := m.acquireRunning(); err != nil { + t.Fatalf("could not take the backup single-flight: %v", err) + } + if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) { + t.Fatalf("during a backup run: err %v, exists %v", err, r1256Exists(c)) + } + m.ReleaseRunningForTest() + if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); err != nil || r1256Exists(c) { + t.Fatalf("control: with nothing running the delete goes through: %v", err) + } +} diff --git a/controller/internal/backup/r1256_restore_edges_test.go b/controller/internal/backup/r1256_restore_edges_test.go index be73874d..a2297c1e 100644 --- a/controller/internal/backup/r1256_restore_edges_test.go +++ b/controller/internal/backup/r1256_restore_edges_test.go @@ -300,7 +300,7 @@ func r1256Exists(p string) bool { _, err := os.Stat(p); return err == nil } func TestR1256_H8_OnlyAnUnusedOldCopyIsRemoved(t *testing.T) { m, sett := newOffboxManager(t) now := time.Now() - old := 20 * 24 * time.Hour + old := 40 * 24 * time.Hour unused := r1256Copy(t, m, "unused", old, now) young := r1256Copy(t, m, "young", 24*time.Hour, now) held := r1256Copy(t, m, "held", old, now) @@ -339,7 +339,7 @@ func TestR1256_H8_OnlyAnUnusedOldCopyIsRemoved(t *testing.T) { func TestR1256_H8_NothingIsRemovedDuringARestoreOrWithAnUnreadableMarker(t *testing.T) { m, _ := newOffboxManager(t) now := time.Now() - c := r1256Copy(t, m, "app", 20*24*time.Hour, now) + c := r1256Copy(t, m, "app", 40*24*time.Hour, now) m.BeginRestoreOp("restore", "other") if got := m.SweepUnusedRestoreCopies(now); len(got) != 0 || !r1256Exists(c) { t.Fatalf("removed %v during a restore", got) diff --git a/controller/internal/backup/restore_copies.go b/controller/internal/backup/restore_copies.go new file mode 100644 index 00000000..217d68dc --- /dev/null +++ b/controller/internal/backup/restore_copies.go @@ -0,0 +1,182 @@ +package backup + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// D62 (operator ruling 2026-10-29; `07` §6.3): the household sees its restore copies in the kept-copies list +// („Megőrzött másolatok") on the restore page — the app, the date of the restore the copy protects, its size, and the +// day the box deletes it by itself (restoreCopyUnusedAge after the restore) — and may delete one earlier with +// „Törlés most", after a warning and the app's name typed. +// +// A copy a hold or a pending put-back needs is NEVER deletable (the same three states the sweep leaves alone, R-1256 +// H8): the row says why in one line and the button is off; the handler refuses it too. While any backup or restore +// operation runs on the box, a delete is refused (409). The check and the removal are ONE step under m.mu, so a +// restore cannot begin in between (the R-1256 H6 shape). Pinned by TestD62_*. + +// Why a restore copy is needed — the row's one line, and the refusal's reason. "" = not needed: deletable. +const ( + RestoreCopyNeededHold = "hold" // the app is held (a failed put-back keeps its copy, D20) — support decides + RestoreCopyNeededPutBack = "putback" // a put-back waits for its drive (R-1251) or a cut restore names it (D22) + RestoreCopyNeededUnknown = "unknown" // the app-stop marker cannot be read: nothing is offered (fail closed) +) + +var ( + ErrRestoreCopyNotFound = errors.New("restore copy not found") + ErrRestoreCopyNeeded = errors.New("restore copy is needed by a hold or a pending put-back") + ErrRestoreCopyBusy = errors.New("a backup or restore operation runs") +) + +// RestoreCopy is one restore copy (/backups/restore-undo//). +type RestoreCopy struct { + App string + Stamp string // the folder's name; with App, the copy's identity on the page + At time.Time // when the restore began (the stamp; the folder's time when the stamp does not parse) + SizeBytes int64 + Path string + Needed string // "" or one of RestoreCopyNeeded* + DeleteOn time.Time // when the sweep removes it; zero when Needed != "" (a needed copy is never swept) +} + +// restoreCopyAt is a copy's restore time: its stamp, or the folder's time when the stamp does not parse. +func restoreCopyAt(dir, name string) (time.Time, bool) { + if at, err := time.Parse(restoreCopyStampLayout, name); err == nil { + return at, true + } + fi, err := os.Stat(dir) + if err != nil { + return time.Time{}, false + } + return fi.ModTime(), true +} + +// restoreCopyNeededFor is why app's copies are needed ("" = deletable). named is appStopNamedApps' answer, ok its ok. +func (m *Manager) restoreCopyNeededFor(app string, named map[string]bool, ok bool) string { + if !ok || m.settings == nil { + return RestoreCopyNeededUnknown + } + if named[app] { + return RestoreCopyNeededPutBack + } + if h, held := m.settings.GetRestoreHold(app); held { + if h.Reason == settings.HoldReasonPutBackPending { + return RestoreCopyNeededPutBack + } + return RestoreCopyNeededHold + } + return "" +} + +func dirSize(dir string) int64 { + var n int64 + _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { + if err == nil && d.Type().IsRegular() { + if fi, e := d.Info(); e == nil { + n += fi.Size() + } + } + return nil + }) + return n +} + +// RestoreCopies lists every restore copy on the box's drives, oldest first. +func (m *Manager) RestoreCopies() []RestoreCopy { + named, ok := m.appStopNamedApps() + var out []RestoreCopy + for _, nsRoot := range m.primaryUnitRoots() { + base := filepath.Join(nsRoot, "backups", "restore-undo") + apps, err := os.ReadDir(base) + if err != nil { + continue + } + for _, a := range apps { + if !a.IsDir() { + continue + } + app := a.Name() + needed := m.restoreCopyNeededFor(app, named, ok) + copies, err := os.ReadDir(filepath.Join(base, app)) + if err != nil { + continue + } + for _, c := range copies { + if !c.IsDir() { + continue + } + dir := filepath.Join(base, app, c.Name()) + at, ok := restoreCopyAt(dir, c.Name()) + if !ok { + continue + } + rc := RestoreCopy{App: app, Stamp: c.Name(), At: at, SizeBytes: dirSize(dir), Path: dir, Needed: needed} + if needed == "" { + rc.DeleteOn = at.Add(restoreCopyUnusedAge) + } + out = append(out, rc) + } + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) + return out +} + +// validCopyName is a single path element: no separator, not empty, not "." or "..". +func validCopyName(s string) bool { + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, `/\`) && filepath.Base(s) == s +} + +// DeleteRestoreCopy removes ONE restore copy, named by its app and its folder's name, and nothing else (the path +// guard: both names are single path elements, the folder is a real directory — not a link — directly under +// /backups/restore-undo//). Refused while any backup or restore operation runs (ErrRestoreCopyBusy) +// and for a copy a hold or a pending put-back needs (ErrRestoreCopyNeeded). The check and the removal hold m.mu, so +// no restore can begin in between. Returns the copy as it was (for the log line and the operator event). +func (m *Manager) DeleteRestoreCopy(app, stamp string) (RestoreCopy, error) { + if !validCopyName(app) || !validCopyName(stamp) { + return RestoreCopy{}, ErrRestoreCopyNotFound + } + m.mu.Lock() + defer m.mu.Unlock() + if busy, _ := m.restoreBusyLocked(); busy != "" { + return RestoreCopy{}, ErrRestoreCopyBusy + } + var found *RestoreCopy + for _, nsRoot := range m.primaryUnitRoots() { + appDir := filepath.Join(nsRoot, "backups", "restore-undo", app) + dir := filepath.Join(appDir, stamp) + fi, err := os.Lstat(dir) + if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 { + continue + } + if filepath.Dir(dir) != appDir || filepath.Dir(appDir) != filepath.Join(nsRoot, "backups", "restore-undo") { + continue + } + at, ok := restoreCopyAt(dir, stamp) + if !ok { + continue + } + found = &RestoreCopy{App: app, Stamp: stamp, At: at, SizeBytes: dirSize(dir), Path: dir} + break + } + if found == nil { + return RestoreCopy{}, ErrRestoreCopyNotFound + } + named, ok := m.appStopNamedApps() + if found.Needed = m.restoreCopyNeededFor(app, named, ok); found.Needed != "" { + return *found, ErrRestoreCopyNeeded + } + if err := os.RemoveAll(found.Path); err != nil { + return *found, fmt.Errorf("removing the restore copy %s: %w", found.Path, err) + } + _ = os.Remove(filepath.Dir(found.Path)) // the per-app folder, when it is now empty + return *found, nil +} diff --git a/controller/internal/backup/restore_copy_sweep.go b/controller/internal/backup/restore_copy_sweep.go index af056974..e42da216 100644 --- a/controller/internal/backup/restore_copy_sweep.go +++ b/controller/internal/backup/restore_copy_sweep.go @@ -15,7 +15,8 @@ import ( // stable start (R-1270). Every one of those is still needed. A copy outside all of them — the app runs again, or was // removed, and nothing names the copy — is disk the household cannot see or use. // -// The rule (safest reading, decided in the 2026-10-28 night run; operator may change the age): a copy is removed only +// The rule (the age: operator ruling D62, 2026-10-29 — 30 days, and the household may delete a copy earlier with +// „Törlés most", restore_copies.go): a copy is removed only // when ALL of these hold, and the sweep removes NOTHING when it cannot answer one of them: // - it is older than restoreCopyUnusedAge (its folder's stamp; the folder's time when the stamp does not parse); // - the app has no restore hold of any kind (held apps keep every copy — support decides); @@ -24,8 +25,8 @@ import ( // // Run every 6 hours from main.go (TestR1256_H8_TheSweepIsWiredAtStartup). Pinned by TestR1256_H8_*. -// restoreCopyUnusedAge is how long an unused copy stays for support after its restore. -const restoreCopyUnusedAge = 14 * 24 * time.Hour +// restoreCopyUnusedAge is how long an unused copy stays after its restore (D62: 30 days; TestD62_AnUnusedCopyIsKept30Days). +const restoreCopyUnusedAge = 30 * 24 * time.Hour // restoreCopyStampLayout is copyLiveVolumes' folder name. const restoreCopyStampLayout = "20060102T150405Z" diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 39780db2..b8fc7d69 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2634,6 +2634,19 @@ "kept.delete.type": "To delete it, type: %s", "kept.delete.mismatch": "The name you typed does not match (%s), so nothing was deleted.", "kept.deleted": "The kept data of %s is deleted.", + "restore_copy.title": "Copies from before a restore", + "restore_copy.hint": "When you restore an app, the box first saves the app’s data as it was. It deletes that copy by itself 30 days after the restore.", + "restore_copy.restored_on": "restore on %s", + "restore_copy.auto_delete": "automatically deleted on %s", + "restore_copy.needed.hold": "Cannot be deleted: the app is stopped, and this copy is its way back. Support decides about it.", + "restore_copy.needed.putback": "Cannot be deleted: an interrupted restore puts the data back from this copy.", + "restore_copy.needed.unknown": "Cannot be deleted now: the box cannot tell whether this copy is still needed.", + "restore_copy.delete": "Delete now", + "restore_copy.warning": "This is the copy of %s’s data from before the restore on %s. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.", + "restore_copy.app_busy": "Cannot be deleted now: an operation runs on the app. Try again when it has ended.", + "restore_copy.not_found": "This copy is no longer there, so nothing was deleted.", + "restore_copy.failed": "The copy could not be deleted: %s", + "flash.restore_copy.deleted": "The copy of %s from before its restore is deleted.", "kept.page.title": "Kept data", "kept.page.back": "Back", "kept.page.intro": "Data that removed apps left on the drives. You can look at it, load it into a new install, or delete it. The server never deletes any of it by itself.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 805529ee..caedf1b3 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2622,6 +2622,19 @@ "kept.delete.type": "A törléshez írd be: %s", "kept.delete.mismatch": "A beírt név nem egyezik (%s), ezért nem töröltünk semmit.", "kept.deleted": "A(z) %s megőrzött adatai törölve.", + "restore_copy.title": "Visszaállítás előtti másolatok", + "restore_copy.hint": "Visszaállításkor a doboz előbb elmenti az alkalmazás akkori adatait. Ezt a másolatot a visszaállítás után 30 nappal magától törli.", + "restore_copy.restored_on": "visszaállítás: %s", + "restore_copy.auto_delete": "automatikusan törlődik: %s", + "restore_copy.needed.hold": "Nem törölhető: az alkalmazás le van állítva, és ez a másolat az út vissza. Az ügyfélszolgálat dönt róla.", + "restore_copy.needed.putback": "Nem törölhető: egy félbeszakadt visszaállítás ebből a másolatból teszi vissza az adatokat.", + "restore_copy.needed.unknown": "Most nem törölhető: a doboz nem tudja eldönteni, kell-e még ez a másolat.", + "restore_copy.delete": "Törlés most", + "restore_copy.warning": "Ez a(z) %s adatainak másolata a %s-i visszaállítás előttről. Ha törlöd, a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza.", + "restore_copy.app_busy": "Most nem törölhető: az alkalmazáson éppen egy művelet fut. Próbáld újra, ha véget ért.", + "restore_copy.not_found": "Ez a másolat már nincs meg, ezért nem töröltünk semmit.", + "restore_copy.failed": "A másolatot nem sikerült törölni: %s", + "flash.restore_copy.deleted": "A(z) %s visszaállítás előtti másolata törölve.", "kept.page.title": "Megőrzött adatok", "kept.page.back": "Vissza", "kept.page.intro": "Az eltávolított alkalmazások lemezen maradt adatai. Megnézheted őket, betöltheted egy új telepítésbe, vagy törölheted. Magától a szerver soha nem töröl közülük semmit.", diff --git a/controller/internal/notify/d62_restore_copy_deleted_test.go b/controller/internal/notify/d62_restore_copy_deleted_test.go new file mode 100644 index 00000000..af9d81f8 --- /dev/null +++ b/controller/internal/notify/d62_restore_copy_deleted_test.go @@ -0,0 +1,34 @@ +package notify + +import ( + "encoding/json" + "strings" + "testing" +) + +// D62 — restore_copy_deleted: info (the hub's vocabulary), the operator message only (no household sentence — the hub +// bars the type from customers too), and the details carry who, app, the restore's date and the size. +func TestD62_RestoreCopyDeletedEvent(t *testing.T) { + n := &Notifier{} + var et, sev, msg, cust string + var det []byte + n.pushFn = func(eventType, severity, message, customer string, details interface{}) { + et, sev, msg, cust = eventType, severity, message, customer + det, _ = json.Marshal(details) + } + n.NotifyRestoreCopyDeleted(RestoreCopyDeletedDetails{App: "vikunja", RestoreAt: "2026-10-29T08:00:00Z", SizeBytes: 4096, + Who: "the household (dashboard sign-in) from 192.168.0.5", Path: "/x/backups/restore-undo/vikunja/20261029T080000Z"}) + if et != "restore_copy_deleted" || sev != "info" || cust != "" { + t.Fatalf("type=%q severity=%q customer=%q", et, sev, cust) + } + for _, want := range []string{"vikunja", "2026-10-29T08:00:00Z", "4096", "the household"} { + if !strings.Contains(msg, want) { + t.Fatalf("message %q lacks %q", msg, want) + } + } + for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":4096`, `"who":"the household`} { + if !strings.Contains(string(det), want) { + t.Fatalf("details %s lack %s", det, want) + } + } +} diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index af099677..7026a85b 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -1585,6 +1585,25 @@ func (n *Notifier) NotifyAppDefinitionRefused(app, detail string) { n.pushEventBoth("app_definition_refused", "warning", msg, "", AppDefinitionRefusedDetails{App: app, Refused: detail}) } +// RestoreCopyDeletedDetails is restore_copy_deleted's record: who deleted which app's copy, the restore it protected, +// and its size. +type RestoreCopyDeletedDetails struct { + App string `json:"app"` + RestoreAt string `json:"restore_at"` // RFC 3339, UTC + SizeBytes int64 `json:"size_bytes"` + Who string `json:"who"` + Path string `json:"path"` +} + +// NotifyRestoreCopyDeleted (D62, operator 2026-10-29): the household deleted a restore copy with „Törlés most" before +// its 30 days. Info, operator-only on the hub (registered in both of its lists, hub D62): an audit line for support, +// never a household mail. Pinned by TestD62_RestoreCopyDeletedEvent. +func (n *Notifier) NotifyRestoreCopyDeleted(d RestoreCopyDeletedDetails) { + msg := fmt.Sprintf("%s: the restore copy from before the restore of %s (%d bytes) was deleted by hand — %s. That restore can no longer be undone from this box.", + d.App, d.RestoreAt, d.SizeBytes, d.Who) + n.pushEventBoth("restore_copy_deleted", "info", msg, "", d) +} + // healthSeverity is the event severity a health status would be sent at (R-647): the disabled path // names what it drops, and "warn" is a health STATUS, not a severity the hub knows. func healthSeverity(status string) string { diff --git a/controller/internal/web/d62_restore_copies_test.go b/controller/internal/web/d62_restore_copies_test.go new file mode 100644 index 00000000..1731be8e --- /dev/null +++ b/controller/internal/web/d62_restore_copies_test.go @@ -0,0 +1,233 @@ +package web + +import ( + "encoding/json" + "html/template" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// D62 (operator ruling 2026-10-29): restore copies in the kept-copies list with „Törlés most". Real settings, manager, +// disk and a hub stand-in that records the event — the handler is the endpoint the page's form calls. + +type d62Fixture struct { + s *Server + sett *settings.Settings + nsRoot string + events chan map[string]interface{} +} + +func newD62Fixture(t *testing.T) *d62Fixture { + t.Helper() + 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) + } + cfg := &config.Config{} + cfg.Paths.DataDir = tmp + cfg.Paths.SystemDataPath = filepath.Join(tmp, "sys") + events := make(chan map[string]interface{}, 8) + hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/event" { + var ev map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&ev) + events <- ev + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(hub.Close) + n := notify.New(hub.URL, "test-key", "c1", sett, lg, false) + s := &Server{cfg: cfg, settings: sett, backupMgr: backup.NewManager(cfg, sett, lg), notifier: n, logger: lg} + return &d62Fixture{s: s, sett: sett, nsRoot: backup.NamespaceRootFor(cfg.Paths.SystemDataPath, cfg.Paths.SystemDataPath), events: events} +} + +// plant writes one restore copy of app taken at `at`; returns its folder. +func (f *d62Fixture) plant(t *testing.T, app string, at time.Time) string { + t.Helper() + dir := filepath.Join(backup.RestoreUndoDir(f.nsRoot, app), at.UTC().Format("20060102T150405Z")) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, app+"_data.tar"), []byte("LIVE-DATA"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func (f *d62Fixture) post(form string) (int, map[string]interface{}) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/backup/restore-copy/delete", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "192.168.0.5:5555" + f.s.restoreCopyDeleteHandler(rr, req) + var body map[string]interface{} + _ = json.Unmarshal(rr.Body.Bytes(), &body) + return rr.Code, body +} + +func exists(p string) bool { _, err := os.Stat(p); return err == nil } + +// The list shows the restore's date, the size and „automatikusan törlődik: <30 days later>"; a held copy shows why and +// offers no warning. RED-PROOF: drop the auto-delete part from restoreCopyRows → FAILS. +func TestD62_TheListShowsTheDateAndTheAutoDeleteDay(t *testing.T) { + f := newD62Fixture(t) + at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC) + f.plant(t, "vikunja", at) + f.plant(t, "immich", at) + if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: at.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil { + t.Fatal(err) + } + rows := f.s.restoreCopyRows("hu") + if len(rows) != 2 { + t.Fatalf("rows %+v", rows) + } + var free, held restoreCopyRow + for _, r := range rows { + if r.App == "vikunja" { + free = r + } else { + held = r + } + } + for _, want := range []string{"visszaállítás: 2026. 10. 29. 09:00", "9 B", "automatikusan törlődik: 2026. 11. 28."} { + if !strings.Contains(free.Line, want) { + t.Errorf("free row line %q lacks %q", free.Line, want) + } + } + if !strings.Contains(free.Warning, "Ez a(z) vikunja adatainak másolata a 2026. 10. 29-i visszaállítás előttről") || + !strings.Contains(free.Warning, "a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza") { + t.Errorf("warning %q", free.Warning) + } + if free.NeededText != "" || free.TypePrompt == "" { + t.Errorf("free row: needed %q prompt %q", free.NeededText, free.TypePrompt) + } + if held.NeededText == "" || held.Warning != "" || strings.Contains(held.Line, "automatikusan") { + t.Errorf("held row offers a delete or an auto-delete day: %+v", held) + } + en := f.s.restoreCopyRows("en") + for _, r := range en { + if r.App == "vikunja" && (!strings.Contains(r.Line, "automatically deleted on 2026-11-28") || + !strings.Contains(r.Warning, "This is the copy of vikunja’s data from before the restore on 2026-10-29. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.")) { + t.Errorf("English row %+v", r) + } + } +} + +// The page: a deletable copy has the form (app, stamp, the typed name, CSRF); a held one has the button OFF and the +// reason, and no form. RED-PROOF: render the form for a needed copy too → FAILS. +func TestD62_ThePageOffersTheFormOnlyForADeletableCopy(t *testing.T) { + d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") + d["Backup"] = i18nBackupStatus(false, false) + d["RestoreCopies"] = []restoreCopyRow{ + {App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "L1", Warning: "W1", TypePrompt: "P1"}, + {App: "immich", Stamp: "20261020T080000Z", Name: "Immich", Line: "L2", NeededText: "HELD-REASON"}, + } + d["CSRFField"] = template.HTML(``) + html := renderBackupPage(t, "backups_restore", d) + for _, want := range []string{"Visszaállítás előtti másolatok", `action="/backup/restore-copy/delete"`, `name="app" value="vikunja"`, + `name="stamp" value="20261029T080000Z"`, `name="confirm"`, "W1", "P1", "Törlés most", `data-restore-copy-off="true"`, "HELD-REASON"} { + if !strings.Contains(html, want) { + t.Errorf("page lacks %q", want) + } + } + if strings.Contains(html, `name="app" value="immich"`) || strings.Contains(html, `data-restore-copy="immich/`) { + t.Error("a held copy got a delete form") + } + form := html[strings.Index(html, `action="/backup/restore-copy/delete"`):] + if !strings.Contains(form[:strings.Index(form, "")], `value="TOKEN-D62"`) { + t.Error("the delete form carries no CSRF field") + } + none := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") + none["Backup"] = i18nBackupStatus(false, false) + if html := renderBackupPage(t, "backups_restore", none); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "restore-copy") { + t.Error("a page with no copy shows the section") + } +} + +// A wrong typed name is refused (400) and nothing is deleted. RED-PROOF: drop the name check → FAILS. +func TestD62_AWrongNameIsRefused(t *testing.T) { + f := newD62Fixture(t) + c := f.plant(t, "vikunja", time.Now().Add(-time.Hour)) + for _, typed := range []string{"", "vikunj", "immich", "VIKUNJA "} { + code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=" + typed) + if code != http.StatusBadRequest || body["ok"] != false || !exists(c) { + t.Fatalf("typed %q: code %d body %v exists %v", typed, code, body, exists(c)) + } + } +} + +// A held copy is refused by the handler too (409, the reason), and stays. RED-PROOF: drop the Needed check in the +// handler AND in DeleteRestoreCopy → FAILS (either alone holds it). +func TestD62_AHeldCopyIsRefusedByTheHandler(t *testing.T) { + f := newD62Fixture(t) + c := f.plant(t, "immich", time.Now().Add(-time.Hour)) + if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: time.Now().Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil { + t.Fatal(err) + } + code, body := f.post("app=immich&stamp=" + filepath.Base(c) + "&confirm=immich") + if code != http.StatusConflict || !exists(c) || !strings.Contains(body["error"].(string), "félbeszakadt visszaállítás") { + t.Fatalf("code %d body %v exists %v", code, body, exists(c)) + } +} + +// While a restore runs, a delete is 409 with a sentence, and nothing moves. RED-PROOF: drop the busy check → FAILS. +func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) { + f := newD62Fixture(t) + c := f.plant(t, "vikunja", time.Now().Add(-time.Hour)) + f.s.backupMgr.BeginRestoreOp("restore", "other") + code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja") + if code != http.StatusConflict || !exists(c) || body["error"] == "" { + t.Fatalf("code %d body %v exists %v", code, body, exists(c)) + } + f.s.backupMgr.EndRestoreOp(true, "ok") +} + +// The right name: the copy is gone, ONLY it (the app's other copy and another app's copy stay), the answer leads back +// to the page with the flash, and the operator event carries who, app, the restore's date and size. +// RED-PROOF: drop the NotifyRestoreCopyDeleted call → no event → FAILS. +func TestD62_ADeleteRemovesOnlyThatCopyAndStoresTheEvent(t *testing.T) { + f := newD62Fixture(t) + at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC) + c := f.plant(t, "vikunja", at) + sibling := f.plant(t, "vikunja", at.Add(time.Hour)) + other := f.plant(t, "immich", at) + code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja") + if code != http.StatusOK || body["ok"] != true || !strings.Contains(body["redirect"].(string), "flash.restore_copy.deleted") { + t.Fatalf("code %d body %v", code, body) + } + if exists(c) || !exists(sibling) || !exists(other) { + t.Fatalf("gone=%v sibling=%v other=%v", !exists(c), exists(sibling), exists(other)) + } + select { + case ev := <-f.events: + if ev["event_type"] != "restore_copy_deleted" || ev["severity"] != "info" { + t.Fatalf("event %v", ev) + } + det, _ := json.Marshal(ev["details"]) + for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":9`, `"who":"the household (dashboard sign-in) from 192.168.0.5"`} { + if !strings.Contains(string(det), want) { + t.Errorf("details %s lack %s", det, want) + } + } + case <-time.After(5 * time.Second): + t.Fatal("no restore_copy_deleted event reached the hub") + } + // a second press: the copy is gone → 404, no second event + if code, _ := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja"); code != http.StatusNotFound { + t.Fatalf("second press code %d", code) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 234212f7..11471b84 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1346,6 +1346,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { // D25: second-drive copies whose source is gone — kept until the household removes one. rows := s.keptCopyRows(s.langFor(r)) data["KeptCopies"] = rows + data["RestoreCopies"] = s.restoreCopyRows(s.langFor(r)) // D62: restore copies in the same list for _, k := range rows { if k.Restorable { data["KeptUnitRestorable"] = true // D47: the confirm script only when a row offers „Visszaállítás" diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go index 2d551234..5c9e6a20 100644 --- a/controller/internal/web/i18n_cases_b_test.go +++ b/controller/internal/web/i18n_cases_b_test.go @@ -255,6 +255,21 @@ func i18nCasesB() []i18nCase { d["KeptUnitRestorable"] = true return d }}, + // D62 (2026-10-29): restore copies in the kept-copies list — one deletable (the warning and the typed name), one a + // hold needs (the button off, the reason). A NEW case on a box without off-site (the second call site), so no + // existing fixture changes. Line, Warning, TypePrompt and NeededText are composed in Go in the request's language; + // the fixture carries the same data in both renders. + {"backups_restore_restore_copies", "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"] = false + d["RestoreCopies"] = []restoreCopyRow{ + {App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "2026-10-29 09:00 · 12 MB · 2026-11-28", + Warning: "Vikunja 2026-10-29", TypePrompt: "Vikunja"}, + {App: "immich", Stamp: "20261020T080000Z", Name: "Immich Photos", Line: "2026-10-20 10:00 · 2 GB", NeededText: "hold: support decides"}, + } + return d + }}, {"backups_restore_known_empty", "backups_restore", func() map[string]interface{} { d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") d["Backup"] = i18nBackupStatus(false, false) diff --git a/controller/internal/web/kept_copies.go b/controller/internal/web/kept_copies.go index 34d05c0b..5b5219fd 100644 --- a/controller/internal/web/kept_copies.go +++ b/controller/internal/web/kept_copies.go @@ -1,12 +1,16 @@ package web import ( + "encoding/json" "errors" "net/http" "net/url" "strings" + "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" ) // D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is @@ -121,3 +125,134 @@ func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request) }() http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound) } + +// ── D62 (operator ruling 2026-10-29): restore copies in the same list ─────────────────────────────────────────────── +// +// Each restore copy shows the app, the date of the restore it protects, its size and the day the box deletes it by +// itself (30 days after the restore). „Törlés most" deletes it earlier: a warning that names the app and the date, +// and the app's name typed. A copy a hold or a pending put-back needs shows why in one line and has no button — and +// the handler refuses it too. Pinned by TestD62_* (web). + +// restoreCopyRow is one restore copy on the page. +type restoreCopyRow struct { + App string + Stamp string + Name string // the app's display name — what the household types + Line string // „visszaállítás: · · automatikusan törlődik: " + NeededText string // why it cannot be deleted; "" = the button is offered + Warning string // the confirm step's sentence (app and date) + TypePrompt string +} + +// restoreCopyAppName is the app's display name, or its slug when the app is gone. +func (s *Server) restoreCopyAppName(app string) string { + if s.stackMgr != nil { + if st, ok := s.stackMgr.GetStack(app); ok && st.Meta.DisplayName != "" { + return st.Meta.DisplayName + } + } + return app +} + +func (s *Server) restoreCopyRows(lang string) []restoreCopyRow { + if s.backupMgr == nil { + return nil + } + var out []restoreCopyRow + for _, c := range s.backupMgr.RestoreCopies() { + name := s.restoreCopyAppName(c.App) + at := c.At.In(getTimezone()) + line := s.msgLang(lang, "restore_copy.restored_on", fmtLocalTime(lang, c.At)) + " · " + + i18n.LocalizeDecimals(lang, backup.HumanizeBytes(c.SizeBytes)) + row := restoreCopyRow{App: c.App, Stamp: c.Stamp, Name: name} + if c.Needed == "" { + line += " · " + s.msgLang(lang, "restore_copy.auto_delete", i18n.FormatDate(lang, c.DeleteOn.In(getTimezone()))) + row.Warning = s.msgLang(lang, "restore_copy.warning", name, strings.TrimSuffix(i18n.FormatDate(lang, at), ".")) + row.TypePrompt = s.msgLang(lang, "kept.delete.type", name) + } else { + row.NeededText = s.msgLang(lang, "restore_copy.needed."+c.Needed) + } + row.Line = line + out = append(out, row) + } + return out +} + +// restoreCopyJSON is the delete's answer: ok with the page to go to, or the refusal's sentence. +func restoreCopyJSON(w http.ResponseWriter, code int, ok bool, msg, redirect string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "error": msg, "redirect": redirect}) +} + +// restoreCopyDeleteHandler is „Törlés most": POST /backup/restore-copy/delete (app, stamp, confirm). JSON answer: +// 200 deleted; 400 the typed name does not match; 404 no such copy; 409 the copy is needed, or an operation runs on +// the box or the app; 500 the removal failed. CSRF as every state-changing form (CsrfProtect). +func (s *Server) restoreCopyDeleteHandler(w http.ResponseWriter, r *http.Request) { + lang := s.langFor(r) + if s.backupMgr == nil { + restoreCopyJSON(w, http.StatusServiceUnavailable, false, s.msgLang(lang, "flash.offbox.mgr_unreachable"), "") + return + } + _ = r.ParseForm() + app, stamp := strings.TrimSpace(r.FormValue("app")), strings.TrimSpace(r.FormValue("stamp")) + var found *backup.RestoreCopy + for _, c := range s.backupMgr.RestoreCopies() { + if c.App == app && c.Stamp == stamp { + c := c + found = &c + break + } + } + if found == nil { + s.logger.Printf("[WARN] [web] restore-copy delete REFUSED: %q/%q is not a listed copy (from %s)", app, stamp, clientIP(r)) + restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "") + return + } + if found.Needed != "" { + s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the copy is needed (%s)", found.Path, found.Needed) + restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+found.Needed), "") + return + } + name := s.restoreCopyAppName(app) + if strings.TrimSpace(r.FormValue("confirm")) != name { + s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the typed confirmation did not match", found.Path) + restoreCopyJSON(w, http.StatusBadRequest, false, s.msgLang(lang, "kept.delete.mismatch", name), "") + return + } + if s.stackMgr != nil && s.stackMgr.IsUpdating(app) { + s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: an update of %s runs", found.Path, app) + restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.app_busy"), "") + return + } + who := "the household (dashboard sign-in) from " + clientIP(r) + c, err := s.backupMgr.DeleteRestoreCopy(app, stamp) + switch { + case errors.Is(err, backup.ErrRestoreCopyBusy): + busy, mirror := s.backupMgr.RestoreBusy() + msg, blocked := s.restoreBusyMessage(busy, mirror) + if !blocked { + msg = s.msgLang(lang, "restore_copy.app_busy") + } + s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: a backup or restore operation runs", found.Path) + restoreCopyJSON(w, http.StatusConflict, false, msg, "") + return + case errors.Is(err, backup.ErrRestoreCopyNeeded): + restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+c.Needed), "") + return + case errors.Is(err, backup.ErrRestoreCopyNotFound): + restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "") + return + case err != nil: + s.logger.Printf("[ERROR] [web] restore-copy delete of %s failed: %v", found.Path, err) + restoreCopyJSON(w, http.StatusInternalServerError, false, s.msgLang(lang, "restore_copy.failed", err.Error()), "") + return + } + restoreAt := c.At.UTC().Format(time.RFC3339) + s.logger.Printf("[WARN] [web] restore copy DELETED by hand (D62): app=%s restore_at=%s size=%d path=%s who=%s", + app, restoreAt, c.SizeBytes, c.Path, who) + if s.notifier != nil { + s.notifier.NotifyRestoreCopyDeleted(notify.RestoreCopyDeletedDetails{App: app, RestoreAt: restoreAt, SizeBytes: c.SizeBytes, Who: who, Path: c.Path}) + } + restoreCopyJSON(w, http.StatusOK, true, "", "/backups/restore?"+flashQuery("flash", "flash.restore_copy.deleted", name)) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index ef9ee2af..5bbd11ed 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -844,6 +844,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // D25 (2026-10-21): „Eltávolítás" of ONE kept second-drive copy whose source is gone. case path == "/backup/kept/remove" && r.Method == http.MethodPost: s.keptCopyRemoveHandler(w, r) + // D62 (2026-10-29): „Törlés most" of ONE restore copy (typed name; never a copy a hold or a put-back needs). + case path == "/backup/restore-copy/delete" && r.Method == http.MethodPost: + s.restoreCopyDeleteHandler(w, r) // D47 (2026-10-25): „Visszaállítás" from a set-aside recovery unit of an earlier install. case path == "/backup/kept/restore" && r.Method == http.MethodPost: s.keptUnitRestoreHandler(w, r) diff --git a/controller/internal/web/templates/backups_restore.html b/controller/internal/web/templates/backups_restore.html index df2bc019..1a514848 100644 --- a/controller/internal/web/templates/backups_restore.html +++ b/controller/internal/web/templates/backups_restore.html @@ -429,9 +429,9 @@ function submitRestore() { {{/* D25 + D47: the kept copies (second-drive copies whose source is gone; set-aside units of an earlier install). Shown inside the off-site card when the box has off-site, and after it when it has none — before 2026-10-25 it sat only inside the off-site block, so a box without off-site never listed its kept copies. One body, two call sites. */}} -{{define "kept_copies_section"}}{{if .KeptCopies}} +{{define "kept_copies_section"}}{{if or .KeptCopies .RestoreCopies}}
-

{{T "backups_restore.megorzott_masolatok"}}

+

{{T "backups_restore.megorzott_masolatok"}}

{{if .KeptCopies}}

{{T "backups_restore.megorzott_masolatok_hint"}}

{{range .KeptCopies}} @@ -467,4 +467,43 @@ the off-site block, so a box without off-site never listed its kept copies. One }); } - {{end}}{{end}} + {{end}}{{if .RestoreCopies}}{{/* D62 (operator ruling 2026-10-29): restore copies — the app, the restore's date, the size, the day the box deletes + it; „Törlés most" after the warning and the app's name typed. A needed copy: the button is off, the reason in one line. */}} +

{{T "restore_copy.title"}}

+

{{T "restore_copy.hint"}}

+
+ {{range .RestoreCopies}} + {{template "app_list_row" dict "Slug" .App "Name" .Name "Secondary" .Line}}{{if .NeededText}} + {{end}} + {{template "app_list_row_end"}}{{if .NeededText}} +

{{.NeededText}}

{{else}} +
+ {{T "restore_copy.delete"}} +
+ {{$.CSRFField}} + + +

{{.Warning}}

+ + + + +
+
{{end}} + {{end}} +
+ + {{end}}{{end}}{{end}} diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_restore_copies.html b/controller/internal/web/testdata/i18n_parity/backups_restore_restore_copies.html new file mode 100644 index 00000000..5089a45f --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_restore_copies.html @@ -0,0 +1,915 @@ + + + + + + + + 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. +
+
+ +
+
+ +
+ +
+
+ + + + +
+

Megőrzött másolatok

+

Visszaállítás előtti másolatok

+

Visszaállításkor a doboz előbb elmenti az alkalmazás akkori adatait. Ezt a másolatot a visszaállítás után 30 nappal magától törli.

+
+ + +
+ +
+ Vikunja + 2026-10-29 09:00 · 12 MB · 2026-11-28 +
+
+ + +
+
+ +
+ Törlés most +
+ + + +

Vikunja 2026-10-29

+ + + + +
+
+ + +
+ +
+ Immich Photos + 2026-10-20 10:00 · 2 GB +
+
+ + + +
+
+ +

hold: support decides

+ +
+ + + + + +
+

Hordozható mentéscsomag (.fab)

+

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

+ +

Nincs telepített alkalmazás.

+ +
+ + + + + + +
+ + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index cc393eb9..6b0035f3 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -310,6 +310,16 @@ "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.", + "flash.restore_copy.deleted": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.app_busy": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.auto_delete": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.failed": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.not_found": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.restored_on": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.warning": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.needed.hold": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.needed.putback": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_test.go.", + "restore_copy.needed.unknown": "BORN AS A KEY (D62, operator ruling 2026-10-29) -- restore copies in the kept-copies list: the line, the warning, the delete's refusals and flash. Pinned by internal/web/d62_restore_copies_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.",