d698ce343b
gates / gates (push) Successful in 14s
R-487: the local backup lists are keyed on the drives, not on what is deployed — a removed app whose unit was kept is listed with the restore that reinstalls it, the picker answers for it, and the restore opens the unit where it sits. R-491: a removal clears the app's update hold. R-490: /api/system/info reaches the API router and reads the default storage path. R-489: volumes_removed is the real before/after difference, [] when none. R-476: a Tier-2 copy is dated by its data, not its manifest. R-456: the boot-orphan rule is pinned. Every fix red-proofed.
136 lines
4.9 KiB
Go
136 lines
4.9 KiB
Go
package backup
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// r487Provider knows exactly which stacks are deployed: GetStackComposePath answers ok for those and
|
|
// only those, which is the question ListRestorePoints and the restore ask.
|
|
type r487Provider struct{ floorProvider }
|
|
|
|
func (p *r487Provider) GetStackComposePath(name string) (string, bool) {
|
|
for _, s := range p.stacks {
|
|
if s == name {
|
|
return filepath.Join(p.dir, "stacks", name, "docker-compose.yml"), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func r487Manager(t *testing.T, sysPath string, deployed ...string) (*Manager, *settings.Settings) {
|
|
t.Helper()
|
|
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.SystemDataPath = sysPath
|
|
m := NewManager(cfg, sett, log.New(os.Stderr, "", 0))
|
|
m.SetStackProvider(&r487Provider{floorProvider{stacks: deployed, dir: t.TempDir()}})
|
|
return m, sett
|
|
}
|
|
|
|
// writeR487Unit lays down a readable unit (manifest + one dump) for stack under nsRoot.
|
|
func writeR487Unit(t *testing.T, nsRoot, stack, display string, dumpAt time.Time) string {
|
|
t.Helper()
|
|
u := mkUnit(t, nsRoot, stack)
|
|
man := RecoveryManifest{SchemaVersion: 2, AppName: stack, DisplayName: display, CreatedAt: "2026-09-12T02:15:29Z"}
|
|
b, _ := json.Marshal(man)
|
|
if err := os.WriteFile(UnitManifestFile(u), b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(UnitDBDumpDir(u), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dump := filepath.Join(UnitDBDumpDir(u), stack+".sql")
|
|
if err := os.WriteFile(dump, []byte("-- dump"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chtimes(dump, dumpAt, dumpAt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// the manifest is the OLDER artifact, as on a real box: it moves only when the definition changes
|
|
older := dumpAt.Add(-24 * time.Hour)
|
|
if err := os.Chtimes(UnitManifestFile(u), older, older); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// R-487 — a removed app's kept unit is listed; a deployed app's is not; a unit on a registered DATA
|
|
// drive is found where it sits and carries that drive's label.
|
|
func TestR487_ListRemovedAppUnits_ListsKeptUnitsOfUndeployedApps(t *testing.T) {
|
|
sys := t.TempDir()
|
|
m, sett := r487Manager(t, sys, "kept-app")
|
|
sysRoot := m.namespaceRoot(sys)
|
|
writeR487Unit(t, sysRoot, "kept-app", "Kept", time.Now())
|
|
writeR487Unit(t, sysRoot, "gone-app", "Gone", time.Now())
|
|
drive := t.TempDir()
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "Külső HDD", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "On the drive", time.Now())
|
|
// a bare directory without a manifest is NOT an offer
|
|
if err := os.MkdirAll(RecoveryUnitPath(sysRoot, "hollow"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := m.ListRemovedAppUnits()
|
|
if len(got) != 2 || got[0].StackName != "drive-app" || got[1].StackName != "gone-app" {
|
|
t.Fatalf("want [drive-app gone-app], got %+v", got)
|
|
}
|
|
if got[0].DriveLabel != "Külső HDD" || got[1].DriveLabel != systemDriveLabel {
|
|
t.Errorf("drive labels: %q / %q", got[0].DriveLabel, got[1].DriveLabel)
|
|
}
|
|
if got[1].DisplayName != "Gone" || got[0].UnitDir != RecoveryUnitPath(m.namespaceRoot(drive), "drive-app") {
|
|
t.Errorf("display/unit dir: %+v", got)
|
|
}
|
|
for _, u := range got {
|
|
if u.StackName == "kept-app" || u.StackName == "hollow" {
|
|
t.Errorf("%s must not be listed", u.StackName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// R-487 — the restore picker's snapshot list answers for a removed app instead of 404.
|
|
func TestR487_ListRestorePointsFindsARemovedAppsUnit(t *testing.T) {
|
|
sys := t.TempDir()
|
|
m, _ := r487Manager(t, sys)
|
|
at := time.Date(2026, 9, 13, 0, 30, 0, 0, time.UTC)
|
|
writeR487Unit(t, m.namespaceRoot(sys), "gone-app", "Gone", at)
|
|
|
|
pts, found := m.ListRestorePoints("gone-app")
|
|
if !found || len(pts) != 1 {
|
|
t.Fatalf("want found with one point, got found=%v pts=%+v", found, pts)
|
|
}
|
|
if pts[0].ShortID != restorePointShortID || pts[0].Tier != 1 || pts[0].Time != at.Format(time.RFC3339) {
|
|
t.Errorf("point %+v", pts[0])
|
|
}
|
|
if _, found := m.ListRestorePoints("never-existed"); found {
|
|
t.Error("an app with no unit anywhere must still be not-found")
|
|
}
|
|
}
|
|
|
|
// R-487 — a removed app's unit kept on a DATA drive is the one the restore opens, not the system
|
|
// path the drive fallback would name.
|
|
func TestR487_PrimaryUnitDirForNamesTheRemovedUnitWhereItSits(t *testing.T) {
|
|
sys := t.TempDir()
|
|
m, sett := r487Manager(t, sys)
|
|
drive := t.TempDir()
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "D", time.Now())
|
|
if got := m.primaryUnitDirFor("drive-app"); got != want {
|
|
t.Errorf("unit dir: got %s want %s", got, want)
|
|
}
|
|
}
|