R-699: a unit that holds no data is never an update-precondition copy (found live on 9202)
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
A just-installed app's unit, captured by the status refresh before any backup, satisfied the precondition on its manifest time; tandoor's PostgreSQL was converted with no backup of its database. Listed still; never a copy on Tier 1 or Tier 2. Red-proof RP6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+7
-2
@@ -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:
|
**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/`.
|
`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
|
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,
|
backup — measured per app: six of seven catalog apps keep the login in their data (code-server is the exception,
|
||||||
`loginAppliedEveryStart`).
|
`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.
|
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
|
## 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
|
||||||
|
|||||||
@@ -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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func r659Manager(t *testing.T, files bool, tiers map[int]time.Time) *Manager {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
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) {
|
m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) {
|
||||||
at, ok := tiers[UpdateTierOffsite]
|
at, ok := tiers[UpdateTierOffsite]
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// only for a unit that holds no data file at all, whose whole content is its definition.
|
||||||
func unitNewestArtifact(unitDir string) (time.Time, bool) {
|
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))
|
fi, err := os.Stat(UnitManifestFile(unitDir))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, false
|
return time.Time{}, false, false
|
||||||
}
|
}
|
||||||
if man := readManifest(UnitManifestFile(unitDir)); man != nil {
|
if man := readManifest(UnitManifestFile(unitDir)); man != nil {
|
||||||
if t, ok := man.Data.DataTime(); ok {
|
if t, ok := man.Data.DataTime(); ok {
|
||||||
return t, true
|
return t, true, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var newest time.Time
|
var newest time.Time
|
||||||
newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest)
|
newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest)
|
||||||
newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest)
|
newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest)
|
||||||
if newest.IsZero() {
|
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
|
// ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type RestorePoint struct {
|
|||||||
ShortID string `json:"short_id"` // opaque label; POST /backup/restore uses it for logging only
|
ShortID string `json:"short_id"` // opaque label; POST /backup/restore uses it for logging only
|
||||||
Tier int `json:"tier"` // always 1 (see above)
|
Tier int `json:"tier"` // always 1 (see above)
|
||||||
DriveLabel string `json:"drive_label"` // registered storage label; empty for the SSD fallback
|
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"),
|
// 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.
|
// 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 {
|
if !ok {
|
||||||
return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer
|
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,
|
ShortID: restorePointShortID,
|
||||||
Tier: 1,
|
Tier: 1,
|
||||||
DriveLabel: m.sysDriveLabelFor(stackName),
|
DriveLabel: m.sysDriveLabelFor(stackName),
|
||||||
|
DataProven: proven,
|
||||||
}}, true
|
}}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// RFC3339 UTC; "" when the unit is not readable.
|
||||||
UnitDataDate string
|
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.
|
// 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
|
// 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.
|
// record is what makes this date impossible to overstate.
|
||||||
c.UnitPackageDate = unitPackageDate(unitDir)
|
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.UnitDataDate = newest.UTC().Format(time.RFC3339)
|
||||||
|
c.UnitDataUnproven = !proven
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ type Tier2RestorePoint struct {
|
|||||||
// DataDate (v0.275.0, R-696) — the mirrored unit's DATA time (unitNewestArtifact on the mirror): when
|
// 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.
|
// the data the copy holds was written, which a mirror run copies but never makes newer. "" = unknown.
|
||||||
DataDate string
|
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.
|
// restorePointFromCoverage is the pure half of the predicate.
|
||||||
@@ -58,6 +61,7 @@ func restorePointFromCoverage(cov Tier2Coverage) Tier2RestorePoint {
|
|||||||
PackagePreserved: preserved,
|
PackagePreserved: preserved,
|
||||||
CopyLastSuccess: cov.CopyLastSuccess,
|
CopyLastSuccess: cov.CopyLastSuccess,
|
||||||
DataDate: cov.UnitDataDate,
|
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
|
// 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.
|
// the package date is the honest one, because that is what the copy really holds.
|
||||||
func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) {
|
func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) {
|
||||||
if !p.Restorable || !p.CopyDateProven {
|
if !p.Restorable || !p.CopyDateProven || p.DataUnproven {
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
src := p.CopyLastSuccess
|
src := p.CopyLastSuccess
|
||||||
@@ -269,6 +273,11 @@ func (m *Manager) updateTierPoint(ctx context.Context, stackName string, tier in
|
|||||||
}
|
}
|
||||||
pts, _ := list(stackName)
|
pts, _ := list(stackName)
|
||||||
for _, rp := range pts {
|
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 {
|
if at, err := time.Parse(time.RFC3339, rp.Time); err == nil {
|
||||||
return UpdateTierPoint{Tier: tier, At: at}, true
|
return UpdateTierPoint{Tier: tier, At: at}, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func noTier2(string) (Tier2RestorePoint, error) {
|
|||||||
}
|
}
|
||||||
func tier1At(at time.Time) func(string) ([]RestorePoint, bool) {
|
func tier1At(at time.Time) func(string) ([]RestorePoint, bool) {
|
||||||
return 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 }
|
func noTier1(string) ([]RestorePoint, bool) { return []RestorePoint{}, true }
|
||||||
|
|||||||
Reference in New Issue
Block a user