diff --git a/CHANGELOG.md b/CHANGELOG.md index 106db1a..68fc234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ ## Changelog +### v0.149.0 — the dashboard tells the truth about the last backup (2026-07-20) + +Closes **F3** from `felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md`. + +The dashboard's backup card claimed **„Utolsó mentés: Még nem futott"** on every box, forever — +including boxes with dumps on disk and `crossdrive_completed` / `db_dump_completed` events already +recorded in the hub. It was not a backup failure; it was a lie in the view layer. + +`dashboard.html` branches the row on `{{if .BackupStatus}}` and reads `.Success` / `.LastRun` from +it, but `dashboardHandler` never put `BackupStatus` in the template data. The key was always +missing, so the `{{if}}` arm was unreachable and the `{{else}}` — "never ran" — rendered +unconditionally. The neighbouring „Adatbázisok: N mentve" row kept working because it reads +`DBDumpStatus`, which *was* passed; that is exactly the contradiction the audit caught on the live +box (a card reporting "never ran" directly above "2 mentve"). + +The fix is the one-line pass-through the template always expected: +`data["BackupStatus"] = fullStatus.LastDBDump`. `*DBDumpStatus` nil/non-nil maps exactly onto the +template's branch, so a genuinely fresh box still reads „Még nem futott" honestly and no zero-value +timestamp is ever fabricated. No template change, no new view-model, and "utolsó mentés" keeps its +existing meaning (the last DB-dump run, consistent with the backups page's DB section). + +Tests (`internal/web/dashboard_backup_card_test.go`) drive the **real handler** through +`ServeHTTP` rather than the template alone, so they bite on the handler wiring: a planted dump file +on the app's drive must surface as its own timestamp; a box with no dump must still say „Még nem +futott" and must not render `0001-01-01`; a failed run must render „Sikertelen". Red-proofed — +deleting the new assignment fails the first of those. + ### v0.148.0 — coherent snapshot pairs + an offsite restore that actually restores (2026-07-19) Closes **R-43** and **R-44**, the two findings from `DIAG-immich-restore-2026-07-19`. The short diff --git a/CONTEXT.md b/CONTEXT.md index c6f8439..41a9ad9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,20 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-20 (vacation remote-ops audit — no code change) +Last updated: 2026-07-20 (v0.149.0 — F3 backup-card fix + remote-site remediation) + +> **2026-07-20 — remote-site remediation + v0.149.0.** **F1 is MITIGATED FOR THE WINDOW, not durably +> fixed:** `vmbr0` on the demo host is now **static `192.168.0.162/24`** (was DHCP; the remote router +> had handed it `.147`, and the agent binds that literal), applied with `ifreload -a`; the agent came +> up clean and the red „a tárolókezelő ügynök nem elérhető" banner is gone. The control plane is +> still pinned to a LAN literal — the durable fix (host-internal island bridge) is a separate +> spike-first arc, **R-50**. Restoring the agent immediately let the quiesce loop run the overdue +> whole-guest backup by itself (**F2 closed**), a manual app-data run followed (2 DBs, 3 volume dumps, +> 43 s), and **Immich is back** (`photos.demo-felhom.eu` → 200; it had been left `Exited` by the +> pre-transport shutdown, not by the offsite-restore test). **v0.149.0 fixes F3** — the dashboard card +> said „Utolsó mentés: Még nem futott" on every box because `dashboardHandler` never passed the +> `BackupStatus` key the template branches on. F4/F5/F6/F7 are roadmap-only (**R-51/R-52/R-53/R-54**). +> Evidence: `felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md` §Remediation. > **2026-07-20 — demo box moved to a remote site until ~2026-08-02; `ssh felhom-pve` = tailnet > 100.70.170.35 (direct, ~37 ms). THE HOST AGENT IS DOWN THERE:** its `localapi` binds the literal diff --git a/controller/internal/web/dashboard_backup_card_test.go b/controller/internal/web/dashboard_backup_card_test.go new file mode 100644 index 0000000..903df51 --- /dev/null +++ b/controller/internal/web/dashboard_backup_card_test.go @@ -0,0 +1,174 @@ +package web + +import ( + "io" + "log" + "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/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// F3 (AUDIT-vacation-remote-ops-2026-07-20): the dashboard's "Utolsó mentés" row branches on +// .BackupStatus, a key dashboardHandler never set — so the {{if}} arm was unreachable and every box +// reported "Még nem futott" forever, even with dumps on disk and crossdrive_completed events in the +// hub. These tests drive the REAL handler through ServeHTTP so they bite on the handler wiring, not +// just on the template. + +// dashDumpProvider lists one deployed stack on a known drive, so the backup manager's per-drive dump +// scan has somewhere to look. Everything else comes from blockProvider (async_restore_test.go). +type dashDumpProvider struct { + blockProvider + stack string +} + +func (p *dashDumpProvider) ListDeployedStacks() []backup.StackSummary { + return []backup.StackSummary{{Name: p.stack}} +} + +// newDashboardServer builds a Server that renders the real dashboard through ServeHTTP, backed by a +// real backup.Manager. When dumpAt is non-zero a DB dump file is planted on the app's drive with +// that modtime — GetFullStatus then synthesizes LastDBDump from it, which is what the card must show. +func newDashboardServer(t *testing.T, dumpAt time.Time) *Server { + t.Helper() + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + + cfg := &config.Config{} + cfg.Customer.ID = "test-customer" + cfg.Customer.Name = "Teszt Ügyfél" + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Paths.SystemDataPath = filepath.Join(dir, "system") + cfg.Stacks.ComposeCommand = "docker compose" // skip detection (not needed for page renders) + cfg.Backup.Enabled = true // the card only renders when backup is enabled + + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatalf("settings: %v", err) + } + stackMgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatalf("stacks manager: %v", err) + } + + drive := filepath.Join(dir, "drive") + bm := backup.NewManager(cfg, sett, lg) + bm.SetStackProvider(&dashDumpProvider{blockProvider{hdd: drive}, "bookstack"}) + + if !dumpAt.IsZero() { + dumpDir := backup.AppDBDumpPath(backup.NamespaceRoot(drive, true), "bookstack") + if err := os.MkdirAll(dumpDir, 0o755); err != nil { + t.Fatal(err) + } + f := filepath.Join(dumpDir, "bookstack-mariadb.sql") + body := "-- MySQL dump\n-- Server version 12.3\n" + strings.Repeat("-- padding to clear the min-size validator\n", 4) + if err := os.WriteFile(f, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(f, dumpAt, dumpAt); err != nil { + t.Fatal(err) + } + } + bm.RefreshCache(time.Now()) // populates the cache the card reads (and the dump-file scan) + + s := &Server{cfg: cfg, settings: sett, stackMgr: stackMgr, backupMgr: bm, logger: lg, version: "test"} + s.loadTemplates() + return s +} + +// backupCard returns just the dashboard's backup card, so an assertion cannot accidentally match +// the same words elsewhere on the page. +func backupCard(t *testing.T, body string) string { + t.Helper() + i := strings.Index(body, `class="backup-status-card"`) + if i < 0 { + t.Fatalf("no backup card on the dashboard (BackupEnabled false?)") + } + rest := body[i:] + if j := strings.Index(rest, "\n"); j > 0 { + return rest[:j] + } + return rest +} + +// Scenario B — a real dump on disk ⇒ the card shows ITS timestamp, never "Még nem futott". +// COMPANION red-proof: delete `data["BackupStatus"] = fullStatus.LastDBDump` from dashboardHandler +// → the card falls back to "Még nem futott" and this test FAILS. Run → fail → restore (in REPORT). +func TestDashboardBackupCard_ShowsLastRun(t *testing.T) { + dumpAt := time.Date(2026, 7, 19, 3, 30, 0, 0, time.Local) + s := newDashboardServer(t, dumpAt) + + rec := getPage(t, s, "/") + if rec.Code != 200 { + t.Fatalf("GET / = %d: %s", rec.Code, rec.Body.String()) + } + card := backupCard(t, rec.Body.String()) + + want := dumpAt.Format("2006-01-02 15:04") + if !strings.Contains(card, want) { + t.Errorf("card must show the real last run %q, got:\n%s", want, card) + } + if strings.Contains(card, "Még nem futott") { + t.Errorf("a box WITH a dump must not claim it never ran, got:\n%s", card) + } +} + +// Scenario C — no dump anywhere ⇒ the fresh-box branch still renders honestly (nil passes through; +// the fix must not fabricate a zero timestamp like "0001-01-01 00:00"). +func TestDashboardBackupCard_FreshBoxStaysHonest(t *testing.T) { + s := newDashboardServer(t, time.Time{}) + + rec := getPage(t, s, "/") + if rec.Code != 200 { + t.Fatalf("GET / = %d: %s", rec.Code, rec.Body.String()) + } + card := backupCard(t, rec.Body.String()) + + if !strings.Contains(card, "Még nem futott") { + t.Errorf("a box with NO dump must say so, got:\n%s", card) + } + if strings.Contains(card, "0001-01-01") { + t.Errorf("nil dump must not render a zero-value timestamp, got:\n%s", card) + } +} + +// Scenario D — a FAILED dump renders "Sikertelen", not a timestamp and not "Még nem futott". +// The failure flag only exists in the manager's in-memory run state (unreachable from this package +// without shelling out to docker), so this one asserts the template branch directly. +func TestDashboardBackupCard_FailedRunShowsSikertelen(t *testing.T) { + data := map[string]interface{}{ + "Page": "dashboard", "Title": "Vezérlőpult", + "MissingStorage": map[string]string{}, + "NetworkWarnings": map[string]string{}, + "NetworkStubs": map[string]string{}, + "Subdomains": map[string]string{}, + "RunningCount": 0, "StoppedCount": 0, "TotalCount": 0, + "SystemInfo": system.SystemInfo{}, + "Domain": "demo-felhom.eu", + "BackupEnabled": true, + "BackupStatus": &backup.DBDumpStatus{ + LastRun: time.Date(2026, 7, 19, 3, 30, 0, 0, time.Local), + Success: false, + }, + } + card := backupCard(t, renderBackupPage(t, "dashboard", data)) + + if !strings.Contains(card, "Sikertelen") { + t.Errorf("a failed dump must render Sikertelen, got:\n%s", card) + } + if strings.Contains(card, "2026-07-19 03:30") { + t.Errorf("a failed dump must NOT render as a successful timestamp, got:\n%s", card) + } + if strings.Contains(card, "Még nem futott") { + t.Errorf("a failed dump is not 'never ran', got:\n%s", card) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ad4bb78..659f059 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -174,6 +174,11 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) fullStatus := s.backupMgr.GetFullStatus(nextDBDump) data["DBDumpStatus"] = fullStatus.LastDBDump + // F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on + // .BackupStatus, which was never passed — so the {{if}} arm was unreachable and EVERY box + // rendered "Még nem futott" regardless of history. *DBDumpStatus nil/non-nil maps exactly + // onto the template's branch, so a fresh box still reads "Még nem futott" honestly. + data["BackupStatus"] = fullStatus.LastDBDump data["BackupRunning"] = fullStatus.Running data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours }