fix(api): F1 — register GET /api/backup/snapshots so the restore panel can populate

The backups.html restore panel fetched /api/backup/snapshots (a restic-era route
that no longer existed), so the snapshot dropdown never populated and the
"Visszaállítás indítása" button could never enable — customers could not restore
anything from the UI (drill finding F1, DRILL-appdata-restore-2026-07-04).

- backup.Manager.ListRestorePoints: the keep-side restore has exactly ONE restore
  point per app (the current recovery unit); time = newest artifact mtime among
  manifest/db-dumps/volume-dumps; tier always 1 (Tier-2 copies are NOT restorable
  via POST /backup/restore — never listed); drive_label from the storage registry,
  empty for the SSD fallback.
- api: /backup/snapshots route + validStackParam guard (same semantics as
  web.validStackName; traversal → 400, unknown stack → 404, no unit → ok+[]).
- Tests dispatch through Router.ServeHTTP (the bug WAS a missing route) + unit
  tests for newest-mtime/label/empty semantics. Companion red-proof: hollow
  always-[] implementation fails TestListRestorePoints_UnitOnDisk +
  TestBackupSnapshots_UnitOnDisk (verified, reverted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 11:42:39 +02:00
parent a39ab65eb2
commit f413f9539d
4 changed files with 417 additions and 0 deletions
@@ -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)
}
}
+42
View File
@@ -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=<name> — 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) {
@@ -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
}
@@ -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 }