moves: register the database only once installed; journal the held render (Plan 5 Task 9a, step 3)
Carry (b), a foreign database parked by F's retirement: the landing's and the dirty return's local step registers only what it placed, the files directory and <host>.env. The database and its marker are registered by configured (registerDatabase, journal step inventory_db) once restore has proved the database at DBPath is this install's build. A rollback of a landing that stopped before that leaves a database it never installed in place. The target's cleaning still counts the build as the op's between the install and the record's stamp, by content: the journalled install manifest's sha256 and a marker naming the op's generation (isBuild). Carry (c), config.env left behind when its ack fails: configured journals <host>.config.env's state before the held render (pre_render) and the rendered file's sha256 as soon as the render answers, its ack failed or not (rendered). The cleaning parks the file only when its content is what this op rendered, by the ack or by the journal. Tests: TestForeignDatabaseSurvivesRollbackRetirement, TestInstalledDatabaseIsRegisteredAfterTheInstall, TestFailedRenderAckIsParkedByCleaning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+357
−10
internal/host/movescarries_test.go+188 −0internal/host/movesinstall.go+107 −1internal/host/movesland.go+4 −1internal/host/movesrollback.go+58 −8
diff --git a/internal/host/movescarries_test.go b/internal/host/movescarries_test.go| new file mode 100644 |
| index 000000000..d7df5e002 |
| --- /dev/null |
| +++ b/internal/host/movescarries_test.go |
| @@ -0,0 +1,188 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The Task 9 carries (Plan 5 Task 9a, M16): (b) a foreign database parked by |
| +// F's retirement, (c) a rendered config left behind when its ack fails. |
| + |
| +// parkedLocally lists the base names under the target's .parked/. |
| +func parkedLocally(t *testing.T, dir string) map[string][]byte { |
| + t.Helper() |
| + out := map[string][]byte{} |
| + filepath.WalkDir(filepath.Join(dir, ".parked"), func(p string, d os.DirEntry, err error) error { |
| + if err == nil && !d.IsDir() && d.Type().IsRegular() { |
| + b, _ := os.ReadFile(p) |
| + out[filepath.Base(p)] = b |
| + } |
| + return nil |
| + }) |
| + return out |
| +} |
| + |
| +// cleanAfterRollback rolls the landing back, recovers the source and runs |
| +// the target's cleaning to cleaned. |
| +func (h *rollbackHarness) cleanAfterRollback(t *testing.T) { |
| + t.Helper() |
| + h.rollBack(t) |
| + h.rollbackPass(t) |
| + h.recovered(t) |
| + for i := 0; i < 4 && h.rb(t).TargetPhase != provision.RbTargetCleaned; i++ { |
| + h.rollbackPass(t) |
| + } |
| + if rb := h.rb(t); rb.TargetPhase != provision.RbTargetCleaned { |
| + t.Fatalf("the target did not clean: %+v", rb) |
| + } |
| +} |
| + |
| +// TestForeignDatabaseSurvivesRollbackRetirement (carry (b)): the landing |
| +// registers only what it placed at local, the files and the env; the |
| +// database and its marker are registered once the install proves them the |
| +// build's. A database that is not the build, standing at DBPath when a |
| +// landing stopped at local rolls back, is neither retired with F nor taken |
| +// by the moves-owned rename. |
| +func TestForeignDatabaseSurvivesRollbackRetirement(t *testing.T) { |
| + h := &rollbackHarness{landingHarness: newLandingHarness(t)} |
| + h.flip(t) |
| + h.landAt = "after-inventory" |
| + h.crashLand(t) |
| + if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandLocal { |
| + t.Fatalf("fixture: the landing is at %s", m.LandingPhase) |
| + } |
| + paths := h.claim(t).Inventory["uk-box"].Paths |
| + if _, ok := paths[p1Host+".db"]; ok || len(paths) != 2 { |
| + t.Fatalf("local registered %v, want the files and the env only", paths) |
| + } |
| + db := provision.DefaultDBPath(h.tdir, p1Host) |
| + foreign := []byte("a database this landing never installed") |
| + if err := os.WriteFile(db, foreign, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(db+".gen", []byte("someone-else\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.cleanAfterRollback(t) |
| + if got, err := os.ReadFile(db); err != nil || !bytes.Equal(got, foreign) { |
| + t.Fatalf("the foreign database: %q %v", got, err) |
| + } |
| + if _, err := os.Stat(db + ".gen"); err != nil { |
| + t.Fatalf("the foreign marker: %v", err) |
| + } |
| + for name, body := range parkedLocally(t, h.tdir) { |
| + if bytes.Equal(body, foreign) { |
| + t.Fatalf("the foreign database was parked as %s", name) |
| + } |
| + } |
| + if _, err := os.Lstat(filepath.Join(h.tdir, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the landing's files directory is still live: %v", err) |
| + } |
| +} |
| + |
| +// TestInstalledDatabaseIsRegisteredAfterTheInstall (carry (b)): once |
| +// configured installs the build, the database and its marker are registered |
| +// under F, and a crash between that install and the record's stamp still |
| +// leaves them the op's by content: the cleaning parks the build. |
| +func TestInstalledDatabaseIsRegisteredAfterTheInstall(t *testing.T) { |
| + h := &rollbackHarness{landingHarness: newLandingHarness(t)} |
| + h.flip(t) |
| + h.landAt = "after-db-rename" |
| + h.crashLand(t) |
| + if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandConfigured || (m.Landing != nil && m.Landing.InstalledDBSHA256 != "") { |
| + t.Fatalf("fixture: %+v", m) |
| + } |
| + if _, ok := h.claim(t).Inventory["uk-box"].Paths[p1Host+".db"]; ok { |
| + t.Fatal("the database was registered before the install finished") |
| + } |
| + db := provision.DefaultDBPath(h.tdir, p1Host) |
| + built, err := os.ReadFile(db) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h.cleanAfterRollback(t) |
| + if _, err := os.Lstat(db); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the build is still at DBPath: %v", err) |
| + } |
| + if got, ok := parkedLocally(t, h.tdir)[p1Host+".db"]; !ok || !bytes.Equal(got, built) { |
| + t.Fatal("the build was not parked") |
| + } |
| + |
| + h2 := newLandingHarness(t) |
| + h2.flip(t) |
| + h2.land(t) |
| + h2.installed(t) |
| + paths := h2.claim(t).Inventory["uk-box"].Paths |
| + for _, p := range []string{p1Host + ".db", p1Host + ".db.gen", p1Host, p1Host + ".env"} { |
| + if _, ok := paths[p]; !ok { |
| + t.Fatalf("after configured the inventory is %v, missing %s", paths, p) |
| + } |
| + } |
| +} |
| + |
| +// TestFailedRenderAckIsParkedByCleaning (carry (c)): the held render wrote |
| +// <host>.config.env and its config ack failed. The journal recorded the |
| +// file's state before the render and the rendered sha256, so the target's |
| +// cleaning parks the file; a file whose content is not the render's is |
| +// left. |
| +func TestFailedRenderAckIsParkedByCleaning(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + replace []byte // written over the rendered file before the cleaning |
| + parked bool |
| + }{ |
| + {"the rendered file", nil, true}, |
| + {"another file at the path", []byte("SOMEONE=else\n"), false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + h := &rollbackHarness{landingHarness: newLandingHarness(t)} |
| + h.flip(t) |
| + path := filepath.Join(h.tdir, p1Host+".config.env") |
| + rendered := []byte("SECRET=landing\n") |
| + sum := sha256.Sum256(rendered) |
| + ackFailed := errors.New("held config ack: the store refused the write") |
| + h.render = func() (appconfig.HeldRender, error) { |
| + if err := os.WriteFile(path, rendered, 0o600); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| + in := h.read(t, p1Host).Move.Inputs |
| + return appconfig.HeldRender{Path: path, SHA256: hex.EncodeToString(sum[:]), BundleETags: in.BundleETags, OverlayETag: in.OverlayETag}, ackFailed |
| + } |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandConfigured { |
| + t.Fatalf("fixture: the landing is at %s", m.LandingPhase) |
| + } |
| + j := h.landJournal(t) |
| + if j.Steps[landStepPreRender] != "absent" || j.Steps[landStepRendered] != "sha256:"+hex.EncodeToString(sum[:]) { |
| + t.Fatalf("the render's journal: pre %q rendered %q", j.Steps[landStepPreRender], j.Steps[landStepRendered]) |
| + } |
| + if tc.replace != nil { |
| + if err := os.WriteFile(path, tc.replace, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + h.cleanAfterRollback(t) |
| + _, err := os.Lstat(path) |
| + if tc.parked { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the rendered config is still live: %v", err) |
| + } |
| + if got, ok := parkedLocally(t, h.tdir)[p1Host+".config.env"]; !ok || !bytes.Equal(got, rendered) { |
| + t.Fatal("the rendered config was not parked") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("a file that is not the render's was taken: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index 8c7ea8ebd..4b0e3526d 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -6,6 +6,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "os" |
| "path/filepath" |
| "sort" |
| "strconv" |
| @@ -690,7 +691,14 @@ func (ir *installRun) local_(ctx context.Context) (bool, error) { |
| } |
| } |
| if !j.Done(landStepInventory) { |
| - paths := []string{host + ".db", host + ".db.gen", host, host + ".env"} |
| + // Only what this install has placed (carry (b)): the files directory |
| + // and <host>.env, which installFiles moved into place when nothing |
| + // it did not write was there. The database and its marker are |
| + // registered by configured, once the install proves the database at |
| + // DBPath is this build (registerDatabase): registered here, before |
| + // the install, a rollback's retirement of F would park whatever |
| + // database stands at DBPath, a foreign one included. |
| + paths := []string{host, host + ".env"} |
| if err := ir.local.RecordInventory(ctx, host, captured, "flat", paths, ir.src.ClaimCheck(phase)); err != nil { |
| return false, fmt.Errorf("record the inventory: %w", err) |
| } |
| @@ -968,7 +976,20 @@ func (ir *installRun) configured(ctx context.Context) (bool, error) { |
| if err != nil { |
| return false, err |
| } |
| + // The render's ownership is journalled (carry (c)): the file's state |
| + // before the render, then the rendered file's sha256 as soon as the |
| + // render answers, even when its config ack failed. A rollback's |
| + // cleaning parks <host>.config.env only when its content is the one |
| + // this op rendered (rollbackOwnership). |
| + if err := ir.notePreRender(); err != nil { |
| + return false, err |
| + } |
| hr, err := cfg.RenderHeld(ctx, ir.s, rt, ir.src.HoldOp) |
| + if hr.SHA256 != "" { |
| + if jerr := ir.noteRendered(hr.SHA256); jerr != nil { |
| + return false, errors.Join(err, jerr) |
| + } |
| + } |
| if err != nil { |
| return false, fmt.Errorf("render the held row: %w", err) |
| } |
| @@ -1014,12 +1035,97 @@ func (ir *installRun) configured(ctx context.Context) (bool, error) { |
| case restoreMarked: |
| return true, nil // the record changed: the refusal, not an install |
| } |
| + if err := ir.registerDatabase(ctx, obs, phase); err != nil { |
| + return false, err |
| + } |
| if ir.pastDeadline() { |
| return ir.refuse(ctx, refuseDeadline) |
| } |
| return ir.advance(ctx, phase, next, installStamp{InstalledDBSHA256: sha, RecoveryInputs: recovery}) |
| } |
| +// registerDatabase registers the installed database and its marker under F |
| +// (carry (b)), once restore has proved the database at DBPath is this |
| +// install's build: renamed into place inside the guard, or found there |
| +// with the build's sha256. Journalled, so a replay registers once. |
| +func (ir *installRun) registerDatabase(ctx context.Context, obs hostclaim.Observed, phase string) error { |
| + j, err := ir.journal() |
| + if err != nil { |
| + return err |
| + } |
| + if j.Done(landStepInventoryDB) { |
| + return nil |
| + } |
| + captured, err := ir.capture(*obs.Claim.Holder) |
| + if err != nil { |
| + return err |
| + } |
| + host := ir.rec.Host |
| + if err := ir.local.RecordInventory(ctx, host, captured, "flat", []string{host + ".db", host + ".db.gen"}, ir.src.ClaimCheck(phase)); err != nil { |
| + return fmt.Errorf("register the installed database: %w", err) |
| + } |
| + j.Phase = phase |
| + j.Mark(landStepInventoryDB, "") |
| + if err := j.Write(ir.cfg().DataDir); err != nil { |
| + return err |
| + } |
| + return ir.fault("after-" + landStepInventoryDB) |
| +} |
| + |
| +// configEnvPath is the held render's file, <DataDir>/<host>.config.env. |
| +func (ir *installRun) configEnvPath() string { |
| + return filepath.Join(ir.cfg().DataDir, ir.rec.Host+".config.env") |
| +} |
| + |
| +// notePreRender journals <host>.config.env's state before the first held |
| +// render: "absent", "sha256:<hex>" of a regular file, or "other". Lstat'ed, |
| +// never followed. A replay keeps the first look. |
| +func (ir *installRun) notePreRender() error { |
| + j, err := ir.journal() |
| + if err != nil { |
| + return err |
| + } |
| + if _, ok := j.Steps[landStepPreRender]; ok { |
| + return nil |
| + } |
| + state := "other" |
| + path := ir.configEnvPath() |
| + fi, err := os.Lstat(path) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + state = "absent" |
| + case err != nil: |
| + return err |
| + case fi.Mode().IsRegular(): |
| + if state, err = fileSHA256(path); err != nil { |
| + return err |
| + } |
| + } |
| + j.Mark(landStepPreRender, state) |
| + if err := j.Write(ir.cfg().DataDir); err != nil { |
| + return err |
| + } |
| + return ir.fault("after-" + landStepPreRender) |
| +} |
| + |
| +// noteRendered journals the sha256 (hex) of the file the held render wrote, |
| +// as "sha256:<hex>": the latest render's, which is what the file holds. |
| +func (ir *installRun) noteRendered(sha string) error { |
| + j, err := ir.journal() |
| + if err != nil { |
| + return err |
| + } |
| + want := "sha256:" + sha |
| + if j.Steps[landStepRendered] == want { |
| + return nil |
| + } |
| + j.Mark(landStepRendered, want) |
| + if err := j.Write(ir.cfg().DataDir); err != nil { |
| + return err |
| + } |
| + return ir.fault("after-" + landStepRendered) |
| +} |
| + |
| // recoveryInstallSource binds the installer to a dirty return on the source |
| // (§7.8 source table, the dirty branch; rulings J13, J14, J20): the record |
| // rolling_back and dirty with the source at an install phase; the claim |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index 90e97c770..b05582916 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -106,13 +106,16 @@ const ( |
| landStepStaged = "staged" // the archive is extracted into <JournalDir>/extract/ (JSON stagedFiles) |
| landStepFilesPlaced = "files_placed" // the install receipt: the staged tree is at the live paths (JSON stagedFiles) |
| landStepExtracted = "extracted" // the archive is in the files directory and <host>.env |
| - landStepInventory = "inventory" // the new paths are registered under F |
| + landStepInventory = "inventory" // the placed files and env are registered under F |
| landStepTimers = "timers" // the timers table is the sealed snapshot |
| landStepColumns = "columns" // replication and next_due carried over |
| landStepVerify = "verify" // the restore-verification entry is gone |
| landStepFilesManifest = "files_manifest" // the prepared files manifest (C8; skipped until it exists) |
| landStepFilesGen = "files_gen" // .files-gen written |
| landStepPreInstall = "pre_install" // the database's siblings absent before its install (JSON preInstall) |
| + landStepInventoryDB = "inventory_db" // the installed database and its marker are registered under F (carry (b)) |
| + landStepPreRender = "pre_render" // <host>.config.env before the held render: "absent", "sha256:<hex>" or "other" (carry (c)) |
| + landStepRendered = "rendered" // the held render's file, "sha256:<hex>", journalled even when its ack failed (carry (c)) |
| ) |
| // landingFilesManifestSkipped is what the files_manifest step records while |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index 33b81c81c..f79d2eadc 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -16,6 +16,7 @@ import ( |
| "github.com/carlosframework/platform/internal/alerting" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/lease" |
| @@ -1388,21 +1389,29 @@ func (r *rollbackRun) parkLeftovers(ctx context.Context, j *moves.Journal, mark |
| // its database, as the record says (landing.installed_db_sha256, stamped |
| // by the CAS that ends configured); the install refused a WAL or shared |
| // memory beside an absent database, so what sits beside it since is the |
| -// op's instance's; |
| +// op's instance's. Before that stamp, the database and its marker are |
| +// the op's only when they are the build itself: the database's sha256 |
| +// is the journalled install manifest's, and the marker names the op's |
| +// generation (a crash between the install and its stamp; carry (b)); |
| // - the quarantine and the litestream shadow, which the install does not |
| // refuse: the database installed as above, and the install's journal |
| // records the path absent just before it put the database in place |
| // (pre_install, notePreInstall). Present then, or no record, is never |
| // the op's (the final re-review's residual); |
| -// - the rendered config, <host>.config.env: the held render's ack on this |
| -// box names this op, and the file's sha256 is the one the ack recorded; |
| +// - the rendered config, <host>.config.env: the file's sha256 is the one |
| +// this op rendered, as the held render's ack on this box records it |
| +// (the ack names this op), or as the landing's journal records it |
| +// (rendered, written as soon as the render answers, its ack failed or |
| +// not; carry (c)). Any other content is not the op's; |
| // - the sidecar's env: never, since a moved host has no sidecar. |
| type rollbackOwnership struct { |
| host, dataDir string |
| placed stagedFiles // the landing's receipt, or its staged identities |
| installed bool |
| + builtSHA256 string // the journalled install manifest's database, "sha256:<hex>", or "" |
| + generation string // the generation the install names (the op id) |
| absentBefore map[string]bool // the siblings pre_install recorded absent |
| - configSHA256 string // the held render's ack, "sha256:<hex>", or "" |
| + configSHA256 []string // what this op rendered, "sha256:<hex>": the ack's and the journal's |
| } |
| // ownership reads the evidence: the landing's journal, the record and the |
| @@ -1425,6 +1434,16 @@ func (r *rollbackRun) ownership(ctx context.Context, j *moves.Journal) (rollback |
| if l := r.rec.Move.Landing; l != nil && l.InstalledDBSHA256 != "" { |
| ev.installed = true |
| } |
| + ev.generation = id |
| + if raw, ok := j.Steps[landStepInstall]; ok { |
| + var m moves.InstallManifest |
| + if err := json.Unmarshal([]byte(raw), &m); err == nil && m.Op == id && m.Host == host && m.DBSHA256 != "" { |
| + ev.builtSHA256 = "sha256:" + m.DBSHA256 |
| + } |
| + } |
| + if rendered := j.Steps[landStepRendered]; strings.HasPrefix(rendered, "sha256:") { |
| + ev.configSHA256 = append(ev.configSHA256, rendered) |
| + } |
| if raw, ok := j.Steps[landStepPreInstall]; ok { |
| var pre preInstall |
| if err := json.Unmarshal([]byte(raw), &pre); err != nil { |
| @@ -1446,7 +1465,7 @@ func (r *rollbackRun) ownership(ctx context.Context, j *moves.Journal) (rollback |
| if err := json.Unmarshal(body, &a); err != nil { |
| log.Printf("moves: %s: the config ack is unreadable (%v); the rendered config is not counted as %s's", host, err, id) |
| } else if a.MoveOp == id && a.Box == cfg.Box && a.Host == host && a.FileSHA256 != "" { |
| - ev.configSHA256 = "sha256:" + a.FileSHA256 |
| + ev.configSHA256 = append(ev.configSHA256, "sha256:"+a.FileSHA256) |
| } |
| } |
| return ev, nil |
| @@ -1461,7 +1480,7 @@ func (ev rollbackOwnership) owns(path, identity string) (bool, error) { |
| case ev.host + ".env": |
| return ev.placed.Env != "" && identity == ev.placed.Env, nil |
| case ev.host + ".config.env": |
| - if ev.configSHA256 == "" { |
| + if len(ev.configSHA256) == 0 { |
| return false, nil |
| } |
| full := filepath.Join(ev.dataDir, path) |
| @@ -1472,8 +1491,13 @@ func (ev rollbackOwnership) owns(path, identity string) (bool, error) { |
| if err != nil { |
| return false, err |
| } |
| - return got == ev.configSHA256, nil |
| - case db, db + ".gen", db + "-wal", db + "-shm": |
| + return containsString(ev.configSHA256, got), nil |
| + case db, db + ".gen": |
| + if ev.installed { |
| + return true, nil |
| + } |
| + return ev.isBuild(path) |
| + case db + "-wal", db + "-shm": |
| return ev.installed, nil |
| case db + ".corrupt", ".activator-" + db + "-litestream": |
| return ev.installed && ev.absentBefore[path], nil |
| @@ -1481,6 +1505,32 @@ func (ev rollbackOwnership) owns(path, identity string) (bool, error) { |
| return false, nil |
| } |
| +// isBuild reports whether path, the database or its marker at the flat |
| +// paths, is this op's build before the record stamped it installed: the |
| +// database's sha256 is the journalled build's, and the marker names the |
| +// op's generation. A link or anything else is not. |
| +func (ev rollbackOwnership) isBuild(path string) (bool, error) { |
| + if ev.builtSHA256 == "" { |
| + return false, nil |
| + } |
| + full := filepath.Join(ev.dataDir, path) |
| + if fi, err := os.Lstat(full); err != nil || !fi.Mode().IsRegular() { |
| + return false, nil |
| + } |
| + if strings.HasSuffix(path, ".gen") { |
| + gen, err := hostbox.ReadGenerationMarker(full) |
| + if err != nil { |
| + return false, nil |
| + } |
| + return gen == ev.generation, nil |
| + } |
| + got, err := fileSHA256(full) |
| + if err != nil { |
| + return false, err |
| + } |
| + return got == ev.builtSHA256, nil |
| +} |
| + |
| // deleteRow deletes the row and its timers rows in one registry transaction |
| // (registry.DeleteIfHoldWithTimers, J12), only while the rollback holds the |
| // row, under the host's hold lock with the record re-read there; the journal |