moves: journal the held render's content hash before it writes (Task 9a fix round 1, I1)
Render ownership was journalled only after RenderHeld returned, so a crash
after config.env was written and before the ack or the rendered record
landed left secret-bearing content no cleaning would take.
- appconfig: RenderHeldBefore, a narrow hook. renderExecConfigBefore calls
it with the sha256 of the rendered bytes once they are known and before
the file is written or found up to date; an error from it writes nothing
and sends no ack. The renderer's atomic write is unchanged, and
renderExecConfig (SyncExec's) passes no hook.
- host: moveCfg.RenderHeld takes the hook; configured passes
installRun.notePreRender, which journals pre_render as JSON {before,
intended}: the file's state before the first render, kept, and the
intended sha256, written ahead of each render's write.
- The target's cleaning counts <host>.config.env as the op's when its
content matches the ack's hash, the rendered record or pre_render's
intended hash; other content is left.
Tests: TestRenderHeldBeforeAnswersTheHashBeforeTheWrite (appconfig),
TestRenderCrashBeforeItsRecordIsParkedByCleaning (the write/ack boundary:
file written, no ack and no rendered record), and
TestFailedRenderAckIsParkedByCleaning on the new record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+297
−70
internal/appconfig/renderheld.go+20 −4internal/appconfig/renderheld_before_test.go+62 −0internal/appconfig/syncexec.go+16 −0internal/host/movescarries_test.go+80 −2internal/host/movesinstall.go+50 −26internal/host/movesinstall_test.go+4 −1internal/host/movesland.go+1 −1internal/host/movesland_test.go+36 −24internal/host/movespass.go+5 −4internal/host/movesrecover_test.go+4 −1internal/host/movesrollback.go+17 −5internal/host/movesrollback_finalfix_test.go+2 −2
diff --git a/internal/appconfig/renderheld.go b/internal/appconfig/renderheld.go| index 1258805b0..2babb92ca 100644 |
| --- a/internal/appconfig/renderheld.go |
| +++ b/internal/appconfig/renderheld.go |
| @@ -52,7 +52,23 @@ func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dat |
| if hostwrite.Enabled(os.Getenv) { |
| return HeldRender{}, ErrHeldClaimWired |
| } |
| - return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil }) |
| + return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil }, nil) |
| +} |
| + |
| +// RenderHeldBefore is RenderHeld with before called once the rendered bytes |
| +// are known and before anything is written: before receives the hex sha256 |
| +// of exactly the bytes the render then places (or finds already in place), |
| +// so a caller can make its ownership of the file durable first (moves Plan 5 |
| +// Task 9a, carry (c)). An error from before ends the render: nothing is |
| +// written and no ack is sent. The write itself is RenderHeld's, unchanged. |
| +func RenderHeldBefore(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, before func(sha256hex string) error) (HeldRender, error) { |
| + if before == nil { |
| + return HeldRender{}, hostwrite.ErrUnavailable |
| + } |
| + if hostwrite.Enabled(os.Getenv) { |
| + return HeldRender{}, ErrHeldClaimWired |
| + } |
| + return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil }, before) |
| } |
| // RenderHeldUnder is RenderHeld for a held row under the host claim (moves |
| @@ -68,10 +84,10 @@ func RenderHeldUnder(ctx context.Context, s release.Store, priv *ecdh.PrivateKey |
| if work == nil { |
| return HeldRender{}, hostwrite.ErrUnavailable |
| } |
| - return renderHeld(ctx, s, priv, dataDir, rt, op, box, work) |
| + return renderHeld(ctx, s, priv, dataDir, rt, op, box, work, nil) |
| } |
| -func renderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error)) (HeldRender, error) { |
| +func renderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error), before func(string) error) (HeldRender, error) { |
| if err := heldRenderable(rt, op, box); err != nil { |
| return HeldRender{}, err |
| } |
| @@ -87,7 +103,7 @@ func renderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dat |
| if w == nil { |
| return HeldRender{}, hostwrite.ErrUnavailable |
| } |
| - r, err := renderExecConfig(ctx, w, rt, m, priv, dataDir, "", v.Overlay) |
| + r, err := renderExecConfigBefore(ctx, w, rt, m, priv, dataDir, "", v.Overlay, before) |
| if err != nil { |
| return HeldRender{}, err |
| } |
diff --git a/internal/appconfig/renderheld_before_test.go b/internal/appconfig/renderheld_before_test.go| new file mode 100644 |
| index 000000000..fd4008713 |
| --- /dev/null |
| +++ b/internal/appconfig/renderheld_before_test.go |
| @@ -0,0 +1,62 @@ |
| +package appconfig |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "testing" |
| +) |
| + |
| +// TestRenderHeldBeforeAnswersTheHashBeforeTheWrite (moves Plan 5 Task 9a, |
| +// fix round 1, I1): the caller learns the sha256 of the exact bytes the |
| +// render is about to place, before anything is written, so it can journal |
| +// its ownership first; the write that follows places those bytes; a refusal |
| +// from the callback writes nothing and sends no ack. |
| +func TestRenderHeldBeforeAnswersTheHashBeforeTheWrite(t *testing.T) { |
| + ctx, s, reg, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + heldFixture(t, ctx, s) |
| + rt := landingRow() |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := ConfigEnvPath(dd, rt) |
| + |
| + refused := errors.New("the journal refused") |
| + if _, err := RenderHeldBefore(ctx, s, priv, dd, rt, heldOp, heldBox, func(string) error { return refused }); !errors.Is(err, refused) { |
| + t.Fatalf("a refusing callback: %v", err) |
| + } |
| + if _, err := os.Stat(path); !os.IsNotExist(err) { |
| + t.Fatalf("a refused render wrote the file: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, ConfigAckKey(heldBox, heldHost)); err == nil { |
| + t.Fatal("a refused render wrote the ack") |
| + } |
| + |
| + var told string |
| + got, err := RenderHeldBefore(ctx, s, priv, dd, rt, heldOp, heldBox, func(sha string) error { |
| + if _, err := os.Stat(path); !os.IsNotExist(err) { |
| + t.Errorf("the file was there before the callback: %v", err) |
| + } |
| + told = sha |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(body) |
| + if want := hex.EncodeToString(sum[:]); told != want || got.SHA256 != want { |
| + t.Fatalf("callback %q, render %q, file %q", told, got.SHA256, want) |
| + } |
| + if a := readAck(t, ctx, s); a.FileSHA256 != told { |
| + t.Fatalf("ack %q, callback %q", a.FileSHA256, told) |
| + } |
| +} |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index cca52b4ea..cc3d95287 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -37,6 +37,8 @@ import ( |
| "bytes" |
| "context" |
| "crypto/ecdh" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "errors" |
| "fmt" |
| "os" |
| @@ -1325,6 +1327,14 @@ type execRender struct { |
| // them. The bare-hostname check stays with each caller, before any path is |
| // derived. |
| func renderExecConfig(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, dataDir, header, instETag string) (execRender, error) { |
| + return renderExecConfigBefore(ctx, work, rt, m, priv, dataDir, header, instETag, nil) |
| +} |
| + |
| +// renderExecConfigBefore is renderExecConfig with before, when not nil, |
| +// called with the hex sha256 of the rendered bytes once they are known and |
| +// before the file is written or found up to date (RenderHeldBefore). An |
| +// error from it ends the render with nothing written. |
| +func renderExecConfigBefore(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, dataDir, header, instETag string, before func(string) error) (execRender, error) { |
| path := ConfigEnvPath(dataDir, rt) |
| // A nested row's current file lives on tenant-owned ground, so its read |
| // goes through instancefs the same way its write below does: a plain |
| @@ -1378,6 +1388,12 @@ func renderExecConfig(ctx context.Context, work hostwrite.Work, rt registry.Rout |
| rendered = append([]byte(header), rendered...) |
| } |
| out := execRender{path: path, rendered: rendered} |
| + if before != nil { |
| + sum := sha256.Sum256(rendered) |
| + if err := before(hex.EncodeToString(sum[:])); err != nil { |
| + return execRender{}, err |
| + } |
| + } |
| if existed && bytes.Equal(cur, rendered) { |
| return out, nil // up to date — stay quiet on the periodic tick |
| } |
diff --git a/internal/host/movescarries_test.go b/internal/host/movescarries_test.go| index 893ee191a..1f7c85167 100644 |
| --- a/internal/host/movescarries_test.go |
| +++ b/internal/host/movescarries_test.go |
| @@ -4,12 +4,14 @@ import ( |
| "bytes" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "os" |
| "path/filepath" |
| "testing" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -149,7 +151,10 @@ func TestFailedRenderAckIsParkedByCleaning(t *testing.T) { |
| 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) { |
| + h.renderHook = func(before func(string) error) (appconfig.HeldRender, error) { |
| + if err := before(hex.EncodeToString(sum[:])); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| if err := os.WriteFile(path, rendered, 0o600); err != nil { |
| return appconfig.HeldRender{}, err |
| } |
| @@ -161,7 +166,8 @@ func TestFailedRenderAckIsParkedByCleaning(t *testing.T) { |
| 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[:]) { |
| + want := "sha256:" + hex.EncodeToString(sum[:]) |
| + if pre := journalledPreRender(t, j); pre.Before != "absent" || pre.Intended != want || j.Steps[landStepRendered] != want { |
| t.Fatalf("the render's journal: pre %q rendered %q", j.Steps[landStepPreRender], j.Steps[landStepRendered]) |
| } |
| if tc.replace != nil { |
| @@ -220,3 +226,75 @@ func TestReleaseTimeArchiveRefusalGoesStuck(t *testing.T) { |
| t.Fatalf("after the retry: %+v", rb) |
| } |
| } |
| + |
| +// journalledPreRender decodes the landing journal's pre_render step. |
| +func journalledPreRender(t *testing.T, j *moves.Journal) preRender { |
| + t.Helper() |
| + var pre preRender |
| + if err := json.Unmarshal([]byte(j.Steps[landStepPreRender]), &pre); err != nil { |
| + t.Fatalf("pre_render %q: %v", j.Steps[landStepPreRender], err) |
| + } |
| + return pre |
| +} |
| + |
| +// TestRenderCrashBeforeItsRecordIsParkedByCleaning (fix round 1, I1): the |
| +// render journals the hash of the bytes it is about to place before it |
| +// writes them, so a crash after the file is written and before either the |
| +// ack or the rendered record lands still leaves the file the op's. The |
| +// cleaning parks it by that intended hash; other content at the path is |
| +// left. |
| +func TestRenderCrashBeforeItsRecordIsParkedByCleaning(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + replace []byte |
| + parked bool |
| + }{ |
| + {"the file the render was placing", nil, true}, |
| + {"other content 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) |
| + crashed := errors.New("the agent died after the write") |
| + h.renderHook = func(before func(string) error) (appconfig.HeldRender, error) { |
| + if err := before(hex.EncodeToString(sum[:])); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| + if err := os.WriteFile(path, rendered, 0o600); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| + return appconfig.HeldRender{}, crashed // no ack, no answer |
| + } |
| + h.land(t) |
| + j := h.landJournal(t) |
| + if pre := journalledPreRender(t, j); pre.Intended != "sha256:"+hex.EncodeToString(sum[:]) || pre.Before != "absent" { |
| + t.Fatalf("pre_render %+v", pre) |
| + } |
| + if _, ok := j.Steps[landStepRendered]; ok { |
| + t.Fatal("fixture: the rendered record landed") |
| + } |
| + 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 b4b7c611c..62f0a9a5c 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -977,15 +977,14 @@ 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) |
| + // The render's ownership is journalled before its write (carry (c), |
| + // fix round 1, I1): the hook runs once the rendered bytes are known and |
| + // before they are placed, and journals their sha256 with the file's |
| + // state before the render (pre_render). The rendered sha256 is journalled |
| + // again 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 |
| + // one this op rendered (rollbackOwnership). |
| + hr, err := cfg.RenderHeld(ctx, ir.s, rt, ir.src.HoldOp, ir.notePreRender) |
| if hr.SHA256 != "" { |
| if jerr := ir.noteRendered(hr.SHA256); jerr != nil { |
| return false, errors.Join(err, jerr) |
| @@ -1078,31 +1077,56 @@ 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 { |
| +// preRender is the landStepPreRender record: <host>.config.env's state |
| +// before the first held render ("absent", "sha256:<hex>" of a regular file, |
| +// or "other"), and the sha256 of the bytes the latest render was about to |
| +// place, journalled before it placed them. |
| +type preRender struct { |
| + Before string `json:"before"` |
| + Intended string `json:"intended"` |
| +} |
| + |
| +// notePreRender is the held render's before-write hook: it journals the |
| +// intended content's sha256 (hex) ahead of the write, keeping the first |
| +// look at the file's earlier state (Lstat'ed, never followed). |
| +func (ir *installRun) notePreRender(sha string) error { |
| + if sha == "" { |
| + return errors.New("moves: the held render names no content") |
| + } |
| j, err := ir.journal() |
| if err != nil { |
| return err |
| } |
| - if _, ok := j.Steps[landStepPreRender]; ok { |
| + var pre preRender |
| + if raw, ok := j.Steps[landStepPreRender]; ok { |
| + if err := json.Unmarshal([]byte(raw), &pre); err != nil { |
| + return fmt.Errorf("the journal's %s step: %w", landStepPreRender, err) |
| + } |
| + } else { |
| + pre.Before = "other" |
| + path := ir.configEnvPath() |
| + fi, err := os.Lstat(path) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + pre.Before = "absent" |
| + case err != nil: |
| + return err |
| + case fi.Mode().IsRegular(): |
| + if pre.Before, err = fileSHA256(path); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + want := "sha256:" + sha |
| + if pre.Intended == want { |
| return nil |
| } |
| - state := "other" |
| - path := ir.configEnvPath() |
| - fi, err := os.Lstat(path) |
| - switch { |
| - case errors.Is(err, os.ErrNotExist): |
| - state = "absent" |
| - case err != nil: |
| + pre.Intended = want |
| + body, err := json.Marshal(pre) |
| + if err != nil { |
| return err |
| - case fi.Mode().IsRegular(): |
| - if state, err = fileSHA256(path); err != nil { |
| - return err |
| - } |
| } |
| - j.Mark(landStepPreRender, state) |
| + j.Mark(landStepPreRender, string(body)) |
| if err := j.Write(ir.cfg().DataDir); err != nil { |
| return err |
| } |
diff --git a/internal/host/movesinstall_test.go b/internal/host/movesinstall_test.go| index 18689869b..126fe1a8b 100644 |
| --- a/internal/host/movesinstall_test.go |
| +++ b/internal/host/movesinstall_test.go |
| @@ -188,8 +188,11 @@ func (h *returnHarness) sourceCfg(t *testing.T) moveCfg { |
| c.Guard = hostguard.Guard{DataDir: h.sdir, LockDir: t.TempDir()} |
| c.Local = park.Local{DataDir: h.sdir, Box: "ie-box"} |
| c.ParkView = recordView(h.s, "ie", roleSource) |
| - c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) { |
| + c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) { |
| h.renders++ |
| + if err := before(h.render.SHA256); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| if op != p1RB || rt.HoldOp != p1RB { |
| t.Errorf("rendered for %q over a row held by %q, want the rollback %s", op, rt.HoldOp, p1RB) |
| } |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index a24fb2aea..207d9654c 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -115,7 +115,7 @@ const ( |
| 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)) |
| + landStepPreRender = "pre_render" // the held render's ownership before its write (JSON preRender, carry (c)) |
| landStepRendered = "rendered" // the held render's file, "sha256:<hex>", journalled even when its ack failed (carry (c)) |
| ) |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index 54f0de4e2..e4c13a06d 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -53,28 +53,31 @@ import ( |
| type landingHarness struct { |
| *cutHarness |
| - tdir string // the target's data directory |
| - leases *fakeParkLeases |
| - lw *movesWorker |
| - lcfg moveCfg |
| - route registry.Route |
| - rows int // rows the source database holds |
| - n uint64 |
| - replaced [][]registry.Timer |
| - repl []string |
| - nextDue []int64 |
| - renders int |
| - render func() (appconfig.HeldRender, error) |
| - release moves.Check |
| - landAt string |
| - landHits []string |
| - landDone bool |
| - parkAt string |
| - parkHits []string |
| - parkDone bool |
| - parkView func(context.Context, hostclaim.Observed) (park.View, error) |
| - landFault func(point string) error |
| - store lease.VersionedStore // when set, the target's versioned store |
| + tdir string // the target's data directory |
| + leases *fakeParkLeases |
| + lw *movesWorker |
| + lcfg moveCfg |
| + route registry.Route |
| + rows int // rows the source database holds |
| + n uint64 |
| + replaced [][]registry.Timer |
| + repl []string |
| + nextDue []int64 |
| + renders int |
| + render func() (appconfig.HeldRender, error) |
| + // renderHook, when set, is the whole held render: it gets the |
| + // before-write callback the installer passes (fix round 1, I1). |
| + renderHook func(before func(string) error) (appconfig.HeldRender, error) |
| + release moves.Check |
| + landAt string |
| + landHits []string |
| + landDone bool |
| + parkAt string |
| + parkHits []string |
| + parkDone bool |
| + parkView func(context.Context, hostclaim.Observed) (park.View, error) |
| + landFault func(point string) error |
| + store lease.VersionedStore // when set, the target's versioned store |
| // probe, when set, is the target's probe wake (Plan 3 Task 6). |
| probe func(context.Context, activator.ProbeConfig) (activator.ProbeResult, error) |
| // probeLive, when set, is the activator's live probe entry. |
| @@ -365,9 +368,18 @@ func (h *landingHarness) landingCfg(t *testing.T) moveCfg { |
| if h.store != nil { |
| c.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.store, nil } |
| } |
| - c.RenderHeld = func(context.Context, release.Store, registry.Route, string) (appconfig.HeldRender, error) { |
| + c.RenderHeld = func(_ context.Context, _ release.Store, _ registry.Route, _ string, before func(string) error) (appconfig.HeldRender, error) { |
| h.renders++ |
| - return h.render() |
| + if h.renderHook != nil { |
| + return h.renderHook(before) |
| + } |
| + hr, err := h.render() |
| + if hr.SHA256 != "" { |
| + if berr := before(hr.SHA256); berr != nil { |
| + return appconfig.HeldRender{}, berr |
| + } |
| + } |
| + return hr, err |
| } |
| c.ReleaseReady = func(context.Context, release.Store, provision.Record, string) moves.Check { return h.release } |
| c.ReplaceTimers = func(_ context.Context, host string, rows []registry.Timer) error { |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index cfe003095..4f24f3df5 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -319,8 +319,8 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| cfg.Guard = hostguard.Guard{DataDir: dataDir} |
| cfg.Local = park.Local{DataDir: dataDir, Box: cfg.Box} |
| cfg.OwnLease = hostbox.RenewingLeases{Region: hib.Region}.Acquire |
| - cfg.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) { |
| - return appconfig.RenderHeld(ctx, s, cfg.SecretsPriv, dataDir, rt, op, cfg.Box) |
| + cfg.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) { |
| + return appconfig.RenderHeldBefore(ctx, s, cfg.SecretsPriv, dataDir, rt, op, cfg.Box, before) |
| } |
| cfg.RemoveVerifyEntry = dbverify.RemoveHost |
| cfg.WriteGen = hostbox.WriteGenerationMarker |
| @@ -569,7 +569,8 @@ type moveCfg struct { |
| // leaves every arm unarmed there. OwnLease |
| // takes the location's lease in the own bucket for the build |
| // (hostbox.RenewingLeases over lease.Acquire). RenderHeld is |
| - // appconfig.RenderHeld for the held row; ReplaceTimers is the |
| + // appconfig.RenderHeldBefore for the held row (before journals the |
| + // render's ownership ahead of its write, carry (c)); ReplaceTimers is the |
| // registry's through schedule.SelectedTimers with a held-timers Work |
| // (heldTimersReplacer); SetReplication and SetNextDue are the |
| // registry's; RemoveVerifyEntry is |
| @@ -589,7 +590,7 @@ type moveCfg struct { |
| ParkRunHeld func(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error |
| ParkLocalAuthorize func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error |
| OwnLease func(ctx context.Context, bucket, location string) (park.HeldLease, error) |
| - RenderHeld func(ctx context.Context, s release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) |
| + RenderHeld func(ctx context.Context, s release.Store, rt registry.Route, op string, before func(sha256hex string) error) (appconfig.HeldRender, error) |
| ReplaceTimers func(ctx context.Context, host string, rows []registry.Timer) error |
| SetReplication func(host, class string) error |
| SetNextDue func(host string, due int64) error |
diff --git a/internal/host/movesrecover_test.go b/internal/host/movesrecover_test.go| index 8fa74c917..e83220e4a 100644 |
| --- a/internal/host/movesrecover_test.go |
| +++ b/internal/host/movesrecover_test.go |
| @@ -117,8 +117,11 @@ func (h *recoverHarness) sourceCfg(t *testing.T) moveCfg { |
| c.Guard = hostguard.Guard{DataDir: h.sdir, LockDir: h.lockDir} |
| c.Local = park.Local{DataDir: h.sdir, Box: "ie-box"} |
| c.ParkView = recordView(h.s, "ie", roleSource) |
| - c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) { |
| + c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) { |
| h.srcRends++ |
| + if err := before(h.srcRender.SHA256); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| if rb := h.read(t, p1Host).Move.Rollback; rb == nil || op != rb.ID || rt.HoldOp != rb.ID { |
| t.Errorf("rendered for %q over a row held by %q, want the rollback", op, rt.HoldOp) |
| } |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index f1a2c46e3..2adbbae67 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -1415,11 +1415,12 @@ func (r *rollbackRun) parkLeftovers(ctx context.Context, j *moves.Journal, mark |
| // 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 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 rendered config, <host>.config.env: the file's sha256 is 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: pre_render's |
| +// intended hash, journalled before the render wrote, or rendered, |
| +// journalled as soon as the render answered (carry (c); fix round 1, |
| +// I1). 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 |
| @@ -1461,6 +1462,17 @@ func (r *rollbackRun) ownership(ctx context.Context, j *moves.Journal) (rollback |
| if rendered := j.Steps[landStepRendered]; strings.HasPrefix(rendered, "sha256:") { |
| ev.configSHA256 = append(ev.configSHA256, rendered) |
| } |
| + if raw, ok := j.Steps[landStepPreRender]; ok { |
| + // The hash the render journalled before it wrote (fix round 1, I1): |
| + // a crash after the write and before either the ack or the |
| + // rendered record still leaves the file the op's by content. |
| + var pre preRender |
| + if err := json.Unmarshal([]byte(raw), &pre); err != nil { |
| + log.Printf("moves: %s: the journal's %s step is unreadable (%v); it vouches for no rendered config", host, landStepPreRender, err) |
| + } else if strings.HasPrefix(pre.Intended, "sha256:") { |
| + ev.configSHA256 = append(ev.configSHA256, pre.Intended) |
| + } |
| + } |
| if raw, ok := j.Steps[landStepPreInstall]; ok { |
| var pre preInstall |
| if err := json.Unmarshal([]byte(raw), &pre); err != nil { |
diff --git a/internal/host/movesrollback_finalfix_test.go b/internal/host/movesrollback_finalfix_test.go| index d227e650d..db865eb91 100644 |
| --- a/internal/host/movesrollback_finalfix_test.go |
| +++ b/internal/host/movesrollback_finalfix_test.go |
| @@ -354,8 +354,8 @@ func (p *policyProbe) wrap(c *moveCfg) { |
| p.check(movesActionHeldTimers, hostwrite.Identity{}) |
| return timers(ctx, host, rows) |
| } |
| - c.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string) (appconfig.HeldRender, error) { |
| - hr, err := render(ctx, s, rt, op) |
| + c.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) { |
| + hr, err := render(ctx, s, rt, op, before) |
| if err == nil { |
| p.check(movesActionHeldConfig, HeldConfigIdentity(hr.BundleETags, hr.OverlayETag)) |
| } |