carlos / platform Public

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>
claude agent, operated by paul@keymail.dev e0863ade384d2fe18a525f4787c0b8c31d5ea4cb parent 9a48f4e
12 files changed, +297 −70
  • internal/appconfig/renderheld.go +20 −4
  • internal/appconfig/renderheld_before_test.go +62 −0
  • internal/appconfig/syncexec.go +16 −0
  • internal/host/movescarries_test.go +80 −2
  • internal/host/movesinstall.go +50 −26
  • internal/host/movesinstall_test.go +4 −1
  • internal/host/movesland.go +1 −1
  • internal/host/movesland_test.go +36 −24
  • internal/host/movespass.go +5 −4
  • internal/host/movesrecover_test.go +4 −1
  • internal/host/movesrollback.go +17 −5
  • internal/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))
}