diff --git a/CHANGELOG.md b/CHANGELOG.md index da16ca3..5346f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## v0.275.0 — a backup's data and its version travel together (R-696, `07` §6.6, D4 option A); R-695, R-691, R-694 (2026-09-26) +## v0.275.0 — a backup's data and its version travel together (R-696, `07` §6.6, D4 option A); R-695, R-691, R-694, R-699 (2026-09-26) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: yes (hu + en, 5 keys). Evidence: `felhom.eu/documentation/audits/version-travel-2026-09-26/`. @@ -33,7 +33,12 @@ page no longer shows that value as "the first password set at install" and says to use the password valid at the backup — measured per app: six of seven catalog apps keep the login in their data (code-server is the exception, `loginAppliedEveryStart`). -- Red-proofs (each seen failing, tree restored): `A5-redproofs/` RP1–RP5, `D2/`, `D3/`, `D4/`. Parity: one new +- **R-699 (found live on 9202 during Part B):** a unit that is only a captured definition — a just-installed app's, + written by the status refresh before any backup — satisfied the update's precondition ("Tier 1 copy 2m0s old") and + tandoor's PostgreSQL was converted with no backup of its database. Such a unit stays listed (restorable as the + definition) but is never a precondition copy on Tier 1 or Tier 2 (`RestorePoint.DataProven`, `unitDataTime`); the + update then backs up first. +- Red-proofs (each seen failing, tree restored): `A5-redproofs/` RP1–RP6, `D2/`, `D3/`, `D4/`. Parity: one new Hungarian fixture (`deploy_deployed_restored_login`); no existing fixture changed. ## v0.274.0 — kept data: a choice at reinstall, a list, a read-only view, a load (2026-09-25, `09` §3 decision 36); R-690, R-692 diff --git a/controller/internal/backup/a_version_travel_test.go b/controller/internal/backup/a_version_travel_test.go index d2498bd..1b6aa72 100644 --- a/controller/internal/backup/a_version_travel_test.go +++ b/controller/internal/backup/a_version_travel_test.go @@ -574,3 +574,47 @@ func TestA4_TheOffsiteRunRecordsThePushedDataTime(t *testing.T) { t.Fatalf("after a successful push the data-time record is %+v ok=%v, want the unit's data time", rec, ok) } } + +// R-699 (v0.275.0) — a just-installed app's unit, captured by the status refresh before any backup ran, +// holds only the definition. It stays LISTED (restorable as the definition) but is never a copy the +// update's precondition leans on; after a data run it is. +// +// COMPANION RED-PROOF (REPORT.md): drop the DataProven skip in updateTierPoint — the precondition then +// accepts the dump-less unit ("Tier 1 … 0s old"), which is what 9202 logged for tandoor on 2026-09-27. +func TestR699_ADefinitionOnlyUnitIsNotAPreconditionCopy(t *testing.T) { + v := newVTStack(t, "16") + if err := v.m.captureRecoveryUnit("app", false); err != nil { // the refresh, right after the install + t.Fatal(err) + } + pts, _ := v.m.ListRestorePoints("app") + if len(pts) != 1 || pts[0].DataProven { + t.Fatalf("points = %+v, want one listed, NOT proven", pts) + } + any := func(UpdateTierPoint) bool { return true } + if p, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); ok { + t.Fatalf("the precondition accepted tier %d at %s — a unit with no data is not a copy", p.Tier, p.At) + } + // The update's own "back up first" is a data run: afterwards the unit IS a copy. + v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { + return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil + } + v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult { + p := filepath.Join(dir, "app-postgres.sql") + mustWrite(t, p, pgDump(1)) + return DumpResult{DB: db, FilePath: p} + } + v.m.perAppTier2 = func(string) error { return nil } + if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil { + t.Fatal(err) + } + if _, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); !ok { + t.Fatal("after a backup the own unit is still not a copy") + } +} + +func TestR699_ADefinitionOnlyMirrorIsNotAPreconditionCopy(t *testing.T) { + p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: "2026-09-27T09:36:36Z", DataDate: "2026-09-27T09:36:36Z", DataUnproven: true} + if _, ok := p.ProvenCopyTime(); ok { + t.Fatal("a mirror of a definition-only unit counted as a proven copy") + } +} diff --git a/controller/internal/backup/r659_whole_copy_test.go b/controller/internal/backup/r659_whole_copy_test.go index f08ffa1..2ce0922 100644 --- a/controller/internal/backup/r659_whole_copy_test.go +++ b/controller/internal/backup/r659_whole_copy_test.go @@ -41,7 +41,7 @@ func r659Manager(t *testing.T, files bool, tiers map[int]time.Time) *Manager { if !ok { return nil, false } - return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1}}, true + return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1, DataProven: true}}, true } m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) { at, ok := tiers[UpdateTierOffsite] diff --git a/controller/internal/backup/removed_units.go b/controller/internal/backup/removed_units.go index eaf6c58..e682a36 100644 --- a/controller/internal/backup/removed_units.go +++ b/controller/internal/backup/removed_units.go @@ -77,22 +77,33 @@ func (m *Manager) driveLabelForRoot(root string) string { // copies `pre-restore-*` excluded — an update's own safety dump is not a backup); the manifest's time // only for a unit that holds no data file at all, whose whole content is its definition. func unitNewestArtifact(unitDir string) (time.Time, bool) { + t, _, ok := unitDataTime(unitDir) + return t, ok +} + +// unitDataTime is unitNewestArtifact plus WHETHER THE TIME IS A PROVEN DATA TIME (R-699, v0.275.0): true +// when a data run confirmed the unit (`data` block) or it holds data files; false when the unit is only a +// captured definition — a just-installed app's unit, written by the status refresh before any backup +// ran. Such a unit is still LISTED (it can be restored: it is the app's definition), but it is never a +// copy the update's precondition may lean on — measured on 9202 2026-09-27: tandoor's two-minute-old, +// dump-less unit satisfied it and PostgreSQL was converted with no backup of the database. +func unitDataTime(unitDir string) (time.Time, bool, bool) { fi, err := os.Stat(UnitManifestFile(unitDir)) if err != nil { - return time.Time{}, false + return time.Time{}, false, false } if man := readManifest(UnitManifestFile(unitDir)); man != nil { if t, ok := man.Data.DataTime(); ok { - return t, true + return t, true, true } } var newest time.Time newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest) newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest) if newest.IsZero() { - return fi.ModTime(), true + return fi.ModTime(), false, true } - return newest, true + return newest, true, true } // ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the diff --git a/controller/internal/backup/restore_points.go b/controller/internal/backup/restore_points.go index c30b7e1..481505a 100644 --- a/controller/internal/backup/restore_points.go +++ b/controller/internal/backup/restore_points.go @@ -21,6 +21,10 @@ type RestorePoint struct { 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 + // DataProven (R-699, v0.275.0) — the time is a PROVEN data time (a data run confirmed the unit, or it + // holds data files). False for a unit that is only a captured definition: listed, never a copy the + // update's precondition may lean on. Not part of the page payload. + DataProven bool `json:"-"` } // restorePointShortID is the single keep-side restore point's identifier. Hungarian ("local"), @@ -60,7 +64,7 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo } // v0.275.0 (R-696): the unit's DATA time (unitNewestArtifact), never the manifest's refresh time. - newest, ok := unitNewestArtifact(RecoveryUnitPath(nsRoot, stackName)) + newest, proven, ok := unitDataTime(RecoveryUnitPath(nsRoot, stackName)) if !ok { return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer } @@ -70,6 +74,7 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo ShortID: restorePointShortID, Tier: 1, DriveLabel: m.sysDriveLabelFor(stackName), + DataProven: proven, }}, true } diff --git a/controller/internal/backup/tier2_restore.go b/controller/internal/backup/tier2_restore.go index c731c88..da56ca7 100644 --- a/controller/internal/backup/tier2_restore.go +++ b/controller/internal/backup/tier2_restore.go @@ -100,6 +100,9 @@ type Tier2Coverage struct { // 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 + // UnitDataUnproven (R-699, v0.275.0) — the mirrored unit holds no proven data (no `data` block, no + // data file): restorable as a definition, never a copy the update's precondition may lean on. + UnitDataUnproven bool } // CanRestore reports whether the FILE restore has any subtree to read at all. @@ -148,8 +151,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 { + if newest, proven, ok := unitDataTime(unitDir); ok { c.UnitDataDate = newest.UTC().Format(time.RFC3339) + c.UnitDataUnproven = !proven } return c } diff --git a/controller/internal/backup/update_guard.go b/controller/internal/backup/update_guard.go index ae82674..f7110f6 100644 --- a/controller/internal/backup/update_guard.go +++ b/controller/internal/backup/update_guard.go @@ -46,6 +46,9 @@ type Tier2RestorePoint struct { // DataDate (v0.275.0, R-696) — the mirrored unit's DATA time (unitNewestArtifact on the mirror): when // the data the copy holds was written, which a mirror run copies but never makes newer. "" = unknown. DataDate string + // DataUnproven (R-699) — the mirrored unit is only a captured definition (no `data`, no data file): + // never a copy the update may lean on. + DataUnproven bool } // restorePointFromCoverage is the pure half of the predicate. @@ -58,6 +61,7 @@ func restorePointFromCoverage(cov Tier2Coverage) Tier2RestorePoint { PackagePreserved: preserved, CopyLastSuccess: cov.CopyLastSuccess, DataDate: cov.UnitDataDate, + DataUnproven: cov.UnitDataUnproven, } } @@ -86,7 +90,7 @@ func (m *Manager) Tier2UnitRestorePoint(stackName string) (Tier2RestorePoint, er // actually mirrored the unit — EXCEPT when the run preserved an older package (R-403), in which case // the package date is the honest one, because that is what the copy really holds. func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) { - if !p.Restorable || !p.CopyDateProven { + if !p.Restorable || !p.CopyDateProven || p.DataUnproven { return time.Time{}, false } src := p.CopyLastSuccess @@ -269,6 +273,11 @@ func (m *Manager) updateTierPoint(ctx context.Context, stackName string, tier in } pts, _ := list(stackName) for _, rp := range pts { + if !rp.DataProven { + // R-699: a unit that is only a captured definition is not a copy of the app's data. + m.logger.Printf("[INFO] [backup] update precondition for %s: the own unit holds no data yet (no backup run has confirmed it) — not a copy", stackName) + continue + } if at, err := time.Parse(time.RFC3339, rp.Time); err == nil { return UpdateTierPoint{Tier: tier, At: at}, true } diff --git a/controller/internal/backup/update_tiers_test.go b/controller/internal/backup/update_tiers_test.go index cecabfc..c46024e 100644 --- a/controller/internal/backup/update_tiers_test.go +++ b/controller/internal/backup/update_tiers_test.go @@ -39,7 +39,7 @@ func noTier2(string) (Tier2RestorePoint, error) { } func tier1At(at time.Time) func(string) ([]RestorePoint, bool) { return func(string) ([]RestorePoint, bool) { - return []RestorePoint{{Time: at.UTC().Format(time.RFC3339), ShortID: "helyi", Tier: 1}}, true + return []RestorePoint{{Time: at.UTC().Format(time.RFC3339), ShortID: "helyi", Tier: 1, DataProven: true}}, true } } func noTier1(string) ([]RestorePoint, bool) { return []RestorePoint{}, true }