From d698ce343bb33ca0ad64046a1c6a2e19177fa1e0 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 13 Sep 2026 22:50:18 +0200 Subject: [PATCH] controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-487: the local backup lists are keyed on the drives, not on what is deployed — a removed app whose unit was kept is listed with the restore that reinstalls it, the picker answers for it, and the restore opens the unit where it sits. R-491: a removal clears the app's update hold. R-490: /api/system/info reaches the API router and reads the default storage path. R-489: volumes_removed is the real before/after difference, [] when none. R-476: a Tier-2 copy is dated by its data, not its manifest. R-456: the boot-orphan rule is pinned. Every fix red-proofed. --- CHANGELOG.md | 38 +++++ controller/README.md | 6 +- controller/cmd/controller/main.go | 4 + controller/cmd/controller/r475_wiring_test.go | 49 ++++++ .../internal/api/r474_remove_wiring_test.go | 59 ++++++++ controller/internal/api/router.go | 26 +++- .../backup/r476_unit_data_date_test.go | 37 +++++ .../backup/r487_removed_units_test.go | 135 +++++++++++++++++ controller/internal/backup/removed_units.go | 143 ++++++++++++++++++ controller/internal/backup/restore_points.go | 7 + controller/internal/backup/restore_unit.go | 17 ++- controller/internal/backup/tier2_restore.go | 17 +++ .../bootrecon/r456_partly_dead_test.go | 33 ++++ controller/internal/settings/settings.go | 17 +++ controller/internal/stacks/delete.go | 45 +++++- controller/internal/stacks/manager.go | 6 + .../stacks/r489_volumes_removed_test.go | 38 +++++ controller/internal/web/handlers.go | 24 +++ .../internal/web/r487_removed_row_test.go | 111 ++++++++++++++ .../internal/web/templates/backups_apps.html | 26 +++- .../web/templates/backups_restore.html | 8 + controller/scripts/retrieval_promise_gate.py | 5 + 22 files changed, 838 insertions(+), 13 deletions(-) create mode 100644 controller/internal/backup/r476_unit_data_date_test.go create mode 100644 controller/internal/backup/r487_removed_units_test.go create mode 100644 controller/internal/backup/removed_units.go create mode 100644 controller/internal/bootrecon/r456_partly_dead_test.go create mode 100644 controller/internal/stacks/r489_volumes_removed_test.go create mode 100644 controller/internal/web/r487_removed_row_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d5bed64..772a730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +## v0.242.0 — a removed app is listed with its kept backup, and five small ones (2026-09-13/14, R-487 / R-491 / R-490 / R-489 / R-476 / R-456) + +**MinAgent: 0.129.0** (unchanged) + +- **R-487 (P2) — a removed app whose backups were kept is listed, with the restore that brings it + back.** After „Töröld az adataimat is" with the backups kept, the unit sat on the drive and was + restorable through `POST /backup/restore` — and listed on NEITHER backup page (measured on demo-hp + with adventurelog). The local lists are now keyed on the DRIVES the way R-237 keyed the off-site + list on the store: `Manager.ListRemovedAppUnits` walks `backups/primary/` on the system path and + every connected registered drive and lists the units whose app is not deployed. The Mentések page + renders them after the deployed rows („Eltávolítva — visszaállítható", one action: *Visszaállítás + a mentésből*), the Visszaállítás picker lists them in their own group, `GET /api/backup/snapshots` + answers for them instead of 404, and the restore opens the unit WHERE IT SITS + (`primaryUnitDirFor`) — a unit kept on a data drive used to be unreachable, because the drive of a + removed app is unknown and the fallback named the system path. +- **R-476 — a Tier-2 copy is dated by its data, not by its manifest.** The manifest moves only when + the app's definition changes, so a copy holding a dump from 00:30 today was dated the day before. + `Tier2Coverage.UnitDataDate` (newest dump in the mirrored unit) is what a refreshed leg names; a + PRESERVED package keeps the manifest date (R-403). +- **R-491 (P2) — a removal clears the app's update hold.** An app held after a failed update and then + removed kept its hold in the store, so a reinstall under the same name would begin refused with a + sentence about a copy that no longer existed (measured on demo-hp with nextcloud). `removeStack` now + clears an UPDATE hold (`Settings.ClearUpdateHold`); an R-379 restore hold stays operator-cleared. +- **R-490 — the monitoring page's memory-distribution card can render.** `/api/system/info` was eaten + by the web layer's `/api/system/` prefix (404 „ismeretlen végpont"); an exact-path mount on the API + router wins in ServeMux. `systemInfo` also gained the default-storage-path fallback every other + reader of the empty `cfg.Paths.HDDPath` already had (R-465). +- **R-489 — `volumes_removed` reports the volumes a removal removed**, `[]` when none — never `null`: + the project's volumes are listed before and after `down --volumes` and the difference is reported + (compose's progress lines are printed to a TTY it does not have here). +- **R-456 — the boot-orphan rule is pinned:** an absent member does not make a stack degraded, so a + partly-dead stack is not a boot orphan; a present-but-dead member is (`internal/bootrecon/r456_partly_dead_test.go`). + +**Tests / red-proofs:** `TestR487_` (lister over two drives, picker, unit dir, row builder, two +render tests with negative controls), `TestR476_` (date rule + coverage reader), `TestR491_` (wiring), +`TestR490_` (mount + behaviour), `TestR489_` (difference, never null, the docker listing), `TestR456_`; +red-proofs in `felhom.eu` `audits/v0242-2026-09-14/`. + ## v0.241.0 — a bind-data app leans on off-site before its own unit, and the hold says what the copy holds (2026-09-13, R-479) **MinAgent: 0.129.0** (unchanged) diff --git a/controller/README.md b/controller/README.md index c7bddb5..80c132a 100644 --- a/controller/README.md +++ b/controller/README.md @@ -985,7 +985,7 @@ height with no magic number to drift as item counts change. |-------|------|----------| | `/backups` | Áttekintés | storage overview, whole-guest Rendszermentés, status stat cards, single-copy warning, **backup-target banner + offer (v0.186.0)** | | `/backups/remote` | Távoli mentés | Felhom-offsite status card (3 states, display-only), tier-3 status block + quota, participation toggles (+ zero-toggle hint; the persisted zero-toggle run-warning is DISPLAY-replaced by a "kijelölés módosult" note once ≥1 app is toggled — `offboxWarningDisplay`, v0.126.0), manual-target form (`#offbox-section`) | -| `/backups/apps` | Alkalmazások | schedule, Adatbázisok table, per-app 1./2./3. tier rows (tier-2 config entry; tier-3 actions deep-link to `/backups/remote#offbox-section`) | +| `/backups/apps` | Alkalmazások | schedule, Adatbázisok table, per-app 1./2./3. tier rows (tier-2 config entry; tier-3 actions deep-link to `/backups/remote#offbox-section`); **since v0.242.0 a REMOVED app whose recovery unit was kept is listed after the deployed rows („Eltávolítva — visszaállítható") with one action, the unit restore that reinstalls it (R-487) — the list is keyed on the drives, not on what is deployed** | | `/backups/restore` | Visszaállítás | restore panel, offsite restore list (**one „Visszaállítás…" entry per app** since v0.154.0), existing verification copies, .fab download/import loop | | `/backups/restore/app?name=` | Visszaállítás — | **R-48 per-app offsite restore wizard** (v0.154.0). GET-only; three described intent cards (ellenőrzés / hiányzó fájlok / teljes visszaállítás), a visible phase strip, and a server-derived step. Adds NO mutation endpoint — every card posts to the pre-existing `/backup/offbox/{restore,place,reconstitute}` | @@ -3786,7 +3786,7 @@ All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent | GET | `/api/stacks/{name}/logs` | Container logs (`?raw=1` for plain text) | | GET | `/api/stacks/{name}/hdd-data` | HDD data paths + sizes — resolved from the app's OWN `app.yaml` `HDD_PATH` (v0.236.0, R-442), never the global config | | GET | `/api/stacks/{name}/backup-data` | Backup data paths + sizes (DB dumps, cross-drive rsync) | -| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed"). `remove_hdd_data: true` deletes the app's folders under its recorded `HDD_PATH` and lists them; **409 + a Hungarian sentence when the data was asked for but its location cannot be resolved or the drive is absent — nothing is touched, the app is kept** (v0.236.0, R-442). `hdd_paths_removed` is `[]` for an SSD app (never `null`); `hdd_paths_missing`, `hdd_note`, `backup_paths_refused` state what was not found / not removed. `remove_backups: true` (v0.240.0, R-474) deletes the app's whole recovery unit, its Tier-2 mirror(s) on any registered drive and its backup preferences (`backup_paths_removed` lists them); **without it the backups AND the Tier-2 record are kept, so the removed app can still be restored from the second drive (R-486)**. Off-site snapshots are never touched by removal | +| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed"). `remove_hdd_data: true` deletes the app's folders under its recorded `HDD_PATH` and lists them; **409 + a Hungarian sentence when the data was asked for but its location cannot be resolved or the drive is absent — nothing is touched, the app is kept** (v0.236.0, R-442). `hdd_paths_removed` is `[]` for an SSD app (never `null`); `hdd_paths_missing`, `hdd_note`, `backup_paths_refused` state what was not found / not removed. `remove_backups: true` (v0.240.0, R-474) deletes the app's whole recovery unit, its Tier-2 mirror(s) on any registered drive and its backup preferences (`backup_paths_removed` lists them); **without it the backups AND the Tier-2 record are kept, so the removed app can still be restored from the second drive (R-486)**. Off-site snapshots are never touched by removal. Since v0.242.0 a removal also clears the app's update hold (R-491) and `volumes_removed` lists the named volumes actually removed, `[]` when none (R-489) | | DELETE | `/api/stacks/{name}` | Delete orphaned stack — same R-442 resolution and refusal shape as `/remove` | | POST | `/api/sync` | Trigger catalog sync | | GET | `/api/system/info` | System info + sync status | @@ -3797,7 +3797,7 @@ All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent |--------|----------|-------------| | GET | `/api/backup/status` | Full backup status | | POST | `/api/backup/run` | Trigger manual backup | -| GET | `/api/backup/snapshots` | List snapshots (`?stack={name}` for filtering) | +| GET | `/api/backup/snapshots` | List snapshots (`?stack={name}` for filtering). Since v0.242.0 it also answers for a REMOVED app whose unit is on a connected drive (R-487) — 404 only when no unit exists anywhere | | POST | `/api/stacks/{name}/cross-backup` | Save cross-drive config | | POST | `/api/stacks/{name}/cross-backup/run` | Trigger cross-drive backup | | GET | `/api/stacks/{name}/cross-backup/status` | Cross-drive status | diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 5afa0c9..b67301d 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1747,6 +1747,10 @@ func main() { mux.Handle("/api/sharing/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSharingAPI)))) // Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize. mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI)))) + // R-490 (v0.242.0): the web layer's /api/system/ prefix knows only the memory routes and answered + // 404 to /api/system/info, so the monitoring page's memory-distribution card never rendered. An + // exact pattern wins over the prefix in ServeMux. Pinned by TestR490_SystemInfoIsMountedAheadOfTheWebPrefix. + mux.Handle("/api/system/info", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP)))) // Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance, // a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive. mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot)))) diff --git a/controller/cmd/controller/r475_wiring_test.go b/controller/cmd/controller/r475_wiring_test.go index 5c00ae0..a8ba103 100644 --- a/controller/cmd/controller/r475_wiring_test.go +++ b/controller/cmd/controller/r475_wiring_test.go @@ -73,3 +73,52 @@ func TestR475_AdapterReadsEveryTier(t *testing.T) { t.Errorf("the hold must carry UpdateCopyHolds through HoldAfterFailedUpdateHolding (R-479); selectors:%s", h) } } + +// R-490 — /api/system/info reaches the API router; the web layer's /api/system/ prefix used to eat it. +// +// COMPANION RED-PROOF (REPORT.md): delete the exact-path mount — this fails. +func TestR490_SystemInfoIsMountedAheadOfTheWebPrefix(t *testing.T) { + src, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var exact, prefix bool + ast.Inspect(src, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) < 2 { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Handle" { + return true + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok { + return true + } + switch lit.Value { + case `"/api/system/info"`: + exact = strings.Contains(fmtNode(call.Args[1]), "apiRouter") + case `"/api/system/"`: + prefix = true + } + return true + }) + if !prefix { + t.Fatal("the /api/system/ prefix mount is gone — this test's premise moved") + } + if !exact { + t.Error("/api/system/info must be mounted on the API router ahead of the web layer's /api/system/ prefix (R-490)") + } +} + +func fmtNode(n ast.Node) string { + var names []string + ast.Inspect(n, func(x ast.Node) bool { + if id, ok := x.(*ast.Ident); ok { + names = append(names, id.Name) + } + return true + }) + return strings.Join(names, " ") +} diff --git a/controller/internal/api/r474_remove_wiring_test.go b/controller/internal/api/r474_remove_wiring_test.go index 6586997..b2e00e3 100644 --- a/controller/internal/api/r474_remove_wiring_test.go +++ b/controller/internal/api/r474_remove_wiring_test.go @@ -1,9 +1,13 @@ package api import ( + "encoding/json" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "go/ast" "go/parser" "go/token" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -60,3 +64,58 @@ func TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo(t *testing.T) { }) } } + +// R-491 — a removal clears the app's UPDATE hold (and only that kind). +// +// COMPANION RED-PROOF (REPORT.md): remove the ClearUpdateHold call from removeStack — this fails. +func TestR491_RemovalClearsTheUpdateHold(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "router.go", nil, 0) + if err != nil { + t.Fatal(err) + } + found := false + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Name.Name != "removeStack" || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "ClearUpdateHold" { + found = true + } + return true + }) + } + if !found { + t.Error("removeStack must clear the app's update hold (R-491), or a reinstall starts held") + } +} + +// R-490 — with a default storage path registered, system-info reports the drive (the fallback every +// other reader already had). +func TestR490_SystemInfoFallsBackToTheDefaultStoragePath(t *testing.T) { + r, sett, _, _ := newSlice4Router(t) + r.sett = sett // the fixture router carries no settings; system-info reads the default path from them + if err := sett.AddStoragePath(settings.StoragePath{Path: t.TempDir(), Label: "HDD", Schedulable: true, IsDefault: true}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Data struct { + System struct { + HDDConfigured bool `json:"hdd_configured"` + } `json:"system"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.Data.System.HDDConfigured { + t.Errorf("hdd_configured must be true with a default storage path registered; body %s", w.Body.String()) + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 0206776..bd7999d 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -905,6 +905,17 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri r.logger.Printf("[WARN] [api] Failed to forget the backup preferences of %s: %v", name, err) } } + // R-491 (v0.242.0): an app held after a failed update that the customer then REMOVES is not held + // any more — the hold named a copy for a deploy that no longer exists, and every start gate reads + // the store, so a reinstall under the same name would begin refused. Measured on demo-hp + // 2026-09-13 (nextcloud). An R-379 restore hold is deliberately NOT cleared here. + if r.sett != nil { + if cleared, err := r.sett.ClearUpdateHold(name); err != nil { + r.logger.Printf("[WARN] [api] Failed to clear the update hold of %s: %v", name, err) + } else if cleared { + r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name) + } + } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"}) @@ -978,7 +989,15 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) { } func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) { - info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector) + // R-490 / R-465: the same default-storage-path fallback every other reader of the always-empty + // cfg.Paths.HDDPath has; without it hdd_configured was false on every box. + hddPath := r.cfg.Paths.HDDPath + if r.sett != nil { + if p := r.sett.GetDefaultStoragePath(); p != "" { + hddPath = p + } + } + info := system.GetInfo(hddPath, r.cpuCollector) // F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide // "used" is not observable from the container. Report the controller's accurate committed-app memory // (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI. @@ -993,7 +1012,10 @@ func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) { info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100 } } - syncStatus := r.syncer.Status() + var syncStatus interface{} + if r.syncer != nil { // nil in tests; production always wires the syncer + syncStatus = r.syncer.Status() + } data := map[string]interface{}{ "system": info, "sync_status": syncStatus, diff --git a/controller/internal/backup/r476_unit_data_date_test.go b/controller/internal/backup/r476_unit_data_date_test.go new file mode 100644 index 0000000..17c9a8b --- /dev/null +++ b/controller/internal/backup/r476_unit_data_date_test.go @@ -0,0 +1,37 @@ +package backup + +import ( + "os" + "testing" +) + +// R-476 — a refreshed Tier-2 copy is dated by its DATA (the newest dump), not by a manifest that +// moves only when the app's definition changes; a PRESERVED package keeps the manifest date (R-403). +func TestR476_UnitRestoreDateNamesTheDataTimeWhenTheLegWasRefreshed(t *testing.T) { + cov := Tier2Coverage{UnitPackageDate: "2026-09-12T02:15:29Z", UnitDataDate: "2026-09-13T00:30:00Z", CopyLastSuccess: "2026-09-13T01:30:00Z"} + if d, preserved := cov.UnitRestoreDate(); d != "2026-09-13T00:30:00Z" || preserved { + t.Errorf("refreshed leg: got %q preserved=%v, want the dump's time", d, preserved) + } + cov.UnitLegPreserved = true + if d, preserved := cov.UnitRestoreDate(); d != "2026-09-12T02:15:29Z" || !preserved { + t.Errorf("preserved leg: got %q preserved=%v, want the manifest's date", d, preserved) + } + older := Tier2Coverage{UnitPackageDate: "2026-09-13T02:00:00Z", UnitDataDate: "2026-09-12T00:30:00Z"} + if d, _ := older.UnitRestoreDate(); d != "2026-09-13T02:00:00Z" { + t.Errorf("data older than the manifest must not move the date backwards: %q", d) + } +} + +// R-476 — the coverage reader fills the data date from the mirrored unit's own artifacts. +func TestR476_CoverageReadsTheUnitDataDate(t *testing.T) { + dest := t.TempDir() + u := tier2UnitDir(dest) + if err := os.MkdirAll(u, 0o755); err != nil { + t.Fatal(err) + } + writeUnitManifest(t, u, []string{"app.sql"}, nil) + cov := tier2CoverageAt(dest) + if cov.UnitDataDate == "" { + t.Fatal("UnitDataDate must be read from the unit") + } +} diff --git a/controller/internal/backup/r487_removed_units_test.go b/controller/internal/backup/r487_removed_units_test.go new file mode 100644 index 0000000..f9f55cf --- /dev/null +++ b/controller/internal/backup/r487_removed_units_test.go @@ -0,0 +1,135 @@ +package backup + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// r487Provider knows exactly which stacks are deployed: GetStackComposePath answers ok for those and +// only those, which is the question ListRestorePoints and the restore ask. +type r487Provider struct{ floorProvider } + +func (p *r487Provider) GetStackComposePath(name string) (string, bool) { + for _, s := range p.stacks { + if s == name { + return filepath.Join(p.dir, "stacks", name, "docker-compose.yml"), true + } + } + return "", false +} + +func r487Manager(t *testing.T, sysPath string, deployed ...string) (*Manager, *settings.Settings) { + t.Helper() + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0)) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.SystemDataPath = sysPath + m := NewManager(cfg, sett, log.New(os.Stderr, "", 0)) + m.SetStackProvider(&r487Provider{floorProvider{stacks: deployed, dir: t.TempDir()}}) + return m, sett +} + +// writeR487Unit lays down a readable unit (manifest + one dump) for stack under nsRoot. +func writeR487Unit(t *testing.T, nsRoot, stack, display string, dumpAt time.Time) string { + t.Helper() + u := mkUnit(t, nsRoot, stack) + man := RecoveryManifest{SchemaVersion: 2, AppName: stack, DisplayName: display, CreatedAt: "2026-09-12T02:15:29Z"} + b, _ := json.Marshal(man) + if err := os.WriteFile(UnitManifestFile(u), b, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(UnitDBDumpDir(u), 0o755); err != nil { + t.Fatal(err) + } + dump := filepath.Join(UnitDBDumpDir(u), stack+".sql") + if err := os.WriteFile(dump, []byte("-- dump"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(dump, dumpAt, dumpAt); err != nil { + t.Fatal(err) + } + // the manifest is the OLDER artifact, as on a real box: it moves only when the definition changes + older := dumpAt.Add(-24 * time.Hour) + if err := os.Chtimes(UnitManifestFile(u), older, older); err != nil { + t.Fatal(err) + } + return u +} + +// R-487 — a removed app's kept unit is listed; a deployed app's is not; a unit on a registered DATA +// drive is found where it sits and carries that drive's label. +func TestR487_ListRemovedAppUnits_ListsKeptUnitsOfUndeployedApps(t *testing.T) { + sys := t.TempDir() + m, sett := r487Manager(t, sys, "kept-app") + sysRoot := m.namespaceRoot(sys) + writeR487Unit(t, sysRoot, "kept-app", "Kept", time.Now()) + writeR487Unit(t, sysRoot, "gone-app", "Gone", time.Now()) + drive := t.TempDir() + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "Külső HDD", Schedulable: true}); err != nil { + t.Fatal(err) + } + writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "On the drive", time.Now()) + // a bare directory without a manifest is NOT an offer + if err := os.MkdirAll(RecoveryUnitPath(sysRoot, "hollow"), 0o755); err != nil { + t.Fatal(err) + } + + got := m.ListRemovedAppUnits() + if len(got) != 2 || got[0].StackName != "drive-app" || got[1].StackName != "gone-app" { + t.Fatalf("want [drive-app gone-app], got %+v", got) + } + if got[0].DriveLabel != "Külső HDD" || got[1].DriveLabel != systemDriveLabel { + t.Errorf("drive labels: %q / %q", got[0].DriveLabel, got[1].DriveLabel) + } + if got[1].DisplayName != "Gone" || got[0].UnitDir != RecoveryUnitPath(m.namespaceRoot(drive), "drive-app") { + t.Errorf("display/unit dir: %+v", got) + } + for _, u := range got { + if u.StackName == "kept-app" || u.StackName == "hollow" { + t.Errorf("%s must not be listed", u.StackName) + } + } +} + +// R-487 — the restore picker's snapshot list answers for a removed app instead of 404. +func TestR487_ListRestorePointsFindsARemovedAppsUnit(t *testing.T) { + sys := t.TempDir() + m, _ := r487Manager(t, sys) + at := time.Date(2026, 9, 13, 0, 30, 0, 0, time.UTC) + writeR487Unit(t, m.namespaceRoot(sys), "gone-app", "Gone", at) + + pts, found := m.ListRestorePoints("gone-app") + if !found || len(pts) != 1 { + t.Fatalf("want found with one point, got found=%v pts=%+v", found, pts) + } + if pts[0].ShortID != restorePointShortID || pts[0].Tier != 1 || pts[0].Time != at.Format(time.RFC3339) { + t.Errorf("point %+v", pts[0]) + } + if _, found := m.ListRestorePoints("never-existed"); found { + t.Error("an app with no unit anywhere must still be not-found") + } +} + +// R-487 — a removed app's unit kept on a DATA drive is the one the restore opens, not the system +// path the drive fallback would name. +func TestR487_PrimaryUnitDirForNamesTheRemovedUnitWhereItSits(t *testing.T) { + sys := t.TempDir() + m, sett := r487Manager(t, sys) + drive := t.TempDir() + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil { + t.Fatal(err) + } + want := writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "D", time.Now()) + if got := m.primaryUnitDirFor("drive-app"); got != want { + t.Errorf("unit dir: got %s want %s", got, want) + } +} diff --git a/controller/internal/backup/removed_units.go b/controller/internal/backup/removed_units.go new file mode 100644 index 0000000..c99e983 --- /dev/null +++ b/controller/internal/backup/removed_units.go @@ -0,0 +1,143 @@ +package backup + +import ( + "os" + "path/filepath" + "sort" + "time" +) + +// RemovedAppUnit is a recovery unit that sits on a registered drive while its app is NOT deployed — +// the state „Töröld az adataimat is" leaves behind when the customer keeps the backups (R-487). +// +// It exists because the unit was restorable through POST /backup/restore the whole time and listed +// on NEITHER backup page, so the customer's remove-by-mistake route existed only as an endpoint. +// The off-site list had exactly this defect and was fixed by keying it on the STORE (R-237); the +// local list is now keyed on the drives the same way — what is on disk decides, not what is deployed. +type RemovedAppUnit struct { + StackName string + DisplayName string // from the unit's own manifest; the stack name when the manifest has none + UnitDir string // the recovery-unit directory, backups/primary/ on the drive it sits on + DriveLabel string // registered storage label; the system-drive label for the SSD fallback + Time string // RFC3339 UTC — newest artifact in the unit (same rule as ListRestorePoints) +} + +// primaryUnitRoots names every felhom-data namespace root a recovery unit can sit under: the system +// data path and every registered storage path that is still connected. Deduplicated; a disconnected +// drive is skipped — a unit nobody can open is not an offer (R-102's rule, one tier down). +func (m *Manager) primaryUnitRoots() []string { + seen := make(map[string]bool) + var roots []string + add := func(drive string) { + if drive == "" || !filepath.IsAbs(drive) { + return + } + root := m.namespaceRoot(drive) + if root == "" || seen[root] { + return + } + seen[root] = true + roots = append(roots, root) + } + add(m.systemDataPath) + if m.settings != nil { + for _, sp := range m.settings.GetStoragePaths() { + if sp.Disconnected { + continue + } + add(sp.Path) + } + } + return roots +} + +// driveLabelForRoot maps a namespace root back to the label the page shows for it. +func (m *Manager) driveLabelForRoot(root string) string { + if m.systemDataPath != "" && root == m.namespaceRoot(m.systemDataPath) { + return systemDriveLabel + } + if m.settings != nil { + for _, sp := range m.settings.GetStoragePaths() { + if m.namespaceRoot(sp.Path) == root { + return m.settings.GetStorageLabel(sp.Path) + } + } + } + return "" +} + +// unitNewestArtifact is the unit's data time: the newest of its manifest, .sql dumps and .tar +// volume dumps. ONE rule, shared with ListRestorePoints, so the two lists cannot date a unit +// differently. +func unitNewestArtifact(unitDir string) (time.Time, bool) { + fi, err := os.Stat(UnitManifestFile(unitDir)) + if err != nil { + return time.Time{}, false + } + newest := fi.ModTime() + newest = newestArtifact(UnitDBDumpDir(unitDir), ".sql", newest) + newest = newestArtifact(UnitVolumeDumpDir(unitDir), ".tar", newest) + return newest, true +} + +// ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the +// units whose app is not deployed, sorted by stack name. A unit without a readable manifest is not +// listed — the restore would fall back to the volume-only path, which is not the offer this row makes. +// A nil provider lists nothing: with no provider "not deployed" cannot be told from "unknown", and an +// offer to overwrite must fail closed (the isStackDeployed rule). +func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit { + if m.stackProvider == nil { + return nil + } + deployed := make(map[string]bool) + for _, name := range m.knownStackNames() { + deployed[name] = true + } + seen := make(map[string]bool) + var out []RemovedAppUnit + for _, root := range m.primaryUnitRoots() { + entries, err := os.ReadDir(PrimaryBackupPath(root)) + if err != nil { + continue + } + for _, e := range entries { + name := e.Name() + if !e.IsDir() || deployed[name] || seen[name] { + continue + } + unitDir := RecoveryUnitPath(root, name) + man := readManifest(UnitManifestFile(unitDir)) + if man == nil { + continue + } + newest, ok := unitNewestArtifact(unitDir) + if !ok { + continue + } + display := man.DisplayName + if display == "" { + display = name + } + seen[name] = true + out = append(out, RemovedAppUnit{ + StackName: name, + DisplayName: display, + UnitDir: unitDir, + DriveLabel: m.driveLabelForRoot(root), + Time: newest.UTC().Format(time.RFC3339), + }) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].StackName < out[j].StackName }) + return out +} + +// RemovedAppUnitFor returns the removed app's unit, if one exists on a connected drive. +func (m *Manager) RemovedAppUnitFor(stackName string) (RemovedAppUnit, bool) { + for _, u := range m.ListRemovedAppUnits() { + if u.StackName == stackName { + return u, true + } + } + return RemovedAppUnit{}, false +} diff --git a/controller/internal/backup/restore_points.go b/controller/internal/backup/restore_points.go index b8b9338..d2bf572 100644 --- a/controller/internal/backup/restore_points.go +++ b/controller/internal/backup/restore_points.go @@ -39,6 +39,13 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo return nil, false } if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok { + // R-487: a removed app whose backups were kept is not deployed, but its unit is on a drive + // and POST /backup/restore reinstalls from it. The picker used to be told 404 here while the + // restore itself worked — the list is keyed on the drive now, the way R-237 keyed the + // off-site list on the store. + if u, found := m.RemovedAppUnitFor(stackName); found { + return []RestorePoint{{Time: u.Time, ShortID: restorePointShortID, Tier: 1, DriveLabel: u.DriveLabel}}, true + } return nil, false } diff --git a/controller/internal/backup/restore_unit.go b/controller/internal/backup/restore_unit.go index bf1f8b3..c7879da 100644 --- a/controller/internal/backup/restore_unit.go +++ b/controller/internal/backup/restore_unit.go @@ -184,7 +184,22 @@ type UnitRestoreResult struct { // An unresolvable drive path is still refused inside …At, in the same place and with the same // message, so the order of the checks a caller can observe is unchanged. func (m *Manager) RestoreFromRecoveryUnit(stackName string) (UnitRestoreResult, error) { - return m.RestoreFromRecoveryUnitAt(stackName, RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName)) + return m.RestoreFromRecoveryUnitAt(stackName, m.primaryUnitDirFor(stackName)) +} + +// primaryUnitDirFor names the PRIMARY unit a keep-side restore opens. For a deployed app that is +// backups/primary/ on its own drive. For a REMOVED app (R-487) the drive is no longer known +// — GetAppDrivePath falls back to the system path — so a unit kept on a data drive was unreachable +// and the restore silently took the volume-only fallback. It is now found where it sits. +func (m *Manager) primaryUnitDirFor(stackName string) string { + if m.stackProvider != nil { + if _, deployed := m.stackProvider.GetStackComposePath(stackName); !deployed { + if u, found := m.RemovedAppUnitFor(stackName); found { + return u.UnitDir + } + } + } + return RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName) } // RestoreFromRecoveryUnitAt is RestoreFromRecoveryUnit with an EXPLICIT recovery-unit directory. diff --git a/controller/internal/backup/tier2_restore.go b/controller/internal/backup/tier2_restore.go index 708d315..23a497c 100644 --- a/controller/internal/backup/tier2_restore.go +++ b/controller/internal/backup/tier2_restore.go @@ -94,6 +94,12 @@ type Tier2Coverage struct { // it is a fact about the artifact the restore will actually open. "" means UNKNOWN. UnitPackageDate string UnitLegPreserved bool + // UnitDataDate (R-476) — the newest ARTIFACT in the mirrored unit: its dumps' mtime, or the + // manifest's when nothing is newer. The manifest moves only when the app's DEFINITION changes + // (checksum-skip), while the nightly dumps keep their names and their fresh bytes — so on + // demo-hp a copy holding a dump written at 00:30Z was dated by a manifest from the day before. + // RFC3339 UTC; "" when the unit is not readable. + UnitDataDate string } // CanRestore reports whether the FILE restore has any subtree to read at all. @@ -142,6 +148,9 @@ func tier2CoverageAt(destBase string) Tier2Coverage { // R-403: ask the package itself when it was made. Reading the artifact rather than the status // record is what makes this date impossible to overstate. c.UnitPackageDate = unitPackageDate(unitDir) + if newest, ok := unitNewestArtifact(unitDir); ok { + c.UnitDataDate = newest.UTC().Format(time.RFC3339) + } return c } @@ -299,11 +308,19 @@ func (c Tier2Coverage) Tier2CopyDate() (date string, proven bool) { // `12:03:49Z` against a run at `12:14:24Z` — perfectly healthy, and all four would have been told // their package was stale. A warning that fires on everything is a warning nobody reads, which costs // the same as the comforting lie it was meant to replace. +// +// R-476: when the leg was NOT preserved, the package's date is its DATA time — the newest dump in +// the copy — never the manifest's, which moves only when the definition changes and so undersold a +// fresh copy by a day. A PRESERVED package keeps the manifest date: nothing in it is newer, and the +// R-403 rule that a preserved package is never shown as fresh is what this sits under. func (c Tier2Coverage) UnitRestoreDate() (date string, preserved bool) { if c.UnitPackageDate == "" { copyDate, _ := c.Tier2CopyDate() return copyDate, c.UnitLegPreserved } + if !c.UnitLegPreserved && c.UnitDataDate != "" && c.UnitDataDate > c.UnitPackageDate { + return c.UnitDataDate, false + } return c.UnitPackageDate, c.UnitLegPreserved } diff --git a/controller/internal/bootrecon/r456_partly_dead_test.go b/controller/internal/bootrecon/r456_partly_dead_test.go new file mode 100644 index 0000000..c582e41 --- /dev/null +++ b/controller/internal/bootrecon/r456_partly_dead_test.go @@ -0,0 +1,33 @@ +package bootrecon + +import ( + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-456 — written down in 02-controller-module-map.md and pinned here: a stack with one member +// REMOVED (absent, not dead) and the rest running reads as running, so the boot sweep does not repair +// it; only a present-but-dead supervised member makes it degraded. Measured 2026-09-02 on demo-hp +// (`docker rm -f bookstack` with bookstack-db running: not selected; both gone: repaired in 6.3 s). +func TestR456_AbsentMemberIsNotABootOrphan(t *testing.T) { + present := []stacks.ContainerInfo{{Name: "bookstack-db", State: "running"}} + state := stacks.AggregateStateForTest(present) + if state != stacks.StateRunning { + t.Fatalf("one running member, one absent: state %q, want running (an absent member is not a dead one)", state) + } + s := stacks.Stack{Name: "bookstack", Deployed: true, State: state, Containers: present, + AppConfig: &stacks.AppConfig{Deployed: true, DesiredState: stacks.DesiredStateRunning}} + if isBootOrphan(s) { + t.Error("a partly-dead stack must NOT be a boot orphan — repairing half a stack beside its live database is not obviously safe (R-456)") + } + // Control: the same member present but DEAD is degraded, and degraded IS an orphan. + dead := []stacks.ContainerInfo{{Name: "bookstack-db", State: "running"}, {Name: "bookstack", State: "exited"}} + if st := stacks.AggregateStateForTest(dead); st != stacks.StateDegraded { + t.Fatalf("control: a present dead member must read degraded, got %q", st) + } + s.State, s.Containers = stacks.StateDegraded, dead + if !isBootOrphan(s) { + t.Error("control: a degraded stack IS a boot orphan") + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index de68654..fec6ecb 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -1675,6 +1675,23 @@ func (s *Settings) ClearRestoreHold(stack string) (bool, error) { return true, s.save() } +// ClearUpdateHold (R-491, v0.242.0) removes an app's hold ONLY when it is an update hold. An R-379 +// restore hold stays: that one means a database was left in an unknown state and is cleared by the +// operator (`-clear-restore-hold`), never by a removal. Returns whether an update hold was cleared. +func (s *Settings) ClearUpdateHold(stack string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.RestoreHolds[stack] + if !ok || h.Reason != HoldReasonUpdateFailed { + return false, nil + } + delete(s.RestoreHolds, stack) + if s.log != nil { + s.log.Printf("[INFO] [settings] update hold CLEARED for %s (the app was removed)", stack) + } + return true, s.save() +} + // SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped. func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error { s.mu.Lock() diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 19d0da5..ebb5db5 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -478,6 +478,10 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo // Step 2: Run docker compose down --volumes (keep images for potential redeploy) env := m.stackEnv(stackDir) + // R-489 (v0.242.0): the volumes are listed BEFORE and AFTER; the difference is what was removed. + // Parsing compose's progress output reported `null` over volumes it did remove — measured five + // times on demo-hp 2026-09-13 — because compose prints that progress to a TTY it does not have here. + volsBefore := m.projectVolumes(name) output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes") if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: compose down output: %s", name, truncateStr(output, 500)) @@ -487,12 +491,10 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err) } - // Step 3: Identify removed volumes from compose output - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") { - resp.VolumesRemoved = append(resp.VolumesRemoved, line) - } + // Step 3: the volumes that are gone now — `[]` when none, never null (R-489). + resp.VolumesRemoved = removedVolumes(volsBefore, m.projectVolumes(name)) + if len(resp.VolumesRemoved) > 0 { + m.logger.Printf("[INFO] [stacks] RemoveStack %s: removed volume(s) %v", name, resp.VolumesRemoved) } // Step 4: Handle HDD data @@ -848,3 +850,34 @@ func getDirSizeBytes(path string) int64 { } return 0 } + +// projectVolumes lists the named volumes Docker holds for a compose project (by its project label). +// A listing failure reads as no volumes, so a removal never fails on bookkeeping. +func (m *Manager) projectVolumes(project string) []string { + out, err := m.execCommand("docker", "volume", "ls", "--filter", "label=com.docker.compose.project="+project, "--format", "{{.Name}}") + if err != nil { + return nil + } + var vols []string + for _, l := range strings.Split(out, "\n") { + if l = strings.TrimSpace(l); l != "" { + vols = append(vols, l) + } + } + return vols +} + +// removedVolumes is before minus after, as a non-nil slice (the JSON must read `[]`, not `null`). +func removedVolumes(before, after []string) []string { + still := map[string]bool{} + for _, v := range after { + still[v] = true + } + removed := []string{} + for _, v := range before { + if !still[v] { + removed = append(removed, v) + } + } + return removed +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 4f5ef32..34b2187 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -1610,3 +1610,9 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool { } return slugs } + +// AggregateStateForTest exposes aggregateState with every member supervised, to tests outside this +// package — R-456's pin lives in bootrecon. +func AggregateStateForTest(containers []ContainerInfo) ContainerState { + return aggregateState(containers, func(string) string { return "unless-stopped" }) +} diff --git a/controller/internal/stacks/r489_volumes_removed_test.go b/controller/internal/stacks/r489_volumes_removed_test.go new file mode 100644 index 0000000..b98e3e6 --- /dev/null +++ b/controller/internal/stacks/r489_volumes_removed_test.go @@ -0,0 +1,38 @@ +package stacks + +import ( + "encoding/json" + "strings" + "testing" +) + +// R-489 — the removal reports the volumes it removed, and `[]` when none — never `null`. +// +// COMPANION RED-PROOF (REPORT.md): make removedVolumes return nil for an empty result — the JSON +// reads null and the second assertion fails. +func TestR489_RemovedVolumesIsADifferenceAndNeverNull(t *testing.T) { + got := removedVolumes([]string{"app_data", "app_db", "app_cache"}, []string{"app_cache"}) + if strings.Join(got, ",") != "app_data,app_db" { + t.Errorf("before minus after = %v", got) + } + b, _ := json.Marshal(RemoveResponse{VolumesRemoved: removedVolumes(nil, nil)}) + if !strings.Contains(string(b), `"volumes_removed":[]`) { + t.Errorf("no volumes must read as [] — got %s", b) + } +} + +func TestR489_ProjectVolumesReadsTheDockerListing(t *testing.T) { + m := &Manager{} + var seen []string + m.execFn = func(name string, args ...string) (string, error) { + seen = append([]string{name}, args...) + return "app_data\napp_db\n\n", nil + } + got := m.projectVolumes("app") + if strings.Join(got, ",") != "app_data,app_db" { + t.Errorf("volumes = %v", got) + } + if !strings.Contains(strings.Join(seen, " "), "label=com.docker.compose.project=app") { + t.Errorf("must filter by the compose project label; ran %v", seen) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 5f0f7c6..e402b2f 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1090,6 +1090,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() + // R-487: removed apps whose unit was kept join the local restore picker. + data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits() } // R-237: the restore list is driven by the STORE, not by what is deployed and toggled. A rebuilt // box has neither, and used to be shown nothing at all while its snapshots sat in the repository. @@ -1209,6 +1211,11 @@ type AppBackupRow struct { // Drive disconnected — app's home drive is currently disconnected DriveDisconnected bool + // Removed (R-487) — the app is NOT deployed; this row stands for a recovery unit kept on a + // drive after „Töröld az adataimat is" with the backups kept. Its one action is the unit restore, + // which reinstalls (R-253). RemovedUnitTime is the unit's newest artifact, RFC3339. + Removed bool + RemovedUnitTime string // Tier2 destination drive is currently disconnected (backup paused, not failed) Tier2DestDisconnected bool // Tier2 destination drive is inactive (Schedulable=false, backup paused) @@ -1458,6 +1465,23 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup rows = append(rows, row) } + // R-487: the list is keyed on the DRIVES, not on what is deployed — a removed app whose unit was + // kept is listed after the deployed ones, with the restore that brings it back. + if s.backupMgr != nil { + for _, u := range s.backupMgr.ListRemovedAppUnits() { + rows = append(rows, AppBackupRow{ + StackName: u.StackName, + DisplayName: u.DisplayName, + Slug: u.StackName, + StorageLabel: u.DriveLabel, + Status: "yellow", + StatusText: "Eltávolított alkalmazás — a mentése megvan, visszaállítható", + Removed: true, + RemovedUnitTime: u.Time, + Tier1LastRun: u.Time, + }) + } + } return rows } diff --git a/controller/internal/web/r487_removed_row_test.go b/controller/internal/web/r487_removed_row_test.go new file mode 100644 index 0000000..be43c2c --- /dev/null +++ b/controller/internal/web/r487_removed_row_test.go @@ -0,0 +1,111 @@ +package web + +import ( + "os" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// R-487 — a removed app whose unit was kept renders on the backups page with the restore that brings +// it back; a page without such rows carries none of it (the negative control). +func TestR487_BackupsPageListsARemovedAppWithItsRestore(t *testing.T) { + data := splitTestData() + data["AppBackupRows"] = []AppBackupRow{ + {StackName: "calibre-web", DisplayName: "Calibre-Web"}, + {StackName: "gone-app", DisplayName: "Gone", Removed: true, RemovedUnitTime: "2026-09-13T00:30:00Z", StorageLabel: "HDD"}, + } + html := renderBackupPage(t, "backups_apps", data) + for _, want := range []string{ + `name="stack_name" value="gone-app"`, + `name="snapshot_id" value="helyi"`, + `action="/backup/restore"`, + `class="layer-badge">Megtartva<`, + } { + if !strings.Contains(html, want) { + t.Errorf("removed row must render %q", want) + } + } + if strings.Count(html, `class="layer-badge">Megtartva<`) != 1 { + t.Error("the kept badge must render for the removed row only") + } + control := renderBackupPage(t, "backups_apps", splitTestData()) + if strings.Contains(control, "Megtartva") || strings.Contains(control, `value="helyi"`) { + t.Error("negative control: a page with no removed app must not offer the restore") + } +} + +// R-487 — the restore picker lists removed apps in their own group. +func TestR487_RestorePickerListsRemovedApps(t *testing.T) { + data := splitTestData() + data["RemovedApps"] = []backup.RemovedAppUnit{{StackName: "gone-app", DisplayName: "Gone"}} + html := renderBackupPage(t, "backups_restore", data) + if !strings.Contains(html, ` {{end}} + {{/* R-487: removed apps whose unit was kept — the restore reinstalls them (R-253). */}} + {{if .RemovedApps}} + + {{range .RemovedApps}} + + {{end}} + + {{end}}
diff --git a/controller/scripts/retrieval_promise_gate.py b/controller/scripts/retrieval_promise_gate.py index 3653c4e..af0732e 100644 --- a/controller/scripts/retrieval_promise_gate.py +++ b/controller/scripts/retrieval_promise_gate.py @@ -56,6 +56,11 @@ ALLOWLIST = { ("backups.html", "amelyből az egész készülék visszaállítható"): "the LOCAL whole-device backup, made and held by the host agent. Nothing to do with the " "off-site escrow claim — no recovery code is involved.", + ("backups_apps.html", "Eltávolítva — visszaállítható"): + "R-487: a REMOVED app's kept recovery unit. The row renders only when the unit's manifest " + "is readable on a CONNECTED registered drive (backup.ListRemovedAppUnits), and the claim is " + "the very action beside it — POST /backup/restore from that unit. No recovery code, no " + "off-site store: what the box can see on its own disk.", ("backups_apps.html", "alkalmazásonként visszaállítható"): "per-app restore from the local app-data backup. Same: local, no recovery code.", ("backups_remote.html", "mentéseid visszaszerezhetők."):