controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456)
gates / gates (push) Successful in 14s
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.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-476 — a refreshed Tier-2 copy is dated by its DATA (the newest dump), not by a manifest that
|
||||
// moves only when the app's definition changes; a PRESERVED package keeps the manifest date (R-403).
|
||||
func TestR476_UnitRestoreDateNamesTheDataTimeWhenTheLegWasRefreshed(t *testing.T) {
|
||||
cov := Tier2Coverage{UnitPackageDate: "2026-09-12T02:15:29Z", UnitDataDate: "2026-09-13T00:30:00Z", CopyLastSuccess: "2026-09-13T01:30:00Z"}
|
||||
if d, preserved := cov.UnitRestoreDate(); d != "2026-09-13T00:30:00Z" || preserved {
|
||||
t.Errorf("refreshed leg: got %q preserved=%v, want the dump's time", d, preserved)
|
||||
}
|
||||
cov.UnitLegPreserved = true
|
||||
if d, preserved := cov.UnitRestoreDate(); d != "2026-09-12T02:15:29Z" || !preserved {
|
||||
t.Errorf("preserved leg: got %q preserved=%v, want the manifest's date", d, preserved)
|
||||
}
|
||||
older := Tier2Coverage{UnitPackageDate: "2026-09-13T02:00:00Z", UnitDataDate: "2026-09-12T00:30:00Z"}
|
||||
if d, _ := older.UnitRestoreDate(); d != "2026-09-13T02:00:00Z" {
|
||||
t.Errorf("data older than the manifest must not move the date backwards: %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
// R-476 — the coverage reader fills the data date from the mirrored unit's own artifacts.
|
||||
func TestR476_CoverageReadsTheUnitDataDate(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
u := tier2UnitDir(dest)
|
||||
if err := os.MkdirAll(u, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeUnitManifest(t, u, []string{"app.sql"}, nil)
|
||||
cov := tier2CoverageAt(dest)
|
||||
if cov.UnitDataDate == "" {
|
||||
t.Fatal("UnitDataDate must be read from the unit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RemovedAppUnit is a recovery unit that sits on a registered drive while its app is NOT deployed —
|
||||
// the state „Töröld az adataimat is" leaves behind when the customer keeps the backups (R-487).
|
||||
//
|
||||
// It exists because the unit was restorable through POST /backup/restore the whole time and listed
|
||||
// on NEITHER backup page, so the customer's remove-by-mistake route existed only as an endpoint.
|
||||
// The off-site list had exactly this defect and was fixed by keying it on the STORE (R-237); the
|
||||
// local list is now keyed on the drives the same way — what is on disk decides, not what is deployed.
|
||||
type RemovedAppUnit struct {
|
||||
StackName string
|
||||
DisplayName string // from the unit's own manifest; the stack name when the manifest has none
|
||||
UnitDir string // the recovery-unit directory, backups/primary/<stack> on the drive it sits on
|
||||
DriveLabel string // registered storage label; the system-drive label for the SSD fallback
|
||||
Time string // RFC3339 UTC — newest artifact in the unit (same rule as ListRestorePoints)
|
||||
}
|
||||
|
||||
// primaryUnitRoots names every felhom-data namespace root a recovery unit can sit under: the system
|
||||
// data path and every registered storage path that is still connected. Deduplicated; a disconnected
|
||||
// drive is skipped — a unit nobody can open is not an offer (R-102's rule, one tier down).
|
||||
func (m *Manager) primaryUnitRoots() []string {
|
||||
seen := make(map[string]bool)
|
||||
var roots []string
|
||||
add := func(drive string) {
|
||||
if drive == "" || !filepath.IsAbs(drive) {
|
||||
return
|
||||
}
|
||||
root := m.namespaceRoot(drive)
|
||||
if root == "" || seen[root] {
|
||||
return
|
||||
}
|
||||
seen[root] = true
|
||||
roots = append(roots, root)
|
||||
}
|
||||
add(m.systemDataPath)
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
if sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
add(sp.Path)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// driveLabelForRoot maps a namespace root back to the label the page shows for it.
|
||||
func (m *Manager) driveLabelForRoot(root string) string {
|
||||
if m.systemDataPath != "" && root == m.namespaceRoot(m.systemDataPath) {
|
||||
return systemDriveLabel
|
||||
}
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
if m.namespaceRoot(sp.Path) == root {
|
||||
return m.settings.GetStorageLabel(sp.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unitNewestArtifact is the unit's data time: the newest of its manifest, .sql dumps and .tar
|
||||
// volume dumps. ONE rule, shared with ListRestorePoints, so the two lists cannot date a unit
|
||||
// differently.
|
||||
func unitNewestArtifact(unitDir string) (time.Time, bool) {
|
||||
fi, err := os.Stat(UnitManifestFile(unitDir))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
newest := fi.ModTime()
|
||||
newest = newestArtifact(UnitDBDumpDir(unitDir), ".sql", newest)
|
||||
newest = newestArtifact(UnitVolumeDumpDir(unitDir), ".tar", newest)
|
||||
return newest, true
|
||||
}
|
||||
|
||||
// ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the
|
||||
// units whose app is not deployed, sorted by stack name. A unit without a readable manifest is not
|
||||
// listed — the restore would fall back to the volume-only path, which is not the offer this row makes.
|
||||
// A nil provider lists nothing: with no provider "not deployed" cannot be told from "unknown", and an
|
||||
// offer to overwrite must fail closed (the isStackDeployed rule).
|
||||
func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit {
|
||||
if m.stackProvider == nil {
|
||||
return nil
|
||||
}
|
||||
deployed := make(map[string]bool)
|
||||
for _, name := range m.knownStackNames() {
|
||||
deployed[name] = true
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []RemovedAppUnit
|
||||
for _, root := range m.primaryUnitRoots() {
|
||||
entries, err := os.ReadDir(PrimaryBackupPath(root))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.IsDir() || deployed[name] || seen[name] {
|
||||
continue
|
||||
}
|
||||
unitDir := RecoveryUnitPath(root, name)
|
||||
man := readManifest(UnitManifestFile(unitDir))
|
||||
if man == nil {
|
||||
continue
|
||||
}
|
||||
newest, ok := unitNewestArtifact(unitDir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
display := man.DisplayName
|
||||
if display == "" {
|
||||
display = name
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, RemovedAppUnit{
|
||||
StackName: name,
|
||||
DisplayName: display,
|
||||
UnitDir: unitDir,
|
||||
DriveLabel: m.driveLabelForRoot(root),
|
||||
Time: newest.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].StackName < out[j].StackName })
|
||||
return out
|
||||
}
|
||||
|
||||
// RemovedAppUnitFor returns the removed app's unit, if one exists on a connected drive.
|
||||
func (m *Manager) RemovedAppUnitFor(stackName string) (RemovedAppUnit, bool) {
|
||||
for _, u := range m.ListRemovedAppUnits() {
|
||||
if u.StackName == stackName {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return RemovedAppUnit{}, false
|
||||
}
|
||||
@@ -39,6 +39,13 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo
|
||||
return nil, false
|
||||
}
|
||||
if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok {
|
||||
// R-487: a removed app whose backups were kept is not deployed, but its unit is on a drive
|
||||
// and POST /backup/restore reinstalls from it. The picker used to be told 404 here while the
|
||||
// restore itself worked — the list is keyed on the drive now, the way R-237 keyed the
|
||||
// off-site list on the store.
|
||||
if u, found := m.RemovedAppUnitFor(stackName); found {
|
||||
return []RestorePoint{{Time: u.Time, ShortID: restorePointShortID, Tier: 1, DriveLabel: u.DriveLabel}}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,22 @@ type UnitRestoreResult struct {
|
||||
// An unresolvable drive path is still refused inside …At, in the same place and with the same
|
||||
// message, so the order of the checks a caller can observe is unchanged.
|
||||
func (m *Manager) RestoreFromRecoveryUnit(stackName string) (UnitRestoreResult, error) {
|
||||
return m.RestoreFromRecoveryUnitAt(stackName, RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName))
|
||||
return m.RestoreFromRecoveryUnitAt(stackName, m.primaryUnitDirFor(stackName))
|
||||
}
|
||||
|
||||
// primaryUnitDirFor names the PRIMARY unit a keep-side restore opens. For a deployed app that is
|
||||
// backups/primary/<stack> on its own drive. For a REMOVED app (R-487) the drive is no longer known
|
||||
// — GetAppDrivePath falls back to the system path — so a unit kept on a data drive was unreachable
|
||||
// and the restore silently took the volume-only fallback. It is now found where it sits.
|
||||
func (m *Manager) primaryUnitDirFor(stackName string) string {
|
||||
if m.stackProvider != nil {
|
||||
if _, deployed := m.stackProvider.GetStackComposePath(stackName); !deployed {
|
||||
if u, found := m.RemovedAppUnitFor(stackName); found {
|
||||
return u.UnitDir
|
||||
}
|
||||
}
|
||||
}
|
||||
return RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName)
|
||||
}
|
||||
|
||||
// RestoreFromRecoveryUnitAt is RestoreFromRecoveryUnit with an EXPLICIT recovery-unit directory.
|
||||
|
||||
@@ -94,6 +94,12 @@ type Tier2Coverage struct {
|
||||
// it is a fact about the artifact the restore will actually open. "" means UNKNOWN.
|
||||
UnitPackageDate string
|
||||
UnitLegPreserved bool
|
||||
// UnitDataDate (R-476) — the newest ARTIFACT in the mirrored unit: its dumps' mtime, or the
|
||||
// manifest's when nothing is newer. The manifest moves only when the app's DEFINITION changes
|
||||
// (checksum-skip), while the nightly dumps keep their names and their fresh bytes — so on
|
||||
// demo-hp a copy holding a dump written at 00:30Z was dated by a manifest from the day before.
|
||||
// RFC3339 UTC; "" when the unit is not readable.
|
||||
UnitDataDate string
|
||||
}
|
||||
|
||||
// CanRestore reports whether the FILE restore has any subtree to read at all.
|
||||
@@ -142,6 +148,9 @@ func tier2CoverageAt(destBase string) Tier2Coverage {
|
||||
// R-403: ask the package itself when it was made. Reading the artifact rather than the status
|
||||
// record is what makes this date impossible to overstate.
|
||||
c.UnitPackageDate = unitPackageDate(unitDir)
|
||||
if newest, ok := unitNewestArtifact(unitDir); ok {
|
||||
c.UnitDataDate = newest.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -299,11 +308,19 @@ func (c Tier2Coverage) Tier2CopyDate() (date string, proven bool) {
|
||||
// `12:03:49Z` against a run at `12:14:24Z` — perfectly healthy, and all four would have been told
|
||||
// their package was stale. A warning that fires on everything is a warning nobody reads, which costs
|
||||
// the same as the comforting lie it was meant to replace.
|
||||
//
|
||||
// R-476: when the leg was NOT preserved, the package's date is its DATA time — the newest dump in
|
||||
// the copy — never the manifest's, which moves only when the definition changes and so undersold a
|
||||
// fresh copy by a day. A PRESERVED package keeps the manifest date: nothing in it is newer, and the
|
||||
// R-403 rule that a preserved package is never shown as fresh is what this sits under.
|
||||
func (c Tier2Coverage) UnitRestoreDate() (date string, preserved bool) {
|
||||
if c.UnitPackageDate == "" {
|
||||
copyDate, _ := c.Tier2CopyDate()
|
||||
return copyDate, c.UnitLegPreserved
|
||||
}
|
||||
if !c.UnitLegPreserved && c.UnitDataDate != "" && c.UnitDataDate > c.UnitPackageDate {
|
||||
return c.UnitDataDate, false
|
||||
}
|
||||
return c.UnitPackageDate, c.UnitLegPreserved
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user