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,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 }
|
||||
Reference in New Issue
Block a user