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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user