diff --git a/controller/internal/api/backup_snapshots_test.go b/controller/internal/api/backup_snapshots_test.go new file mode 100644 index 0000000..5f1ae93 --- /dev/null +++ b/controller/internal/api/backup_snapshots_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// snapshotsStubProvider implements backup.StackDataProvider for the F1 endpoint tests: one known +// stack ("app") living on `hdd`. Everything else is inert. +type snapshotsStubProvider struct{ hdd string } + +func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool) { + if name == "app" { + return filepath.Join(p.hdd, "compose", "docker-compose.yml"), true + } + return "", false +} +func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil } +func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil } +func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd } +func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil } +func (p *snapshotsStubProvider) StopStack(string) error { return nil } +func (p *snapshotsStubProvider) StartStack(string) error { return nil } +func (p *snapshotsStubProvider) RefreshAndIsRunning(string) bool { return true } +func (p *snapshotsStubProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { + return backup.RecoveryInfo{}, false +} +func (p *snapshotsStubProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } +func (p *snapshotsStubProvider) RecreateStackFromUnit(string, string, map[string]string) error { + return nil +} + +// newSnapshotsRouter wires a Router with a real backup.Manager over a tempdir drive. +func newSnapshotsRouter(t *testing.T) (*Router, string) { + t.Helper() + drive := filepath.Join(t.TempDir(), "drive") + cfg := &config.Config{} + cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys") + mgr := backup.NewManager(cfg, nil, log.New(io.Discard, "", 0)) + mgr.SetStackProvider(&snapshotsStubProvider{hdd: drive}) + return &Router{cfg: cfg, backupMgr: mgr, logger: log.New(io.Discard, "", 0)}, drive +} + +// getSnapshots dispatches through Router.ServeHTTP so the tests also prove the ROUTE is +// registered — the F1 bug was precisely a fetch to a route that did not exist. +func getSnapshots(t *testing.T, r *Router, rawQuery string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/backup/snapshots?"+rawQuery, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +type snapshotsResp struct { + OK bool `json:"ok"` + Error string `json:"error"` + Data []struct { + Time string `json:"time"` + ShortID string `json:"short_id"` + Tier int `json:"tier"` + DriveLabel string `json:"drive_label"` + } `json:"data"` +} + +func decodeSnapshots(t *testing.T, rec *httptest.ResponseRecorder) snapshotsResp { + t.Helper() + var v snapshotsResp + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return v +} + +// Scenario A (data half): a recovery unit on disk → 200 with EXACTLY ONE tier-1 "helyi" entry the +// restore panel JS can render and POST back. +func TestBackupSnapshots_UnitOnDisk(t *testing.T) { + r, drive := newSnapshotsRouter(t) + manifest := backup.RecoveryUnitManifestPath(drive, "app") + if err := os.MkdirAll(filepath.Dir(manifest), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + rec := getSnapshots(t, r, "stack=app") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + v := decodeSnapshots(t, rec) + if !v.OK || len(v.Data) != 1 { + t.Fatalf("want ok with exactly 1 entry, got %+v", v) + } + if v.Data[0].Tier != 1 || v.Data[0].ShortID != "helyi" || v.Data[0].Time == "" { + t.Errorf("entry = %+v (want tier 1, short_id helyi, non-empty time)", v.Data[0]) + } +} + +// Scenario B: known stack, no recovery unit yet → ok:true with an EMPTY data list (the JS shows +// "Nincs elérhető mentés" and keeps the button disabled — reached honestly, not via a 404). +func TestBackupSnapshots_NoBackupYet(t *testing.T) { + r, _ := newSnapshotsRouter(t) + rec := getSnapshots(t, r, "stack=app") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + v := decodeSnapshots(t, rec) + if !v.OK || len(v.Data) != 0 { + t.Errorf("want ok with empty list, got %+v", v) + } +} + +// Scenario C: guards — traversal and empty names are 400 BEFORE any filesystem work; an unknown +// stack is 404; a Router without a backup manager is 400. +func TestBackupSnapshots_Guards(t *testing.T) { + r, _ := newSnapshotsRouter(t) + + for _, q := range []string{"stack=../../etc", "stack=", "stack=a/b", "stack=.."} { + rec := getSnapshots(t, r, q) + if rec.Code != http.StatusBadRequest { + t.Errorf("%q: status = %d, want 400; body=%s", q, rec.Code, rec.Body.String()) + } + } + + rec := getSnapshots(t, r, "stack=ghost") + if rec.Code != http.StatusNotFound { + t.Errorf("unknown stack: status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + + noMgr := &Router{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0)} + rec = getSnapshots(t, noMgr, "stack=app") + if rec.Code != http.StatusBadRequest { + t.Errorf("nil backupMgr: status = %d, want 400", rec.Code) + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index ca92c55..9120826 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -252,6 +252,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/backup/status" && req.Method == http.MethodGet: r.backupStatus(w, req) + // GET /api/backup/snapshots?stack= — restorable keep-side backups for the restore panel + case path == "/backup/snapshots" && req.Method == http.MethodGet: + r.backupSnapshots(w, req) + // POST /api/backup/run case path == "/backup/run" && req.Method == http.MethodPost: r.triggerBackup(w, req) @@ -831,6 +835,44 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) } +// validStackParam reports whether a stack name from a request is a safe single path segment +// (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because +// api ↔ web would be a circular import). Rejects traversal/escape so the name can never become +// a filesystem path outside the app's namespace root. +func validStackParam(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + if strings.ContainsAny(name, "/\\\x00") { + return false + } + return name == filepath.Clean(name) +} + +// backupSnapshots lists the restorable keep-side backups for one app — the data source of the +// /backups restore panel's snapshot dropdown (F1: this route was fetched by the template but never +// existed, so the dropdown could never populate and the restore button never enabled). +func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) { + stack := req.URL.Query().Get("stack") + if !validStackParam(stack) { + r.dbg("backupSnapshots: invalid stack param %q", stack) + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) + return + } + if r.backupMgr == nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"}) + return + } + + points, found := r.backupMgr.ListRestorePoints(stack) + if !found { + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + stack}) + return + } + r.dbg("backupSnapshots: stack=%s points=%d", stack, len(points)) + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: points}) +} + // triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has // moved to the host agent (slice 8C). func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) { diff --git a/controller/internal/backup/restore_points.go b/controller/internal/backup/restore_points.go new file mode 100644 index 0000000..a5b9735 --- /dev/null +++ b/controller/internal/backup/restore_points.go @@ -0,0 +1,90 @@ +package backup + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +// RestorePoint describes one restorable keep-side backup for the /backups restore panel +// (GET /api/backup/snapshots). The field names/shape are the payload contract of the +// backups.html restore JS (formatSnapshot): time / short_id / tier / drive_label. +// +// The keep-side restore has exactly ONE restore point per app — the current recovery unit +// (RestoreFromRecoveryUnit reads "the unit", not a history; snapshot_id is logging-only). +// Tier is always 1: Tier-2 copies are NOT restorable through POST /backup/restore (it only +// reads the app's primary unit), so listing them would silently restore tier-1 data while +// claiming tier-2 — never emit them here. +type RestorePoint struct { + Time string `json:"time"` // RFC3339 — newest artifact in the unit + ShortID string `json:"short_id"` // opaque label; POST /backup/restore uses it for logging only + Tier int `json:"tier"` // always 1 (see above) + DriveLabel string `json:"drive_label"` // registered storage label; empty for the SSD fallback +} + +// restorePointShortID is the single keep-side restore point's identifier. Hungarian ("local"), +// because the JS renders it verbatim inside the snapshot dropdown label. +const restorePointShortID = "helyi" + +// ListRestorePoints returns the app's restorable keep-side backups, and whether the stack is +// known at all (found=false → the caller should 404). A known stack with no recovery unit on +// disk returns an EMPTY list (a valid answer — "no backup yet"), not an error. +// +// The single point's Time is the newest mtime among the unit's artifacts (manifest.json, +// db-dumps/*.sql, volume-dumps/*.tar): the manifest is only rewritten when the app's config +// changes (checksum-skip), so the nightly-refreshed dumps are usually the freshest artifact. +func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, found bool) { + if m.stackProvider == nil { + return nil, false + } + if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok { + return nil, false + } + + nsRoot := m.AppNamespaceRoot(stackName) + if nsRoot == "" || !filepath.IsAbs(nsRoot) { + // Stack is known but its backup location is unresolvable (e.g. systemDataPath unset in a + // misconfigured environment) — honest empty list rather than a path walk from "". + m.logger.Printf("[WARN] [backup] ListRestorePoints(%s): cannot resolve namespace root", stackName) + return []RestorePoint{}, true + } + + fi, err := os.Stat(RecoveryUnitManifestPath(nsRoot, stackName)) + if err != nil { + return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer + } + newest := fi.ModTime() + newest = newestArtifact(AppDBDumpPath(nsRoot, stackName), ".sql", newest) + newest = newestArtifact(AppVolumeDumpPath(nsRoot, stackName), ".tar", newest) + + driveLabel := "" + if drive := m.GetAppDrivePath(stackName); drive != "" && drive != m.systemDataPath && m.settings != nil { + driveLabel = m.settings.GetStorageLabel(drive) + } + + return []RestorePoint{{ + Time: newest.UTC().Format(time.RFC3339), + ShortID: restorePointShortID, + Tier: 1, + DriveLabel: driveLabel, + }}, true +} + +// newestArtifact returns the newest mtime among cur and the files with the given extension in +// dir (non-recursive; a missing dir contributes nothing). +func newestArtifact(dir, ext string, cur time.Time) time.Time { + entries, err := os.ReadDir(dir) + if err != nil { + return cur + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ext) { + continue + } + if info, err := e.Info(); err == nil && info.ModTime().After(cur) { + cur = info.ModTime() + } + } + return cur +} diff --git a/controller/internal/backup/restore_points_test.go b/controller/internal/backup/restore_points_test.go new file mode 100644 index 0000000..f6e9ff0 --- /dev/null +++ b/controller/internal/backup/restore_points_test.go @@ -0,0 +1,141 @@ +package backup + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// newRestorePointsManager builds a Manager over a tempdir unit layout for the ListRestorePoints +// tests. drive is the in-guest namespace root (≠ systemDataPath ⇒ treated as a user-data drive). +func newRestorePointsManager(t *testing.T, drive string, sett *settings.Settings) *Manager { + t.Helper() + return &Manager{ + logger: log.New(io.Discard, "", 0), + settings: sett, + systemDataPath: filepath.Join(t.TempDir(), "sys"), + stackProvider: &fakeRecoveryProvider{hdd: drive}, + } +} + +// TestListRestorePoints_UnitOnDisk proves the F1 endpoint's data source: a recovery unit on disk +// yields EXACTLY ONE tier-1 point whose Time is the NEWEST artifact mtime (here: a db-dump made +// fresher than the manifest — the nightly-dump-vs-checksum-skipped-manifest case). This is the +// half a hollow "returns []" handler could never pass; the companion below is the same layout +// with the manifest deleted. +func TestListRestorePoints_UnitOnDisk(t *testing.T) { + drive := filepath.Join(t.TempDir(), "drive") + mustWrite(t, RecoveryUnitManifestPath(drive, "app"), "{}") + dumpPath := filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql") + mustWrite(t, dumpPath, "dump") + // Make the dump decisively newer than the manifest (mtime granularity safety). + dumpTime := time.Now().Add(1 * time.Hour).Truncate(time.Second) + if err := os.Chtimes(dumpPath, dumpTime, dumpTime); err != nil { + t.Fatal(err) + } + + m := newRestorePointsManager(t, drive, nil) + points, found := m.ListRestorePoints("app") + if !found { + t.Fatal("stack should be found") + } + if len(points) != 1 { + t.Fatalf("want exactly 1 restore point, got %d (%v)", len(points), points) + } + p := points[0] + if p.Tier != 1 { + t.Errorf("tier = %d, want 1 (tier-2 copies are not restorable via /backup/restore)", p.Tier) + } + if p.ShortID != "helyi" { + t.Errorf("short_id = %q, want %q", p.ShortID, "helyi") + } + if want := dumpTime.UTC().Format(time.RFC3339); p.Time != want { + t.Errorf("time = %q, want newest artifact mtime %q (the db-dump, not the older manifest)", p.Time, want) + } +} + +// COMPANION red-proof for the above: the SAME layout minus the manifest must yield an EMPTY list +// (found=true — "no backup yet" is a valid answer, not an error). Together the pair kills the +// hollow implementations: always-[] fails the first test, always-1-entry fails this one. +func TestListRestorePoints_NoUnitYet(t *testing.T) { + drive := filepath.Join(t.TempDir(), "drive") + // db-dump exists but there is NO manifest — the unit is what makes an app restorable. + mustWrite(t, filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql"), "dump") + + m := newRestorePointsManager(t, drive, nil) + points, found := m.ListRestorePoints("app") + if !found { + t.Fatal("a known stack without a unit is still found") + } + if len(points) != 0 { + t.Errorf("want empty list without a recovery unit, got %v", points) + } +} + +// TestListRestorePoints_DriveLabel proves drive_label resolution: a registered storage path's +// label for drive-resident apps, and EMPTY for the SSD system-data fallback (not a bogus basename). +func TestListRestorePoints_DriveLabel(t *testing.T) { + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + + t.Run("registered drive → label", func(t *testing.T) { + drive := filepath.Join(t.TempDir(), "usb") + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "Tárhely (usb)"}); err != nil { + t.Fatal(err) + } + mustWrite(t, RecoveryUnitManifestPath(drive, "app"), "{}") + m := newRestorePointsManager(t, drive, sett) + points, _ := m.ListRestorePoints("app") + if len(points) != 1 || points[0].DriveLabel != "Tárhely (usb)" { + t.Errorf("drive label: %v", points) + } + }) + + t.Run("SSD fallback → empty label", func(t *testing.T) { + sys := filepath.Join(t.TempDir(), "sysdata") + nsRoot := NamespaceRoot(sys, false) // system path appends felhom-data + mustWrite(t, RecoveryUnitManifestPath(nsRoot, "app"), "{}") + m := &Manager{ + logger: log.New(io.Discard, "", 0), + settings: sett, + systemDataPath: sys, + stackProvider: &fakeRecoveryProvider{hdd: ""}, // no HDD_PATH → falls back to systemDataPath + } + points, found := m.ListRestorePoints("app") + if !found || len(points) != 1 { + t.Fatalf("points: %v found=%v", points, found) + } + if points[0].DriveLabel != "" { + t.Errorf("SSD fallback drive_label = %q, want empty", points[0].DriveLabel) + } + }) +} + +// TestListRestorePoints_UnknownStack proves the found=false path (the handler's 404). +func TestListRestorePoints_UnknownStack(t *testing.T) { + m := &Manager{ + logger: log.New(io.Discard, "", 0), + systemDataPath: t.TempDir(), + stackProvider: &unknownStackProvider{}, + } + if _, found := m.ListRestorePoints("ghost"); found { + t.Error("unknown stack must report found=false") + } + // No provider wired at all → also not found (nothing is restorable). + m.stackProvider = nil + if _, found := m.ListRestorePoints("ghost"); found { + t.Error("nil provider must report found=false") + } +} + +// unknownStackProvider is a fakeRecoveryProvider whose stacks never resolve. +type unknownStackProvider struct{ fakeRecoveryProvider } + +func (u *unknownStackProvider) GetStackComposePath(string) (string, bool) { return "", false }