host: a repair across a configuration change replaces the claim's install receipt in one CAS before admission, so EnsureLocal, VerifyReceipt and the post-admission check compare one digest (final review A3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+249
−12
internal/host/relocationassembly.go+1 −0internal/host/relocationeffects.go+28 −1internal/host/relocationeffects_test.go+1 −0internal/host/relocationinstall_linux_test.go+110 −1internal/hostclaim/relocation.go+51 −9internal/hostclaim/relocation_test.go+52 −0internal/hostrelocate/participant.go+6 −1
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go| index 700acd375..b1d48b400 100644 |
| --- a/internal/host/relocationassembly.go |
| +++ b/internal/host/relocationassembly.go |
| @@ -101,6 +101,7 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func( |
| receipts := RelocationReleaseReceipts{Store: a.Claims, Directory: cfg.Directory} |
| paths := relocationHostPaths(cfg.DataDir, cfg.Registry) |
| return &RelocationEffects{ |
| + Claims: a.Claims, |
| Participation: ack, |
| Identity: func(ctx context.Context) (RelocationIdentity, error) { return a.localStopIdentity(ctx) }, |
| Producers: func(ctx context.Context, o hostclaim.Observed) (StopDrainer, error) { |
diff --git a/internal/host/relocationeffects.go b/internal/host/relocationeffects.go| index b8f32a627..7e105c56e 100644 |
| --- a/internal/host/relocationeffects.go |
| +++ b/internal/host/relocationeffects.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| execctl "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // RelocationIdentity is this box's verified actor and stop identity, as |
| @@ -62,6 +63,9 @@ type ReleasePositions interface { |
| // operation that lists this box, whichever of the claim's participation |
| // loop, WorkSource.Prepare or this pass asks first. |
| type RelocationEffects struct { |
| + // Claims is the claim store: EnsureLocal replaces the journaled |
| + // receipt there after a repair (final review A3). |
| + Claims release.Store |
| // Participation is main's participation owner for the observed |
| // operation (productionClaimLocal.Acknowledged): it opens or reuses |
| // the box's one hostexec controller for {host, op} and returns its |
| @@ -291,8 +295,18 @@ func (e *RelocationEffects) InstallLocal(ctx context.Context, obs hostclaim.Obse |
| // the same Install provider under that stamp. A retired stamp is never |
| // reactivated: Install refuses it, and the probe deadline replaces the |
| // target. An opened P is never reinstalled. |
| +// |
| +// A repair re-renders the held configuration, so its journal, and the |
| +// digest the receipt carries, differ from the journaled receipt's when |
| +// the configuration changed since (final review A3). The receipt is then |
| +// replaced in the claim, in one CAS on a fresh read of the same |
| +// installation (hostclaim.ReplaceLocalInstall), before any admission, so |
| +// this verification, VerifyReceipt and relocationAfterAdmission all |
| +// compare the one digest the box holds with the one the claim journals. A |
| +// CAS that fails leaves the old receipt, which the next pass finds not |
| +// verifiable and repairs again to the same journal. |
| func (e *RelocationEffects) EnsureLocal(ctx context.Context, obs hostclaim.Observed) error { |
| - if e.VerifyLocal == nil || e.Install == nil || obs.Claim == nil { |
| + if e.VerifyLocal == nil || e.Install == nil || e.Claims == nil || obs.Claim == nil { |
| return ErrClaimStopUnavailable |
| } |
| c := obs.Claim |
| @@ -313,6 +327,19 @@ func (e *RelocationEffects) EnsureLocal(ctx context.Context, obs hostclaim.Obser |
| if got.Box != l.Box || got.Enrollment != l.Enrollment || got.InstallStamp != l.InstallStamp { |
| return fmt.Errorf("%w: the restored install is not the receipt's", ErrClaimStopReceipt) |
| } |
| + if got != l { |
| + fresh, err := hostclaim.Read(ctx, e.Claims, obs.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Absent || fresh.Claim.Op.ID != c.Op.ID || fresh.Claim.Op.Journal["prepared"] != c.Op.Journal["prepared"] { |
| + return fmt.Errorf("%w: the installation changed under the repair", hostrelocate.ErrRefused) |
| + } |
| + if obs, err = hostclaim.Update(ctx, e.Claims, fresh, hostclaim.ReplaceLocalInstall(c.Op.ID, l, got)); err != nil { |
| + return err |
| + } |
| + l = got |
| + } |
| ok, err := e.VerifyLocal(ctx, obs, l) |
| if err == nil && !ok { |
| err = fmt.Errorf("%w: the restored install did not verify", ErrClaimStopReceipt) |
diff --git a/internal/host/relocationeffects_test.go b/internal/host/relocationeffects_test.go| index 0562af2e4..c82e829dc 100644 |
| --- a/internal/host/relocationeffects_test.go |
| +++ b/internal/host/relocationeffects_test.go |
| @@ -89,6 +89,7 @@ func effectsFixture(t *testing.T) (*RelocationEffects, hostclaim.Observed, *[]st |
| seen := &StopRequest{} |
| exp := time.Date(2026, 10, 7, 10, 0, 0, 0, time.UTC) |
| e := &RelocationEffects{ |
| + Claims: release.NewDirStore(t.TempDir()), |
| Participation: func(context.Context, hostclaim.Observed) (execctl.State, error) { |
| return execctl.State{Binding: execctl.Binding{Host: "shop.example", Incarnation: "inc", Op: "rel-1", Enrollment: "enr", BootID: "boot", ControllerID: "c9"}, LeaseETag: "lease-1", Expires: exp}, nil |
| }, |
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go| index d3901076c..480a545a0 100644 |
| --- a/internal/host/relocationinstall_linux_test.go |
| +++ b/internal/host/relocationinstall_linux_test.go |
| @@ -238,7 +238,7 @@ func (f *installFixture) paths() relocationPaths { |
| } |
| func (f *installFixture) effects() *RelocationEffects { |
| - return &RelocationEffects{Install: f.a.relocationInstall(f.cfg), VerifyLocal: f.a.relocationVerifyLocal(f.cfg), Receipt: f.a.relocationReceipt(f.cfg)} |
| + return &RelocationEffects{Claims: f.claims, Install: f.a.relocationInstall(f.cfg), VerifyLocal: f.a.relocationVerifyLocal(f.cfg), Receipt: f.a.relocationReceipt(f.cfg)} |
| } |
| // Findings 3 and 5, Review Focus 2: from an empty target, the install mints |
| @@ -686,6 +686,115 @@ func TestRelocationEnsureLocalRefusesARetiredStamp(t *testing.T) { |
| } |
| } |
| +// Stage B final review A3 (Astra 3): a repair after the held configuration |
| +// changed re-renders it, so the repaired journal's digest is not the |
| +// journaled receipt's. EnsureLocal replaces the receipt in one CAS on a |
| +// fresh read before any admission, so the pre-admission verification, the |
| +// receipt check after P opens (VerifyReceipt) and the post-admission check |
| +// (relocationAfterAdmission) all compare the digest the box holds with the |
| +// one the claim journals. A CAS that fails once leaves the old receipt, and |
| +// the next pass repairs to the same journal and records it. |
| +func TestRelocationEnsureLocalRepairsAcrossAConfigurationChange(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + ctx := t.Context() |
| + l, err := f.install() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + receipt, _ := json.Marshal(l) |
| + if f.obs, err = hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { |
| + c.Op.Phase = hostclaim.PhaseRelocatingProbing |
| + c.Op.Journal["local_install"] = string(receipt) |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e := f.effects() |
| + file := filepath.Join(f.paths().FilesDir, "public", "index.html") |
| + journaled := func() hostclaim.LocalInstall { |
| + t.Helper() |
| + var got hostclaim.LocalInstall |
| + if err := json.Unmarshal([]byte(f.read().Claim.Op.Journal["local_install"]), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + return got |
| + } |
| + onDisk := func() hostclaim.LocalInstall { |
| + t.Helper() |
| + got, ok, err := f.a.relocationReceipt(f.cfg)(ctx, f.read()) |
| + if err != nil || !ok { |
| + t.Fatalf("the durable receipt: %v %v", ok, err) |
| + } |
| + return got |
| + } |
| + agree := func(want string) { |
| + t.Helper() |
| + if j, d := journaled(), onDisk(); j != d { |
| + t.Fatalf("%s: the claim journals %+v, the box holds %+v", want, j, d) |
| + } |
| + if ok, err := e.VerifyReceipt(ctx, f.read()); err != nil || !ok { |
| + t.Fatalf("%s: VerifyReceipt %v %v", want, ok, err) |
| + } |
| + prior := f.read() |
| + admitted := f.read() |
| + admitted.Claim.Holder.WriterBox, admitted.Claim.Holder.Generation = "dst", admitted.Claim.Op.Journal["prepared"] |
| + if err := relocationAfterAdmission(f.r.DataDir, "dst")(ctx, prior, admitted); err != nil { |
| + t.Fatalf("%s: after admission: %v", want, err) |
| + } |
| + } |
| + agree("after the install") |
| + |
| + // The configuration changes, then a file is lost: the repair renders |
| + // the new configuration and records the new receipt. |
| + f.configs = map[string]string{"CARLOS_FEATURE": "2"} |
| + if err := os.Remove(file); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := f.read().Claim.Gen |
| + if err := e.EnsureLocal(ctx, f.read()); err != nil { |
| + t.Fatalf("the repair across a configuration change: %v", err) |
| + } |
| + if got, err := os.ReadFile(file); err != nil || string(got) != "<h1>shop</h1>" { |
| + t.Fatalf("not restored: %q %v", got, err) |
| + } |
| + repaired := journaled() |
| + if repaired == l || repaired.Box != l.Box || repaired.Enrollment != l.Enrollment || repaired.InstallStamp != l.InstallStamp || f.read().Claim.Gen == before { |
| + t.Fatalf("the receipt was not replaced for the same installation: %+v (was %+v)", repaired, l) |
| + } |
| + agree("after the repair") |
| + // Whole now: nothing is reinstalled or rewritten. |
| + gen := f.read().Claim.Gen |
| + if err := e.EnsureLocal(ctx, f.read()); err != nil || f.read().Claim.Gen != gen { |
| + t.Fatalf("a whole install was repaired again: %v gen %d -> %d", err, gen, f.read().Claim.Gen) |
| + } |
| + |
| + // The configuration changes again and the CAS fails once: the old |
| + // receipt stays in the claim, the box holds the new journal, and the |
| + // next pass repairs to the same journal and records it. |
| + f.configs = map[string]string{"CARLOS_FEATURE": "3"} |
| + if err := os.Remove(file); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.Claims = errPutStore{Store: f.claims} |
| + if err := e.EnsureLocal(ctx, f.read()); err == nil { |
| + t.Fatal("a failed receipt CAS was reported as a repair") |
| + } |
| + if j, d := journaled(), onDisk(); j != repaired || d == repaired { |
| + t.Fatalf("after the failed CAS: the claim journals %+v, the box holds %+v", j, d) |
| + } |
| + if ok, err := e.VerifyReceipt(ctx, f.read()); err != nil || ok { |
| + t.Fatalf("the old receipt verified against the new journal: %v %v", ok, err) |
| + } |
| + e.Claims = f.claims |
| + if err := e.EnsureLocal(ctx, f.read()); err != nil { |
| + t.Fatalf("the pass after the failed CAS: %v", err) |
| + } |
| + if journaled() == repaired { |
| + t.Fatal("the receipt was not replaced on the next pass") |
| + } |
| + agree("after the retried repair") |
| +} |
| + |
| func TestRelocationVerifyLocalReadsTheInstallBack(t *testing.T) { |
| f := newInstallFixture(t, nil) |
| ctx := t.Context() |
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go| index 9d9d71348..ffe02e47f 100644 |
| --- a/internal/hostclaim/relocation.go |
| +++ b/internal/hostclaim/relocation.go |
| @@ -895,17 +895,9 @@ func RecordLocalInstall(opID string, l LocalInstall) func(*Claim) error { |
| if err := requireRelocation(c, opID, PhaseInstalled); err != nil { |
| return err |
| } |
| - stamp, err := journalUint(c.Op, "install_stamp") |
| - if err != nil { |
| + if err := requireReceiptOfInstallation(c, l); err != nil { |
| return err |
| } |
| - if l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] || |
| - l.InstallStamp == 0 || l.InstallStamp != stamp || !isHex64(l.JournalSHA256) { |
| - return fmt.Errorf("%w: receipt is not this installation's", ErrRelocation) |
| - } |
| - if preparedOpened(c) { |
| - return fmt.Errorf("%w: P was admitted; the local install is never remade", ErrRelocation) |
| - } |
| var old LocalInstall |
| if readJournal(c.Op, "local_install", &old) { |
| if old == l { |
| @@ -917,6 +909,56 @@ func RecordLocalInstall(opID string, l LocalInstall) func(*Claim) error { |
| } |
| } |
| +// ReplaceLocalInstall replaces the journaled receipt old with l after a |
| +// repair of the local install under the same installation (stage B final |
| +// review A3): the repair re-renders the held configuration, which may |
| +// have changed since old was journaled, so the box's durable journal and |
| +// its digest are new while the box, enrollment and install stamp are the |
| +// same. Only before P is admitted (an opened P is never reinstalled), only |
| +// over exactly old, and only for the same installation: the three readers |
| +// of the receipt (the pre-admission verification, the post-admission |
| +// check and the receipt check after P opens) then compare one digest. |
| +func ReplaceLocalInstall(opID string, old, l LocalInstall) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if err := requireRelocation(c, opID, PhaseInstalled, PhaseRelocatingProbing); err != nil { |
| + return err |
| + } |
| + if err := requireReceiptOfInstallation(c, l); err != nil { |
| + return err |
| + } |
| + if l.Box != old.Box || l.Enrollment != old.Enrollment || l.InstallStamp != old.InstallStamp { |
| + return fmt.Errorf("%w: the repaired receipt is not the journaled installation's", ErrRelocation) |
| + } |
| + var cur LocalInstall |
| + if !readJournal(c.Op, "local_install", &cur) || cur != old { |
| + return fmt.Errorf("%w: the journaled receipt is not the one being replaced", ErrRelocation) |
| + } |
| + if cur == l { |
| + return ErrAlreadyRecorded |
| + } |
| + return journalJSON(c, "local_install", l) |
| + } |
| +} |
| + |
| +// requireReceiptOfInstallation is the receipt's binding to the operation's |
| +// installation (RecordLocalInstall, ReplaceLocalInstall): the executor, |
| +// its enrollment, the journaled install stamp, a well-formed digest, and |
| +// P not yet admitted. |
| +func requireReceiptOfInstallation(c *Claim, l LocalInstall) error { |
| + stamp, err := journalUint(c.Op, "install_stamp") |
| + if err != nil { |
| + return err |
| + } |
| + if l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] || |
| + l.InstallStamp == 0 || l.InstallStamp != stamp || !isHex64(l.JournalSHA256) { |
| + return fmt.Errorf("%w: receipt is not this installation's", ErrRelocation) |
| + } |
| + if preparedOpened(c) { |
| + return fmt.Errorf("%w: P was admitted; the local install is never remade", ErrRelocation) |
| + } |
| + return nil |
| +} |
| + |
| // ToProbing enters probing, which requires a local install receipt for |
| // exactly op.exec, its enrollment and the journaled install_stamp. now |
| // starts the probe deadline (§5.8: 10 minutes). |
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go| index 2359995d4..0aa35ddc8 100644 |
| --- a/internal/hostclaim/relocation_test.go |
| +++ b/internal/hostclaim/relocation_test.go |
| @@ -1333,3 +1333,55 @@ func TestRelocationNameTargetInStartingIsAFailoversAlone(t *testing.T) { |
| t.Fatalf("a drain named a target in starting: %v", err) |
| } |
| } |
| + |
| +// Stage B final review A3: a repair of the local install replaces the |
| +// journaled receipt with the repaired one, in installed and in probing |
| +// before P is admitted, only over exactly the journaled receipt and only |
| +// for the same box, enrollment and install stamp; never after P opens. |
| +func TestReplaceLocalInstallOnlyOverTheJournaledReceiptBeforePOpens(t *testing.T) { |
| + ctx := context.Background() |
| + for _, phase := range []string{PhaseInstalled, PhaseRelocatingProbing} { |
| + s, o := relAtPhase(t, phase) |
| + old := localInstall(o.Claim) |
| + if phase == PhaseInstalled { |
| + o = step(t, s, o, RecordLocalInstall("rel-1", old)) |
| + } |
| + repaired := old |
| + repaired.JournalSHA256 = strings.Repeat("c", 64) |
| + other := repaired |
| + other.JournalSHA256 = strings.Repeat("d", 64) |
| + if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", other, repaired)); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("%s: replaced over a receipt that is not journaled: %v", phase, err) |
| + } |
| + for name, bad := range map[string]func(*LocalInstall){ |
| + "another box": func(l *LocalInstall) { l.Box = relSource }, |
| + "another enrollment": func(l *LocalInstall) { l.Enrollment = relEnrollment(relSource) }, |
| + "another stamp": func(l *LocalInstall) { l.InstallStamp++ }, |
| + "a malformed digest": func(l *LocalInstall) { l.JournalSHA256 = "x" }, |
| + } { |
| + l := repaired |
| + bad(&l) |
| + if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, l)); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("%s: %s: %v", phase, name, err) |
| + } |
| + } |
| + if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, old)); !errors.Is(err, ErrAlreadyRecorded) { |
| + t.Fatalf("%s: the same receipt: %v", phase, err) |
| + } |
| + o = step(t, s, o, ReplaceLocalInstall("rel-1", old, repaired)) |
| + var got LocalInstall |
| + if !readJournal(o.Claim.Op, "local_install", &got) || got != repaired || o.Claim.Op.Phase != phase { |
| + t.Fatalf("%s: journaled %+v in %s", phase, got, o.Claim.Op.Phase) |
| + } |
| + if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, other)); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("%s: replaced over the superseded receipt: %v", phase, err) |
| + } |
| + } |
| + s, o := relAtPhase(t, "probing+P") |
| + old := localInstall(o.Claim) |
| + repaired := old |
| + repaired.JournalSHA256 = strings.Repeat("c", 64) |
| + if _, err := Update(ctx, s, o, ReplaceLocalInstall("rel-1", old, repaired)); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("replaced after P opened: %v", err) |
| + } |
| +} |
diff --git a/internal/hostrelocate/participant.go b/internal/hostrelocate/participant.go| index 6e1504a36..09fd47149 100644 |
| --- a/internal/hostrelocate/participant.go |
| +++ b/internal/hostrelocate/participant.go |
| @@ -52,7 +52,12 @@ type Effects interface { |
| // complete on this box and, when it is not (a lost lease's scoped |
| // cleanup retires an unopened P's local install), restores it under |
| // install_stamp from P's files manifest. It is never called once P is |
| - // opened: an opened P is never reinstalled (rev 15, round 14). |
| + // opened: an opened P is never reinstalled (rev 15, round 14). A |
| + // repair may write the claim: the typed inventory, and the receipt |
| + // itself when the repaired journal's digest differs (a configuration |
| + // changed since it was journaled; final review A3), replaced in one |
| + // CAS before any admission. The caller re-reads the same installation |
| + // after it. |
| EnsureLocal(context.Context, hostclaim.Observed) error |
| // VerifyReceipt is called before every admission after P is opened: |
| // it reports whether this box's durable local install receipt is |