WIP claim Plan 14 batch 2 complete (not for merge yet): B3, J1, C2, C3, C4 (root fence and installation authority, typed journalled source-recovery rebind, cold-handover process-boundary matrix, authenticated fence view)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 files changed,
+1683
−63
internal/host/claimconstruction.go+1 −1internal/host/claimhandover_crossbox.go+1 −0internal/host/claimhandover_recovery.go+3 −0internal/host/claimhandover_recovery_test.go+27 −2internal/host/claimrecovery_remote.go+118 −0internal/host/claimretire_transport.go+5 −1internal/host/plan14_recovery_source_test.go+103 −47internal/host/rootinstallation_test.go+46 −0internal/host/rootinventory.go+16 −0internal/host/rootinventory_timer.go+33 −0internal/host/rootinventory_timer_test.go+95 −0internal/host/rootrecovery_process_test.go+116 −0internal/host/rootrecovery_rebind.go+197 −0internal/host/rootrecovery_rebind_test.go+146 −0internal/hostbox/coldhandover.go+19 −1internal/hostbox/plan14_cold_process_test.go+306 −0internal/hostguard/guard.go+3 −0internal/hostguard/plan14_public_matrix_test.go+242 −0internal/hostguard/plan14_rebind_test.go+105 −0internal/hostguard/record.go+1 −0internal/hostguard/root.go+3 −1internal/hostguard/rootrebind.go+76 −0internal/instancectl/server.go+1 −0internal/provision/plan9_b8_census_test.go+4 −1internal/provision/testdata/plan14-root-reads.tsv+16 −9
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go| index ee53d91ea..8d0e5cac8 100644 |
| --- a/internal/host/claimconstruction.go |
| +++ b/internal/host/claimconstruction.go |
| @@ -306,7 +306,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| wake.AfterAdmission = coldAdmissionHook(guard, owners.Identity, controlsock.Path) |
| wake.RetireArtifacts = func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error { return hostbox.ErrChanged } |
| if movesC1 { |
| - handover := claimCrossBoxHandover{Guard: guard, Evidence: store, Box: actor.Box, Inventory: inventory, Identity: owners.Identity, Registry: r.Registry, Launches: launches} |
| + handover := claimCrossBoxHandover{RecoveryRebind: recoveryRebindClient(controlsock.Path), Guard: guard, Evidence: store, Box: actor.Box, Inventory: inventory, Identity: owners.Identity, Registry: r.Registry, Launches: launches} |
| if owners.RetirementAuthority != nil { |
| handover.PublicKey = owners.RetirementAuthority.Directory.PublicKey |
| handover.Directory = owners.RetirementAuthority.Directory |
diff --git a/internal/host/claimhandover_crossbox.go b/internal/host/claimhandover_crossbox.go| index 01ddd6a8a..5b608457b 100644 |
| --- a/internal/host/claimhandover_crossbox.go |
| +++ b/internal/host/claimhandover_crossbox.go |
| @@ -42,6 +42,7 @@ func claimAdmissionHandover(box string, local, crossBox func(context.Context, ho |
| // installation stamp (item 4), so the local record this box initialized for |
| // the landing installation is the one the session runs under. |
| type claimCrossBoxHandover struct { |
| + RecoveryRebind func(context.Context, recoveryRebindRequest) error |
| Launches *hostbox.LaunchJournal |
| afterRecoveryRows func() error |
| PublicKey ed25519.PublicKey |
diff --git a/internal/host/claimhandover_recovery.go b/internal/host/claimhandover_recovery.go| index 38a190dbb..fd3a4b8c9 100644 |
| --- a/internal/host/claimhandover_recovery.go |
| +++ b/internal/host/claimhandover_recovery.go |
| @@ -145,6 +145,9 @@ func (h claimCrossBoxHandover) afterRecovery(ctx context.Context, prior, admitte |
| if h.Registry == nil { |
| return hostbox.ErrTypedInventory |
| } |
| + if h.Inventory.Remote() || h.Guard.FenceView != nil { |
| + return h.installRecoveryRemote(ctx, prior, admitted) |
| + } |
| // Activate retires the prior local stamp through the descriptor-based guard. |
| // The restore hook subsequently retires the source database and marker. |
| if err = h.Guard.Activate(ctx, admitted.Host, h.Box, nil); err != nil { |
diff --git a/internal/host/claimhandover_recovery_test.go b/internal/host/claimhandover_recovery_test.go| index 549ea79b6..f1959faca 100644 |
| --- a/internal/host/claimhandover_recovery_test.go |
| +++ b/internal/host/claimhandover_recovery_test.go |
| @@ -18,6 +18,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/hostbox" |
| @@ -136,7 +137,31 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| if err = inventory.WriteStopInventory(ctx, in); err != nil { |
| t.Fatal(err) |
| } |
| - root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Coordinator: f.root(recoveryStopDrain{}), Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, Roots: map[string]string{"data": f.guard.DataDir}, Reconcile: hostbox.ReconcileAdapter{Box: "box-1", Enrollment: "enrollment-1", Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retire")}}} |
| + |
| + rootGuard := f.guard |
| + rootGuard.Root = hostguard.NewRootAuthorityWithInventory(&rollbackDisk{dir: t.TempDir()}, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| + _, revision, err := inventory.Snapshot(ctx, host, c) |
| + return revision, err |
| + }) |
| + held := prior.Claim.Holder |
| + if err := rootGuard.EstablishRoot(ctx, hostguard.RootInstallation{Host: prior.Host, Incarnation: held.Incarnation, Stamp: held.Stamp, Account: held.Account, App: held.App, Record: held.Key, Box: "box-1", Enrollment: "enrollment-1", Migration: 1, InventoryRevision: 1}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + observedFence := prior |
| + observedFence.ETag = receipt.Target.ClaimETag |
| + rootGuard.Claims = rootViewClaimReader{obs: observedFence} |
| + if _, err := rootGuard.BeginFence(ctx, receipt.Target); err != nil { |
| + t.Fatal("root fence setup", err) |
| + } |
| + if err := rootGuard.CompleteFence(ctx, receipt.Target, receipt.Bytes, receipt.EvidenceAt, func(_ context.Context, target hostguard.FenceTarget, body []byte, at time.Time) error { |
| + return verifyRetirementReceipt(ctx, hostguard.FenceReceipt{Target: target, Bytes: body, EvidenceAt: at}) |
| + }); err != nil { |
| + t.Fatal("root receipt setup", err) |
| + } |
| + rootGuard.Claims = f.guard.Claims |
| + coordinator := f.root(recoveryStopDrain{}) |
| + coordinator.Guard = rootGuard |
| + root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, Roots: map[string]string{"data": f.guard.DataDir}, Reconcile: hostbox.ReconcileAdapter{Box: "box-1", Enrollment: "enrollment-1", Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retire")}}} |
| socket := startRetirementRecoveryTransport(t, root) |
| unauthenticated := root |
| unauthenticated.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("unknown requester") } |
| @@ -199,7 +224,7 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| if body, err := os.ReadFile(marker); err != nil || strings.TrimSpace(string(body)) != next.Claim.Holder.Generation { |
| t.Fatalf("next wake generation: %q %v", body, err) |
| } |
| - replayed, err := f.guard.FenceReceiptFor(ctx, receipt.Target) |
| + replayed, err := rootGuard.FenceReceiptFor(ctx, receipt.Target) |
| if err != nil || !bytes.Equal(replayed.Bytes, receipt.Bytes) || !replayed.EvidenceAt.Equal(receipt.EvidenceAt) || replayed.Target != receipt.Target { |
| t.Fatalf("handover rewrote predecessor evidence: %+v %v", replayed, err) |
| } |
diff --git a/internal/host/claimrecovery_remote.go b/internal/host/claimrecovery_remote.go| new file mode 100644 |
| index 000000000..9f01cc353 |
| --- /dev/null |
| +++ b/internal/host/claimrecovery_remote.go |
| @@ -0,0 +1,118 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "reflect" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +func (h claimCrossBoxHandover) installRecoveryRemote(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| + if h.RecoveryRebind == nil || h.Inventory == nil || h.Guard.FenceView == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + actor, err := h.Identity.Verify(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + stamp, err := strconv.ParseUint(prior.Claim.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| + if err != nil || stamp == 0 { |
| + return hostbox.ErrChanged |
| + } |
| + next := admitted.Claim.Holder |
| + captured := hostguard.Captured{Incarnation: next.Incarnation, Stamp: stamp} |
| + successor := hostguard.Captured{Incarnation: next.Incarnation, Stamp: next.Stamp} |
| + for attempt := 0; attempt < 3; attempt++ { |
| + source, revision, err := h.Inventory.Snapshot(ctx, prior.Host, captured) |
| + if err != nil { |
| + return err |
| + } |
| + var q recoveryRebindRequest |
| + err = h.Guard.WithLocal(ctx, prior.Host, func() error { |
| + fresh, err := hostclaim.Read(ctx, h.Evidence, prior.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.ETag != admitted.ETag || !hostclaim.SameWriterIdentity(fresh, admitted) { |
| + return hostbox.ErrChanged |
| + } |
| + view, err := h.Guard.FenceView(ctx, prior.Host, captured) |
| + if err != nil { |
| + return err |
| + } |
| + if view.Installation.Stamp != stamp && view.Installation.Stamp != next.Stamp || view.Installation.Stamp == stamp && view.Installation.InventoryRevision != revision { |
| + return hostbox.ErrChanged |
| + } |
| + _, etag, err := rootClaimRecord(ctx, h.Evidence, fresh.Claim, h.Guard.DataDir) |
| + if err != nil { |
| + return err |
| + } |
| + q = recoveryRebindRequest{V: 1, Prior: captured, Revision: revision, RecordKey: next.Key, RecordETag: etag, Next: hostbox.ColdSuccessor{Host: admitted.Host, Incarnation: next.Incarnation, Stamp: next.Stamp, Session: next.Session, Generation: next.Generation, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: fmt.Sprint(actor.Revision), ETag: admitted.ETag, Op: admitted.Claim.Op.ID, Phase: admitted.Claim.Op.Phase}} |
| + return nil |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if err = h.RecoveryRebind(ctx, q); err != nil { |
| + return err |
| + } |
| + if err = h.Guard.Activate(ctx, admitted.Host, h.Box, nil); err != nil { |
| + return err |
| + } |
| + current, rev, err := h.Inventory.Snapshot(ctx, prior.Host, successor) |
| + if err != nil { |
| + return err |
| + } |
| + err = h.Guard.With(ctx, prior.Host, successor, func(c *hostclaim.Claim) error { |
| + if !hostclaim.SameWriterIdentity(admitted, hostclaim.Observed{Host: admitted.Host, Claim: c}) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + }, func() error { |
| + fresh, err := hostclaim.Read(ctx, h.Evidence, prior.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.ETag != admitted.ETag || !hostclaim.SameWriterIdentity(fresh, admitted) { |
| + return hostbox.ErrChanged |
| + } |
| + view, err := h.Guard.FenceView(ctx, prior.Host, successor) |
| + if err != nil { |
| + return err |
| + } |
| + if view.Installation.Stamp != next.Stamp || view.Installation.Incarnation != next.Incarnation || view.Installation.InventoryRevision != rev || rev != 1 || !reflect.DeepEqual(current, recoveryInventory(source, q.Next)) { |
| + return hostbox.ErrChanged |
| + } |
| + _, etag, err := rootClaimRecord(ctx, h.Evidence, fresh.Claim, h.Guard.DataDir) |
| + if err != nil { |
| + return err |
| + } |
| + if etag != q.RecordETag { |
| + return hostbox.ErrChanged |
| + } |
| + source.InstallationStamp = next.Stamp |
| + owner := "" |
| + for _, e := range current.Entries { |
| + if e.Kind == "route" { |
| + owner = e.Ownership |
| + } |
| + } |
| + if owner == "" { |
| + return hostbox.ErrTypedInventory |
| + } |
| + return h.rebindRecoveryRows(ctx, &source, stamp, owner) |
| + }) |
| + if err == hostbox.ErrChanged { |
| + continue |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + return nil |
| + } |
| + return hostbox.ErrChanged |
| +} |
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go| index 912ac70db..774e0853a 100644 |
| --- a/internal/host/claimretire_transport.go |
| +++ b/internal/host/claimretire_transport.go |
| @@ -51,7 +51,7 @@ func (h ClaimRootHTTP) RecoverPending(ctx context.Context) error { |
| return h.recoverRootInventory(ctx) |
| } |
| func (h ClaimRootHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| - if r.URL.Path != "/claim/retire" && r.URL.Path != "/claim/handover" && r.URL.Path != "/claim/journal" { |
| + if r.URL.Path != "/claim/recovery-rebind" && r.URL.Path != "/claim/retire" && r.URL.Path != "/claim/handover" && r.URL.Path != "/claim/journal" { |
| h.Stop.ServeHTTP(w, r) |
| return |
| } |
| @@ -73,6 +73,10 @@ func (h ClaimRootHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "refused", http.StatusForbidden) |
| return |
| } |
| + if r.URL.Path == "/claim/recovery-rebind" { |
| + h.serveRecoveryRebind(w, r) |
| + return |
| + } |
| if r.URL.Path == "/claim/journal" { |
| h.serveJournal(w, r, peer) |
| return |
diff --git a/internal/host/plan14_recovery_source_test.go b/internal/host/plan14_recovery_source_test.go| index bedbecdf8..8562c0ba8 100644 |
| --- a/internal/host/plan14_recovery_source_test.go |
| +++ b/internal/host/plan14_recovery_source_test.go |
| @@ -2,8 +2,12 @@ package host |
| import ( |
| "context" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "net" |
| "os/exec" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -12,52 +16,104 @@ import ( |
| ) |
| func TestPlan14RecoveryInventorySourceGap(t *testing.T) { |
| - f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| - holder := o.Claim.Holder |
| - authority := f.guard |
| - authority.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| - in := hostguard.RootInstallation{Host: o.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Migration: 1, InventoryRevision: 1, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box-1", Enrollment: "enrollment-1"} |
| - if err := authority.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| - t.Fatal(err) |
| + for _, attack := range []string{"none", "record moved", "claim moved", "fenced", "peer"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| + holder := o.Claim.Holder |
| + authority := f.guard |
| + authority.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| + in := hostguard.RootInstallation{Host: o.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Migration: 1, InventoryRevision: 1, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box-1", Enrollment: "enrollment-1"} |
| + if err := authority.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.Guard.FenceView = authority.Root.View |
| + plan14PutRecord(t, f.claims, provision.Record{Schema: 1, Host: o.Host, Account: holder.Account, App: holder.App, Channel: "stable", CreatedAt: f.now}) |
| + actor := hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 7} |
| + root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: authority, Evidence: f.claims}, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Roots: map[string]string{"data": f.guard.DataDir}, Reconcile: hostbox.ReconcileAdapter{Inventory: h.Inventory, Registry: h.Registry, Box: actor.Box, Enrollment: actor.Enrollment}} |
| + // Inventory reads use the same authenticated Unix endpoint as production. |
| + journals, err := hostbox.NewJournalService(f.journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + journals.Inventory = h.Inventory |
| + root.Journals = journals |
| + socket := startRetirementRecoveryTransport(t, root) |
| + _, h.Inventory = hostbox.RemoteJournals(rootJournalClient(socket)) |
| + if attack == "peer" { |
| + root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, hostbox.ErrChanged } |
| + socket = startRetirementRecoveryTransport(t, root) |
| + } |
| + h.RecoveryRebind = func(ctx context.Context, q recoveryRebindRequest) error { |
| + err := recoveryRebindClient(socket)(ctx, q) |
| + if err != nil { |
| + return err |
| + } |
| + if attack == "fenced" { |
| + _, err = root.Stop.Coordinator.Guard.BeginFence(ctx, hostguard.FenceTarget{Host: q.Next.Host, Incarnation: q.Next.Incarnation, Stamp: q.Next.Stamp, Session: q.Next.Session, Op: q.Next.Op, Phase: q.Next.Phase, Box: q.Next.Box, Enrollment: q.Next.Enrollment, DirectoryRevision: q.Next.DirectoryRevision, ClaimETag: q.Next.ETag, BootID: "boot-1", ControllerID: "controller-1", FenceID: "new-fence"}) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + if attack == "record moved" { |
| + rec := provision.Record{Schema: 1, Host: o.Host, Account: holder.Account, App: holder.App, Channel: "stable", CreatedAt: f.now, RecordSeq: 2} |
| + plan14PutRecord(t, f.claims, rec) |
| + } |
| + if attack == "claim moved" { |
| + _, err = hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { c.Op.Journal["moved"] = "yes"; return nil }) |
| + } |
| + return err |
| + } |
| + before, err := h.Registry.Get(o.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + c.wake.Inputs = plan9D2Inputs{recoveryInputs{f}} |
| + c.wake.Operations = &plan9D2Attempts{} |
| + c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| + wireClaimAdmission(c.wake, "box-1", func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + t.Fatal("wrong handover") |
| + return nil |
| + }, h, nil) |
| + if err = c.wake.stopPredecessor(t.Context(), o); err != nil { |
| + t.Fatal(err) |
| + } |
| + live, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + done := make(chan struct{}) |
| + go func() { <-live.Done(); close(done) }() |
| + c.wake.mu.Lock() |
| + c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| + c.wake.mu.Unlock() |
| + ctx, stop := context.WithTimeout(t.Context(), 3*time.Second) |
| + defer stop() |
| + _, err = c.runtime.Admit(ctx, registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true}) |
| + if attack != "none" { |
| + if err == nil { |
| + t.Fatal("stale or unauthenticated rebind accepted") |
| + } |
| + after, e := h.Registry.Get(o.Host) |
| + if e != nil || after.ClaimOwnership != before.ClaimOwnership { |
| + t.Fatal("stale result rebound rows", e) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal("root recovery admission failed", err) |
| + } |
| + current := f.read() |
| + if current.Claim.Holder.Stamp <= holder.Stamp { |
| + t.Fatal("fixture did not reach the new claim stamp", err) |
| + } |
| + after, e := h.Registry.Get(o.Host) |
| + if e != nil || after.ClaimOwnership == before.ClaimOwnership { |
| + t.Fatal("rows not rebound after root commit", e) |
| + } |
| + view, e := authority.Root.View(t.Context(), o.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| + if e != nil || view.Installation.Stamp != current.Claim.Holder.Stamp { |
| + t.Fatal("root installation not rebound", view, e) |
| + } |
| + t.Logf("fresh claim stamp %d, retained root stamp %d; recovery succeeds: %v", current.Claim.Holder.Stamp, view.Installation.Stamp, err) |
| + }) |
| } |
| - h.Guard.FenceView = authority.Root.View |
| - before, err := h.Registry.Get(o.Host) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| - c.wake.Inputs = plan9D2Inputs{recoveryInputs{f}} |
| - c.wake.Operations = &plan9D2Attempts{} |
| - c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| - wireClaimAdmission(c.wake, "box-1", func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| - t.Fatal("wrong handover") |
| - return nil |
| - }, h, nil) |
| - if err = c.wake.stopPredecessor(t.Context(), o); err != nil { |
| - t.Fatal(err) |
| - } |
| - live, cancel := context.WithCancel(t.Context()) |
| - defer cancel() |
| - done := make(chan struct{}) |
| - go func() { <-live.Done(); close(done) }() |
| - c.wake.mu.Lock() |
| - c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| - c.wake.mu.Unlock() |
| - _, err = c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true}) |
| - if err == nil { |
| - t.Fatal("agent admission silently rebound root installation") |
| - } |
| - current := f.read() |
| - if current.Claim.Holder.Stamp <= holder.Stamp { |
| - t.Fatal("fixture did not reach the new claim stamp", err) |
| - } |
| - after, e := h.Registry.Get(o.Host) |
| - if e != nil || after.ClaimOwnership != before.ClaimOwnership { |
| - t.Fatal("rebound rows before root authority", e) |
| - } |
| - view, e := authority.Root.View(t.Context(), o.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| - if e != nil || view.Installation != in { |
| - t.Fatal("root installation changed", view, e) |
| - } |
| - t.Logf("fresh claim stamp %d, retained root stamp %d; recovery refuses: %v", current.Claim.Holder.Stamp, view.Installation.Stamp, err) |
| } |
diff --git a/internal/host/rootinstallation_test.go b/internal/host/rootinstallation_test.go| index 7fc533966..3e65052dd 100644 |
| --- a/internal/host/rootinstallation_test.go |
| +++ b/internal/host/rootinstallation_test.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "encoding/json" |
| "testing" |
| "time" |
| @@ -72,3 +73,48 @@ type rootViewClaimReader struct{ obs hostclaim.Observed } |
| func (r rootViewClaimReader) Read(context.Context, string) (hostclaim.Observed, error) { |
| return r.obs, nil |
| } |
| + |
| +func TestPlan14RootSidecarBinding(t *testing.T) { |
| + for _, attack := range []string{"none", "account", "app", "host", "name", "key", "incarnation", "missing"} { |
| + t.Run(attack, func(t *testing.T) { |
| + store := release.NewDirStore(t.TempDir()) |
| + rec := provision.SidecarRecord{Schema: 1, Name: "worker", Host: provision.SidecarHost("worker", "shop", "bab"), Account: "bab", App: "shop", Channel: "stable", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)} |
| + key := provision.SidecarRecordKey(rec.Account, rec.App, rec.Name) |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := &hostclaim.Claim{Host: rec.Host, Holder: &hostclaim.Holder{Family: "sidecar", Key: key, Name: rec.Name, Account: rec.Account, App: rec.App, Incarnation: inc}} |
| + switch attack { |
| + case "account": |
| + rec.Account = "other" |
| + case "app": |
| + rec.App = "other" |
| + case "host": |
| + rec.Host = "other.example" |
| + case "name": |
| + rec.Name = "other" |
| + case "key": |
| + c.Holder.Key = "apps/other/shop/sidecars/worker.json" |
| + case "incarnation": |
| + c.Holder.Incarnation = "other" |
| + } |
| + if attack != "missing" { |
| + body, _ := json.Marshal(rec) |
| + if _, err := store.Put(t.Context(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + row, etag, err := rootClaimRecord(t.Context(), store, c, t.TempDir()) |
| + if attack != "none" { |
| + if err == nil { |
| + t.Fatal("forged sidecar bound") |
| + } |
| + return |
| + } |
| + if err != nil || etag == "" || !row.IsSidecar() || row.Account != "bab" || row.App != "shop" || row.Socket != "" || row.Addr != "" { |
| + t.Fatal(row, etag, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootinventory.go b/internal/host/rootinventory.go| index c1241371c..cf3e4e1a1 100644 |
| --- a/internal/host/rootinventory.go |
| +++ b/internal/host/rootinventory.go |
| @@ -112,6 +112,12 @@ func (h ClaimRootHTTP) commitRootInventoryPending(ctx context.Context, q hostbox |
| } |
| continue |
| } |
| + if e.Kind == "timer" { |
| + if err := h.verifyInventoryTimer(in.Host, canonical.ClaimOwnership, e); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| if err := h.verifyInventoryPath(e); err != nil { |
| return err |
| } |
| @@ -242,6 +248,16 @@ func (h ClaimRootHTTP) recoverRootInventory(ctx context.Context) error { |
| return err |
| } |
| for _, raw := range pending { |
| + var recovery recoveryRebindIntent |
| + if err = json.Unmarshal(raw, &recovery); err != nil { |
| + return err |
| + } |
| + if recovery.Recovery != nil { |
| + if err = h.rebindRecoveryInventory(ctx, *recovery.Recovery); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| var q hostbox.JournalRequest |
| if err = json.Unmarshal(raw, &q); err != nil { |
| return err |
diff --git a/internal/host/rootinventory_timer.go b/internal/host/rootinventory_timer.go| new file mode 100644 |
| index 000000000..f949ef1da |
| --- /dev/null |
| +++ b/internal/host/rootinventory_timer.go |
| @@ -0,0 +1,33 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "os" |
| + "strings" |
| +) |
| + |
| +func (h ClaimRootHTTP) verifyInventoryTimer(host string, owner registry.ClaimOwnership, e hostbox.StopInventoryEntry) error { |
| + if h.Reconcile.Registry == nil || e.Kind != "timer" || e.ManagedRoot != "registry" || !strings.HasPrefix(e.RelativePath, host+"/") || e.RowIdentity == "" || e.Ownership != owner.ClaimOwner || e.Device != 0 || e.Inode != 0 || e.Type != 0 { |
| + return hostbox.ErrTypedInventory |
| + } |
| + name := strings.TrimPrefix(e.RelativePath, host+"/") |
| + if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { |
| + return hostbox.ErrTypedInventory |
| + } |
| + timer, err := h.Reconcile.Registry.GetTimer(host, name) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + if timer == nil { |
| + if e.Materialization != "intended" { |
| + return hostbox.ErrTypedInventory |
| + } |
| + return nil |
| + } |
| + if (e.Materialization != "intended" && e.Materialization != "materialized") || timer.Host != host || timer.Name != name || timer.ClaimIncarnation != owner.ClaimIncarnation || timer.ClaimStamp != owner.ClaimStamp || timer.ClaimOwner != e.Ownership || timer.ClaimRowID != e.RowIdentity { |
| + return hostbox.ErrTypedInventory |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/rootinventory_timer_test.go b/internal/host/rootinventory_timer_test.go| new file mode 100644 |
| index 000000000..d6f47ae7d |
| --- /dev/null |
| +++ b/internal/host/rootinventory_timer_test.go |
| @@ -0,0 +1,95 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlan14RootTimerInventory(t *testing.T) { |
| + for _, attack := range []string{"none", "intended", "missing", "row identity", "owner", "path", "root", "shape"} { |
| + t.Run(attack, func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + f := newRecoveryFixture(t) |
| + o := f.read() |
| + h := o.Claim.Holder |
| + plan14PutRecord(t, f.claims, provision.Record{Schema: 1, Host: o.Host, Account: h.Account, App: h.App, Channel: "stable", CreatedAt: f.now}) |
| + _, etag, _ := f.claims.Get(t.Context(), h.Key) |
| + journals, err := hostbox.NewJournalService(f.journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := restartTestRegistry(t) |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", o.Host, h.Incarnation, h.Stamp, "box-1"))) |
| + owner := registry.ClaimOwnership{ClaimIncarnation: h.Incarnation, ClaimStamp: h.Stamp, ClaimOwner: hex.EncodeToString(sum[:]), ClaimRowID: "route"} |
| + row := registry.Route{Host: o.Host, Account: h.Account, App: h.App, DBPath: provision.NestedDBPath(f.guard.DataDir, o.Host), ClaimOwnership: owner} |
| + if err = reg.Upsert(row); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.UpsertTimer(registry.Timer{Host: o.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", Path: "/daily", ClaimOwnership: owner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + timer, err := reg.GetTimer(o.Host, "daily") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !timer.ClaimOwnership.Valid() { |
| + t.Fatal("timer fixture has no ownership") |
| + } |
| + in := hostbox.StopInventory{Complete: true, Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: "en", DirectoryRevision: "1", Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: o.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}} |
| + if err = journals.Inventory.WriteStopInventory(t.Context(), in); err != nil { |
| + t.Fatal(err) |
| + } |
| + g := f.guard |
| + g.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| + if err = g.EstablishRoot(t.Context(), hostguard.RootInstallation{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: "box-1", Enrollment: "en", Migration: 1, InventoryRevision: 1}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + actor := hostbox.Actor{Box: "box-1", Enrollment: "en", Revision: 1} |
| + root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: g, Evidence: f.claims}}, Journals: journals, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Roots: map[string]string{"data": g.DataDir}, Reconcile: hostbox.ReconcileAdapter{Registry: reg, Census: &hostbox.EmptyInstallationCensus{}}} |
| + e := hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: o.Host + "/daily", RowIdentity: timer.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"} |
| + switch attack { |
| + case "missing", "intended": |
| + if err = reg.DeleteTimer(o.Host, "daily"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "intended" { |
| + e.Materialization = "intended" |
| + } |
| + case "row identity": |
| + e.RowIdentity = "other" |
| + case "owner": |
| + e.Ownership = "other" |
| + case "path": |
| + e.RelativePath = "other.example/daily" |
| + case "root": |
| + e.ManagedRoot = "data" |
| + case "shape": |
| + e.Inode = 1 |
| + } |
| + in.Entries = append(in.Entries, e) |
| + _, client := hostbox.RemoteJournals(root.commitRootInventory) |
| + _, err = client.WriteExpected(t.Context(), in, hostbox.InventoryExpectation{Revision: 1, RecordKey: h.Key, RecordETag: etag, Target: hostguard.FenceTarget{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: actor.Box, Enrollment: actor.Enrollment, ClaimETag: o.ETag, Op: o.Claim.Op.ID, Phase: o.Claim.Op.Phase}}) |
| + valid := attack == "none" || attack == "intended" |
| + if valid && err != nil || !valid && err == nil { |
| + t.Fatal(attack, err) |
| + } |
| + _, revision, _ := journals.Inventory.Snapshot(t.Context(), o.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| + want := uint64(1) |
| + if valid { |
| + want = 2 |
| + } |
| + if revision != want { |
| + t.Fatal(revision, want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootrecovery_process_test.go b/internal/host/rootrecovery_process_test.go| new file mode 100644 |
| index 000000000..0fdb97239 |
| --- /dev/null |
| +++ b/internal/host/rootrecovery_process_test.go |
| @@ -0,0 +1,116 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func plan14RebindProcessRoot(t *testing.T, dir string) (ClaimRootHTTP, *rollbackDisk, recoveryRebindRequest) { |
| + root, disk := plan14InventoryProcessRoot(t, dir) |
| + root.InventoryActor = func(context.Context) (hostbox.Actor, error) { |
| + return hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 7}, nil |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, "request.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var q recoveryRebindRequest |
| + if err = json.Unmarshal(raw, &q); err != nil { |
| + t.Fatal(err) |
| + } |
| + return root, disk, q |
| +} |
| +func TestPlan14RecoveryRebindProcessCrash(t *testing.T) { |
| + if phase := os.Getenv("PLAN14_REBIND_CRASH"); phase != "" { |
| + root, disk, q := plan14RebindProcessRoot(t, os.Getenv("PLAN14_REBIND_DIR")) |
| + writes := 0 |
| + disk.beforeWrite = func(string, []byte) { |
| + if phase == "before intent" && writes == 0 || phase == "database" && writes == 1 { |
| + os.Exit(73) |
| + } |
| + } |
| + disk.afterWrite = func(string, []byte) { |
| + writes++ |
| + if phase == "intent" && writes == 1 || phase == "commit" && writes == 2 { |
| + os.Exit(73) |
| + } |
| + } |
| + err := root.rebindRecoveryInventory(t.Context(), q) |
| + t.Fatal("crash not reached", phase, err) |
| + } |
| + for _, phase := range []string{"before intent", "intent", "database", "commit"} { |
| + t.Run(phase, func(t *testing.T) { |
| + f, original, disk, q := plan14RecoveryRebind(t) |
| + dir := t.TempDir() |
| + for _, name := range []string{"root", "data", "opt", "units", "locks", "child"} { |
| + if err := os.Mkdir(filepath.Join(dir, name), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + store := release.NewDirStore(filepath.Join(dir, "deployment")) |
| + keys, err := f.claims.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + body, _, e := f.claims.Get(t.Context(), key) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = store.Put(t.Context(), key, body, ""); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + raw, err := disk.Read(q.Next.Host + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.WriteFile(filepath.Join(dir, "root", q.Next.Host+".json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ = json.Marshal(q) |
| + if err = os.WriteFile(filepath.Join(dir, "request.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + root, _, _ := plan14RebindProcessRoot(t, dir) |
| + source, _, err := original.Reconcile.Inventory.Snapshot(t.Context(), q.Next.Host, q.Prior) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = root.Reconcile.Inventory.WriteStopInventory(t.Context(), source); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestPlan14RecoveryRebindProcessCrash$") |
| + cmd.Dir = filepath.Join(dir, "child") |
| + cmd.Env = append(os.Environ(), "PLAN14_REBIND_CRASH="+phase, "PLAN14_REBIND_DIR="+dir) |
| + output, err := cmd.CombinedOutput() |
| + exit, ok := err.(*exec.ExitError) |
| + if !ok || exit.ExitCode() != 73 { |
| + t.Fatalf("crash %v: %s", err, output) |
| + } |
| + resumed, _, _ := plan14RebindProcessRoot(t, dir) |
| + if err = resumed.recoverRootInventory(t.Context()); err != nil { |
| + t.Fatal("startup recovery", err) |
| + } |
| + if err = resumed.rebindRecoveryInventory(t.Context(), q); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + view, err := resumed.Stop.Coordinator.Guard.Root.View(t.Context(), q.Next.Host, hostguard.Captured{Incarnation: q.Next.Incarnation, Stamp: q.Next.Stamp}) |
| + if err != nil || view.Installation.Stamp != q.Next.Stamp || len(view.Retired) != 1 || view.Retired[0] != q.Prior { |
| + t.Fatal(view, err) |
| + } |
| + _, rev, err := resumed.Reconcile.Inventory.Snapshot(t.Context(), q.Next.Host, view.Captured) |
| + if err != nil || rev != 1 { |
| + t.Fatal(rev, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootrecovery_rebind.go b/internal/host/rootrecovery_rebind.go| new file mode 100644 |
| index 000000000..dfa562e63 |
| --- /dev/null |
| +++ b/internal/host/rootrecovery_rebind.go |
| @@ -0,0 +1,197 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "reflect" |
| + "strconv" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +type recoveryRebindRequest struct { |
| + V int |
| + Prior hostguard.Captured |
| + Revision uint64 |
| + RecordKey, RecordETag string |
| + Next hostbox.ColdSuccessor |
| +} |
| + |
| +type recoveryRebindIntent struct{ Recovery *recoveryRebindRequest } |
| + |
| +func recoveryInventory(in hostbox.StopInventory, next hostbox.ColdSuccessor) hostbox.StopInventory { |
| + in.Entries = append([]hostbox.StopInventoryEntry(nil), in.Entries...) |
| + in.InstallationStamp, in.DirectoryRevision = next.Stamp, next.DirectoryRevision |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", next.Host, next.Incarnation, next.Stamp, next.Box))) |
| + owner := hex.EncodeToString(sum[:]) |
| + for i := range in.Entries { |
| + e := &in.Entries[i] |
| + e.Ownership = owner |
| + if e.Kind == "route" || e.Kind == "timer" { |
| + name := e.RelativePath |
| + if e.Kind == "timer" { |
| + name = name[len(next.Host)+1:] |
| + } |
| + sum := sha256.Sum256([]byte(owner + "\x00" + e.Kind + "\x00" + name)) |
| + e.RowIdentity = hex.EncodeToString(sum[:]) |
| + } |
| + } |
| + return in |
| +} |
| + |
| +func (h ClaimRootHTTP) rebindRecoveryInventory(ctx context.Context, q recoveryRebindRequest) error { |
| + guard := h.Stop.Coordinator.Guard |
| + if guard.Root == nil || h.Reconcile.Inventory == nil || h.InventoryActor == nil || q.V != 1 || q.Revision == 0 || q.RecordKey == "" || q.RecordETag == "" { |
| + return hostbox.ErrUnsupported |
| + } |
| + if h.RequireNewWork != nil { |
| + if err := h.RequireNewWork(ctx); err != nil { |
| + return err |
| + } |
| + } |
| + actor, err := h.InventoryActor(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + n := q.Next |
| + if n.Box != actor.Box || n.Enrollment != actor.Enrollment || n.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) || n.Incarnation != q.Prior.Incarnation || n.Stamp <= q.Prior.Stamp || n.Session != n.Stamp || n.Generation == "" || n.ETag == "" { |
| + return hostbox.ErrChanged |
| + } |
| + source, revision, err := h.Reconcile.Inventory.Snapshot(ctx, n.Host, q.Prior) |
| + if err != nil { |
| + return err |
| + } |
| + if revision != q.Revision || !source.Complete || source.Host != n.Host || source.Incarnation != q.Prior.Incarnation || source.InstallationStamp != q.Prior.Stamp || source.Box != actor.Box || source.Enrollment != actor.Enrollment || len(source.Entries) == 0 { |
| + return hostbox.ErrTypedInventory |
| + } |
| + observed, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, n.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if observed.Claim == nil || observed.Claim.Holder == nil { |
| + return hostbox.ErrChanged |
| + } |
| + holder := observed.Claim.Holder |
| + next := hostguard.RootInstallation{Host: n.Host, Incarnation: n.Incarnation, Stamp: n.Stamp, Account: holder.Account, App: holder.App, Record: q.RecordKey, Box: n.Box, Enrollment: n.Enrollment, Migration: 1, InventoryRevision: 1} |
| + var rebound hostbox.StopInventory |
| + check := func(c *hostclaim.Claim) error { |
| + fresh, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, n.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Absent || fresh.ETag != n.ETag || !reflect.DeepEqual(c, fresh.Claim) || c.Holder == nil { |
| + return hostbox.ErrChanged |
| + } |
| + holder := c.Holder |
| + stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| + if err != nil || stamp != q.Prior.Stamp || !claimRecoveryKind(c.Op.Kind) || c.Op.Phase != hostclaim.PhaseProbing || c.Op.ID != n.Op || c.Op.Phase != n.Phase || c.Op.Exec != n.Box || c.Op.Intent["source"] != n.Box || holder.Key != q.RecordKey || holder.Incarnation != n.Incarnation || holder.Stamp != n.Stamp || holder.Session != n.Session || holder.Generation != n.Generation || holder.WriterBox != n.Box { |
| + return hostbox.ErrChanged |
| + } |
| + if len(holder.Admissions) == 0 { |
| + return hostbox.ErrChanged |
| + } |
| + a := holder.Admissions[len(holder.Admissions)-1] |
| + if a.Mode != "cold" || a.Session != n.Session || a.Generation != n.Generation || a.Box != n.Box { |
| + return hostbox.ErrChanged |
| + } |
| + canonical, etag, err := rootClaimRecord(ctx, h.Stop.Coordinator.Evidence, c, h.Roots["data"]) |
| + if err != nil { |
| + return err |
| + } |
| + if etag != q.RecordETag || canonical.Host != n.Host || canonical.Account != next.Account || canonical.App != next.App { |
| + return hostbox.ErrChanged |
| + } |
| + canonical.ClaimIncarnation, canonical.ClaimStamp = q.Prior.Incarnation, q.Prior.Stamp |
| + routes := 0 |
| + for _, e := range source.Entries { |
| + if e.Kind == "route" { |
| + routes++ |
| + canonical.ClaimOwner, canonical.ClaimRowID = e.Ownership, e.RowIdentity |
| + } |
| + } |
| + if routes != 1 || hostbox.ValidateAgentInventory(source, &canonical, h.Roots["data"]) != nil { |
| + return hostbox.ErrTypedInventory |
| + } |
| + current, rev, err := h.Reconcile.Inventory.Snapshot(ctx, n.Host, q.Prior) |
| + if err != nil { |
| + return err |
| + } |
| + if rev != q.Revision || !reflect.DeepEqual(source, current) { |
| + return hostbox.ErrChanged |
| + } |
| + rebound = recoveryInventory(source, n) |
| + return nil |
| + } |
| + raw, err := json.Marshal(recoveryRebindIntent{Recovery: &q}) |
| + if err != nil { |
| + return err |
| + } |
| + return guard.RebindRootInventory(ctx, q.Prior, q.Revision, next, raw, check, func() error { |
| + current, rev, err := h.Reconcile.Inventory.Snapshot(ctx, n.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}) |
| + if err != nil { |
| + return err |
| + } |
| + if rev != 0 { |
| + if rev != 1 || !reflect.DeepEqual(current, rebound) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + return h.Reconcile.Inventory.WriteStopInventory(ctx, rebound) |
| + }) |
| +} |
| + |
| +func (h ClaimRootHTTP) serveRecoveryRebind(w http.ResponseWriter, r *http.Request) { |
| + var q recoveryRebindRequest |
| + dec := json.NewDecoder(io.LimitReader(r.Body, 32<<10)) |
| + dec.DisallowUnknownFields() |
| + if dec.Decode(&q) != nil || dec.Decode(new(any)) != io.EOF { |
| + w.WriteHeader(http.StatusBadRequest) |
| + return |
| + } |
| + if err := h.rebindRecoveryInventory(r.Context(), q); err != nil { |
| + w.WriteHeader(http.StatusConflict) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| +func recoveryRebindClient(socket string) func(context.Context, recoveryRebindRequest) error { |
| + return func(ctx context.Context, q recoveryRebindRequest) error { |
| + body, err := json.Marshal(q) |
| + if err != nil { |
| + return err |
| + } |
| + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + }} |
| + defer transport.CloseIdleConnections() |
| + client := &http.Client{Transport: transport, Timeout: 30 * time.Second} |
| + for attempt := 0; attempt < 2; attempt++ { |
| + req, e := http.NewRequestWithContext(ctx, http.MethodPost, "http://instance-control/claim/recovery-rebind", bytes.NewReader(body)) |
| + if e != nil { |
| + return e |
| + } |
| + response, e := client.Do(req) |
| + if e != nil { |
| + err = e |
| + continue |
| + } |
| + response.Body.Close() |
| + if response.StatusCode != http.StatusNoContent { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + return err |
| + } |
| +} |
diff --git a/internal/host/rootrecovery_rebind_test.go b/internal/host/rootrecovery_rebind_test.go| new file mode 100644 |
| index 000000000..b252fc944 |
| --- /dev/null |
| +++ b/internal/host/rootrecovery_rebind_test.go |
| @@ -0,0 +1,146 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func plan14RecoveryRebind(t *testing.T) (*recoveryFixture, ClaimRootHTTP, *rollbackDisk, recoveryRebindRequest) { |
| + t.Helper() |
| + f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| + holder := o.Claim.Holder |
| + plan14PutRecord(t, f.claims, provision.Record{Schema: 1, Host: o.Host, Account: holder.Account, App: holder.App, Channel: "stable", CreatedAt: f.now}) |
| + disk := &rollbackDisk{dir: t.TempDir()} |
| + g := f.guard |
| + g.Root = hostguard.NewRootAuthority(disk) |
| + in := hostguard.RootInstallation{Host: o.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Migration: 1, InventoryRevision: 1, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box-1", Enrollment: "enrollment-1"} |
| + if err := g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + next, err := hostclaim.Update(t.Context(), f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, ObservedSession: holder.Session, ObservedGeneration: holder.Generation, Box: "box-1", Mode: "cold", NewGeneration: "recovered", Region: "ie", PredecessorRegion: "ie"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := next.Claim.Holder |
| + _, etag, err := f.claims.Get(t.Context(), holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + actor := hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 7} |
| + root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: g, Evidence: f.claims}}, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Roots: map[string]string{"data": g.DataDir}, Reconcile: hostbox.ReconcileAdapter{Inventory: h.Inventory, Registry: h.Registry, Box: actor.Box, Enrollment: actor.Enrollment}} |
| + q := recoveryRebindRequest{V: 1, Prior: hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}, Revision: 1, RecordKey: holder.Key, RecordETag: etag, Next: hostbox.ColdSuccessor{Host: next.Host, Incarnation: n.Incarnation, Stamp: n.Stamp, Session: n.Session, Generation: n.Generation, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "7", ETag: next.ETag, Op: next.Claim.Op.ID, Phase: next.Claim.Op.Phase}} |
| + return f, root, disk, q |
| +} |
| +func TestPlan14RecoveryRebindAuthority(t *testing.T) { |
| + for _, attack := range []string{"none", "claim etag", "stamp", "session", "generation", "op", "phase", "box", "enrollment", "record etag", "record binding", "revision", "source identity", "missing root", "missing inventory", "plain stopped report", "before commit"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, root, disk, q := plan14RecoveryRebind(t) |
| + switch attack { |
| + case "claim etag": |
| + q.Next.ETag = "stale" |
| + case "stamp": |
| + q.Next.Stamp++ |
| + case "session": |
| + q.Next.Session++ |
| + case "generation": |
| + q.Next.Generation = "other" |
| + case "op": |
| + q.Next.Op = "other" |
| + case "phase": |
| + q.Next.Phase = "other" |
| + case "box": |
| + q.Next.Box = "other" |
| + case "enrollment": |
| + q.Next.Enrollment = "other" |
| + case "record etag": |
| + q.RecordETag = "stale" |
| + case "record binding": |
| + body, oldETag, err := f.claims.Get(t.Context(), q.RecordKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rec provision.Record |
| + json.Unmarshal(body, &rec) |
| + rec.Account = "other" |
| + body, _ = json.Marshal(rec) |
| + q.RecordETag, err = f.claims.Put(t.Context(), q.RecordKey, body, oldETag) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case "revision": |
| + q.Revision++ |
| + case "source identity": |
| + q.Prior.Stamp++ |
| + case "missing root": |
| + if err := os.Remove(filepath.Join(disk.dir, q.Next.Host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing inventory": |
| + root.Reconcile.Inventory, _ = hostbox.OpenStopInventoryDB(filepath.Join(t.TempDir(), "missing.db")) |
| + t.Cleanup(func() { root.Reconcile.Inventory.Close() }) |
| + case "plain stopped report": |
| + o := f.read() |
| + o.Claim.Holder.Admissions = nil |
| + body, _ := json.Marshal(o.Claim) |
| + var err error |
| + key, _ := hostclaim.Key(o.Host) |
| + q.Next.ETag, err = f.claims.Put(t.Context(), key, body, o.ETag) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case "before commit": |
| + disk.afterWrite = func(string, []byte) { |
| + disk.afterWrite = nil |
| + o := f.read() |
| + _, err := hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { c.Op.Phase = "changed"; return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + before, _ := disk.Read(q.Next.Host + ".json") |
| + row, _ := root.Reconcile.Registry.Get(q.Next.Host) |
| + err := root.rebindRecoveryInventory(t.Context(), q) |
| + if attack != "none" { |
| + if err == nil { |
| + t.Fatal("untrusted rebind accepted") |
| + } |
| + after, _ := disk.Read(q.Next.Host + ".json") |
| + if attack != "before commit" && string(before) != string(after) { |
| + t.Fatal("refusal changed root") |
| + } |
| + _, rev, e := root.Reconcile.Inventory.Snapshot(t.Context(), q.Next.Host, hostguard.Captured{Incarnation: q.Next.Incarnation, Stamp: q.Next.Stamp}) |
| + if e != nil || rev != 0 { |
| + t.Fatal("refusal wrote successor", rev, e) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + after, _ := root.Reconcile.Registry.Get(q.Next.Host) |
| + if after.ClaimOwnership != row.ClaimOwnership { |
| + t.Fatal("root rebound registry") |
| + } |
| + saved, _ := disk.Read(q.Next.Host + ".json") |
| + if err = root.rebindRecoveryInventory(t.Context(), q); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + again, _ := disk.Read(q.Next.Host + ".json") |
| + if string(saved) != string(again) { |
| + t.Fatal("replay changed root") |
| + } |
| + _, rev, err := root.Reconcile.Inventory.Snapshot(t.Context(), q.Next.Host, hostguard.Captured{Incarnation: q.Next.Incarnation, Stamp: q.Next.Stamp}) |
| + if err != nil || rev != 1 { |
| + t.Fatal(rev, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostbox/coldhandover.go b/internal/hostbox/coldhandover.go| index 475380a54..af1a734d2 100644 |
| --- a/internal/hostbox/coldhandover.go |
| +++ b/internal/hostbox/coldhandover.go |
| @@ -25,6 +25,7 @@ type ColdSuccessor struct { |
| Stamp, Session uint64 |
| } |
| type ColdHandover struct { |
| + checkpoint func(string) |
| afterIntent func() error |
| afterPark func() error |
| Guard hostguard.Guard |
| @@ -175,6 +176,9 @@ func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next |
| if err := ValidateAgentInventory(j.Inventory, &j.Before.Route, h.Roots["data"]); err != nil { |
| return err |
| } |
| + if j.Phase == "prepared" { |
| + h.atCheckpoint("intent") |
| + } |
| if h.afterIntent != nil && j.Phase == "prepared" { |
| if err := h.afterIntent(); err != nil { |
| return err |
| @@ -268,6 +272,7 @@ func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next |
| if j.Phase == "complete" { |
| return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) |
| } |
| + h.atCheckpoint("parked") |
| if h.afterPark != nil { |
| if err := h.afterPark(); err != nil { |
| return err |
| @@ -322,15 +327,22 @@ func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next |
| if err = h.Inventory.WriteStopInventory(ctx, successor); err != nil { |
| return err |
| } |
| + h.atCheckpoint("inventory-intent") |
| if err = h.Registry.RestoreClaimRows(ctx, j.ID, j.ID+"-old", j.Before, j.After); err != nil { |
| return err |
| } |
| + h.atCheckpoint("rows") |
| successor.Entries[0].Materialization = "materialized" |
| if err = h.Inventory.WriteStopInventory(ctx, successor); err != nil { |
| return err |
| } |
| + h.atCheckpoint("inventory-materialized") |
| j.Phase = "complete" |
| - return writeColdJournal(path, j) |
| + if err := writeColdJournal(path, j); err != nil { |
| + return err |
| + } |
| + h.atCheckpoint("complete-journal") |
| + return nil |
| }) |
| } |
| @@ -619,3 +631,9 @@ func (h ColdHandover) Recover(ctx context.Context) error { |
| } |
| return nil |
| } |
| + |
| +func (h ColdHandover) atCheckpoint(point string) { |
| + if h.checkpoint != nil { |
| + h.checkpoint(point) |
| + } |
| +} |
diff --git a/internal/hostbox/plan14_cold_process_test.go b/internal/hostbox/plan14_cold_process_test.go| new file mode 100644 |
| index 000000000..97449ef05 |
| --- /dev/null |
| +++ b/internal/hostbox/plan14_cold_process_test.go |
| @@ -0,0 +1,306 @@ |
| +//go:build linux |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type plan14ColdManifest struct { |
| + Base, Data, Locks string |
| + Prior hostguard.FenceTarget |
| + Next ColdSuccessor |
| +} |
| +type plan14ColdDisk struct { |
| + rootstate.Store |
| + dir string |
| + before, after func([]byte) |
| +} |
| + |
| +func (d *plan14ColdDisk) Read(n string) ([]byte, error) { return os.ReadFile(filepath.Join(d.dir, n)) } |
| +func (d *plan14ColdDisk) Write(n string, b []byte) error { |
| + if d.before != nil { |
| + d.before(b) |
| + } |
| + f, err := os.CreateTemp(d.dir, "pending-") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(f.Name()) |
| + _, err = f.Write(b) |
| + if err == nil { |
| + err = f.Sync() |
| + } |
| + err = errors.Join(err, f.Close()) |
| + if err != nil { |
| + return err |
| + } |
| + if err = os.Rename(f.Name(), filepath.Join(d.dir, n)); err != nil { |
| + return err |
| + } |
| + parent, err := os.Open(d.dir) |
| + if err != nil { |
| + return err |
| + } |
| + err = errors.Join(parent.Sync(), parent.Close()) |
| + if err != nil { |
| + return err |
| + } |
| + if d.after != nil { |
| + d.after(b) |
| + } |
| + return nil |
| +} |
| +func (d *plan14ColdDisk) Names() ([]string, error) { |
| + es, err := os.ReadDir(d.dir) |
| + var names []string |
| + for _, e := range es { |
| + names = append(names, e.Name()) |
| + } |
| + return names, err |
| +} |
| +func (d *plan14ColdDisk) Lock(context.Context, string) (func(), error) { return func() {}, nil } |
| +func (d *plan14ColdDisk) Close() error { return nil } |
| +func plan14ColdSetup(t *testing.T) plan14ColdManifest { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + source, store, initial := workFixture(t) |
| + ctx := t.Context() |
| + base := t.TempDir() |
| + rootDir := filepath.Join(base, "root") |
| + if err := os.Mkdir(rootDir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + disk := &plan14ColdDisk{dir: rootDir} |
| + admit := func(old hostclaim.Observed, generation string) hostclaim.Observed { |
| + next, err := hostclaim.Update(ctx, store, old, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: old.Claim.Op.ID, ObservedPhase: old.Claim.Op.Phase, ObservedSession: old.Claim.Holder.Session, ObservedGeneration: old.Claim.Holder.Generation, Box: "box", Region: "ie", Mode: "cold", NewGeneration: generation})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return next |
| + } |
| + old := admit(initial, "first") |
| + if err := source.Guard.Activate(ctx, old.Host, "box", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + source.Guard.Root = hostguard.NewRootAuthority(disk) |
| + holder := old.Claim.Holder |
| + if err := source.Guard.EstablishRoot(ctx, hostguard.RootInstallation{Host: old.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box", Enrollment: "enrollment", Migration: 1, InventoryRevision: 1}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + prior := hostguard.FenceTarget{FenceID: "fence", Region: "ie", Host: old.Host, Incarnation: old.Claim.Holder.Incarnation, Stamp: old.Claim.Holder.Stamp, Session: old.Claim.Holder.Session, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ClaimETag: old.ETag, DirectoryRevision: "1", Op: old.Claim.Op.ID, Phase: old.Claim.Op.Phase} |
| + if _, err := source.Guard.BeginFence(ctx, prior); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := source.Guard.CompleteFence(ctx, prior, []byte("verified-root-receipt"), time.Now().UTC(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + inventory, err := OpenStopInventoryDB(filepath.Join(base, "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { inventory.Close() }) |
| + launches, err := OpenLaunchJournal(filepath.Join(base, "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { launches.Close() }) |
| + owner := registry.ClaimOwnership{ClaimIncarnation: prior.Incarnation, ClaimStamp: prior.Stamp, ClaimOwner: "old-owner", ClaimRowID: "old-route"} |
| + dbPath := filepath.Join(source.Guard.DataDir, old.Host+".db") |
| + if err = os.WriteFile(dbPath, []byte("untrusted-local-database"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.Upsert(registry.Route{Host: old.Host, Kind: "instance", Socket: "/run/example.sock", DBPath: dbPath, ClaimOwnership: owner, HoldOp: "held-operation"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.UpsertTimer(registry.Timer{Host: old.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", NextAt: 123, Attempts: 2}); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = reg.GetTimer(old.Host, "daily") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + parker := TypedParker{Roots: map[string]string{"data": source.Guard.DataDir}, ParkedRoot: source.Guard.DataDir, Incarnation: prior.Incarnation, Host: prior.Host, Retirement: "capture"} |
| + artifact, err := parker.CaptureArtifact("data", old.Host+".db", owner.ClaimOwner, ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in := StopInventory{Complete: true, Host: old.Host, Incarnation: prior.Incarnation, InstallationStamp: prior.Stamp, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", Entries: []StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: old.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}, {Kind: "path", ArtifactID: "database", ManagedRoot: "data", RelativePath: artifact.Name, Ownership: artifact.Owner, Materialization: "materialized", Device: artifact.Dev, Inode: artifact.Ino, Type: artifact.Type}}} |
| + if err = inventory.WriteStopInventory(ctx, in); err != nil { |
| + t.Fatal(err) |
| + } |
| + successor := admit(old, "second") |
| + next := ColdSuccessor{Host: old.Host, Incarnation: prior.Incarnation, Stamp: successor.Claim.Holder.Stamp, Session: successor.Claim.Holder.Session, Generation: successor.Claim.Holder.Generation, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", ETag: successor.ETag, Op: successor.Claim.Op.ID, Phase: successor.Claim.Op.Phase} |
| + handover := ColdHandover{Guard: source.Guard, Claims: store, Registry: reg, Inventory: inventory, Launches: launches, Roots: map[string]string{"data": source.Guard.DataDir}, JournalDir: filepath.Join(base, "handover"), VerifyReceipt: func(context.Context, hostguard.FenceReceipt) error { return nil }} |
| + |
| + durable := release.NewDirStore(filepath.Join(base, "deployment")) |
| + keys, err := store.List(ctx, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + body, _, err := store.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = durable.Put(ctx, key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + manifest := plan14ColdManifest{Base: base, Data: source.Guard.DataDir, Locks: source.Guard.LockDir, Prior: prior, Next: next} |
| + raw, _ := json.Marshal(manifest) |
| + if err = os.WriteFile(filepath.Join(base, "fixture.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + _ = handover |
| + return manifest |
| +} |
| +func plan14ColdOpen(t *testing.T, m plan14ColdManifest) (ColdHandover, *plan14ColdDisk) { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(m.Base, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + inventory, err := OpenStopInventoryDB(filepath.Join(m.Base, "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { inventory.Close() }) |
| + launches, err := OpenLaunchJournal(filepath.Join(m.Base, "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { launches.Close() }) |
| + disk := &plan14ColdDisk{dir: filepath.Join(m.Base, "root")} |
| + store := release.NewDirStore(filepath.Join(m.Base, "deployment")) |
| + g := hostguard.Guard{DataDir: m.Data, LockDir: m.Locks, Claims: hostguard.StoreReader{Store: store}, Root: hostguard.NewRootAuthorityWithInventory(disk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| + _, revision, err := inventory.Snapshot(ctx, host, c) |
| + return revision, err |
| + })} |
| + return ColdHandover{Guard: g, Claims: store, Registry: reg, Inventory: inventory, Launches: launches, Roots: map[string]string{"data": m.Data}, JournalDir: filepath.Join(m.Base, "handover"), VerifyReceipt: func(_ context.Context, r hostguard.FenceReceipt) error { |
| + if string(r.Bytes) != "verified-root-receipt" || r.Target != m.Prior { |
| + return ErrChanged |
| + } |
| + return nil |
| + }}, disk |
| +} |
| +func TestPlan14ColdHandoverProcessBoundaries(t *testing.T) { |
| + if phase := os.Getenv("PLAN14_COLD_PHASE"); phase != "" { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + raw, err := os.ReadFile(filepath.Join(os.Getenv("PLAN14_COLD_DIR"), "fixture.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m plan14ColdManifest |
| + if err = json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + h, disk := plan14ColdOpen(t, m) |
| + h.checkpoint = func(point string) { |
| + if phase == point { |
| + os.Exit(73) |
| + } |
| + } |
| + classify := func(raw []byte) string { |
| + var r struct { |
| + Barrier *hostguard.FenceTarget |
| + State struct { |
| + Stamp uint64 `json:"active_stamp"` |
| + } |
| + } |
| + if json.Unmarshal(raw, &r) != nil { |
| + t.Fatal("bad root") |
| + } |
| + if r.State.Stamp == 0 { |
| + return "retired" |
| + } |
| + if r.Barrier != nil { |
| + return "barrier" |
| + } |
| + if r.State.Stamp == m.Next.Stamp { |
| + return "commit" |
| + } |
| + return "old" |
| + } |
| + disk.before = func(raw []byte) { |
| + if phase == "before-root-"+classify(raw) { |
| + os.Exit(73) |
| + } |
| + } |
| + disk.after = func(raw []byte) { |
| + if phase == "after-root-"+classify(raw) { |
| + os.Exit(73) |
| + } |
| + } |
| + err = h.Run(t.Context(), m.Prior, m.Next) |
| + t.Fatal("boundary not reached", phase, err) |
| + } |
| + for _, phase := range []string{"intent", "parked", "inventory-intent", "rows", "inventory-materialized", "complete-journal", "before-root-barrier", "after-root-barrier", "before-root-retired", "after-root-retired", "before-root-commit", "after-root-commit"} { |
| + t.Run(phase, func(t *testing.T) { |
| + m := plan14ColdSetup(t) |
| + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestPlan14ColdHandoverProcessBoundaries$") |
| + cmd.Dir = m.Base |
| + cmd.Env = append(os.Environ(), "PLAN14_COLD_PHASE="+phase, "PLAN14_COLD_DIR="+m.Base) |
| + output, err := cmd.CombinedOutput() |
| + exit, ok := err.(*exec.ExitError) |
| + if !ok || exit.ExitCode() != 73 { |
| + t.Fatalf("crash: %v %s", err, output) |
| + } |
| + h, _ := plan14ColdOpen(t, m) |
| + effects := 0 |
| + allow := func(*hostclaim.Claim) error { return nil } |
| + prior := hostguard.Captured{Incarnation: m.Prior.Incarnation, Stamp: m.Prior.Stamp} |
| + next := hostguard.Captured{Incarnation: m.Next.Incarnation, Stamp: m.Next.Stamp} |
| + if err = h.Guard.With(t.Context(), m.Prior.Host, prior, allow, func() error { effects++; return nil }); err == nil || effects != 0 { |
| + t.Fatal("predecessor granted", err, effects) |
| + } |
| + if phase != "after-root-commit" { |
| + if err = h.Guard.With(t.Context(), m.Next.Host, next, allow, func() error { effects++; return nil }); err == nil || effects != 0 { |
| + t.Fatal("unfinished successor granted", err, effects) |
| + } |
| + } |
| + if err = h.Recover(t.Context()); err != nil { |
| + t.Fatal("resume", err) |
| + } |
| + if err = h.Run(t.Context(), m.Prior, m.Next); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + if err = h.Guard.With(t.Context(), m.Next.Host, next, allow, func() error { effects++; return nil }); err != nil || effects != 1 { |
| + t.Fatal("successor", err, effects) |
| + } |
| + receipt, err := h.Guard.FenceReceiptFor(t.Context(), m.Prior) |
| + if err != nil || h.VerifyReceipt(t.Context(), receipt) != nil || receipt.Target.BootID != "boot" { |
| + t.Fatal("old receipt changed", receipt, err) |
| + } |
| + if _, err = os.Stat(filepath.Join(m.Data, m.Prior.Host+".db")); !os.IsNotExist(err) { |
| + t.Fatal("old data reused", err) |
| + } |
| + row, err := h.Registry.Get(m.Next.Host) |
| + if err != nil || row.ClaimStamp != m.Next.Stamp { |
| + t.Fatal("rows", row, err) |
| + } |
| + view, err := h.Guard.Root.View(t.Context(), m.Next.Host, next) |
| + if err != nil || view.Barrier != nil || view.Installation.InventoryRevision == 0 { |
| + t.Fatal("root commit", view, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go| index a029793b7..ad963ee66 100644 |
| --- a/internal/hostguard/guard.go |
| +++ b/internal/hostguard/guard.go |
| @@ -134,6 +134,9 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f |
| if err != nil { |
| return err |
| } |
| + if (g.Root != nil || g.FenceView != nil) && c.Holder.Stamp != captured.Stamp { |
| + return ErrStampMismatch |
| + } |
| if g.FenceView != nil { |
| v, err := g.FenceView(ctx, host, captured) |
| if err != nil { |
diff --git a/internal/hostguard/plan14_public_matrix_test.go b/internal/hostguard/plan14_public_matrix_test.go| new file mode 100644 |
| index 000000000..aff93046e |
| --- /dev/null |
| +++ b/internal/hostguard/plan14_public_matrix_test.go |
| @@ -0,0 +1,242 @@ |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func TestPlan14PublicRootTamperMatrix(t *testing.T) { |
| + operations := []string{"Capture", "With", "BeginFence", "CompleteFence", "ExistingFence", "ResumeFence", "FenceReceiptFor", "PendingFences", "CompletedColdPredecessor", "CompletedWriterStops", "CleanupWith", "CleanupRetireInstallationWith", "RetireInstallation", "RetireInstallationWith", "WithNoInstallation", "BeginColdHandover", "CompleteColdHandover"} |
| + for _, attack := range []string{"delete", "rename", "locked replacement", "forged receipt"} { |
| + for _, operation := range operations { |
| + t.Run(attack+"/"+operation, func(t *testing.T) { |
| + f, first, captured := fenceFixture(t) |
| + store := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthority(store) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app" |
| + in := RootInstallation{Host: first.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Migration: 1, InventoryRevision: 1, Account: "bab", App: "app", Record: "record", Box: first.Box, Enrollment: first.Enrollment} |
| + if err := f.g.EstablishRoot(t.Context(), in, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + first.ObligationID = WriterStopObligation(first.Session) |
| + first.Region = "uk" |
| + f.obs.Claim.Holder.WriterBox = first.Box |
| + f.obs.Claim.Holder.Generation = "gen" |
| + f.obs.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: first.Session, Box: first.Box, Region: first.Region, Generation: "gen", Mode: "cold"}} |
| + if _, err := f.g.BeginFence(t.Context(), first); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Now().UTC() |
| + receipt := []byte("root receipt") |
| + if err := f.g.CompleteFence(t.Context(), first, receipt, at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + target := first |
| + target.Session++ |
| + target.FenceID = "pending" |
| + target.ObligationID = WriterStopObligation(target.Session) |
| + f.obs.Claim.Holder.Session = target.Session |
| + f.obs.Claim.Holder.Admissions = append(f.obs.Claim.Holder.Admissions, hostclaim.AdmissionRecord{Session: target.Session, Box: target.Box, Region: target.Region, Generation: "gen", Mode: "cold"}) |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := store.Read(target.Host + ".json") |
| + path := filepath.Join(f.g.DataDir, "hostguard", target.Host+".json") |
| + switch attack { |
| + case "delete": |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "locked replacement": |
| + unlock, err := acquire(t.Context(), f.g.LockDir, target.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unlock() |
| + if err = os.Rename(f.g.LockDir, f.g.LockDir+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.Mkdir(f.g.LockDir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "forged receipt": |
| + forged := record{V: 1, Host: target.Host, Incarnation: captured.Incarnation, ActiveStamp: 99, FenceJournal: []FenceReceipt{{Target: target, Bytes: []byte("forged"), EvidenceAt: at}}} |
| + raw, _ := json.Marshal(forged) |
| + if err := os.WriteFile(path, raw, 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + effects := 0 |
| + effect := func() error { effects++; return nil } |
| + verify := func(_ context.Context, r FenceReceipt) error { |
| + if r.Target != first || !bytes.Equal(r.Bytes, receipt) || !r.EvidenceAt.Equal(at) { |
| + return ErrReceiptChanged |
| + } |
| + return nil |
| + } |
| + bad := target |
| + bad.FenceID = "forged" |
| + stale := Captured{Incarnation: captured.Incarnation, Stamp: 99} |
| + ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel() |
| + var err error |
| + success := false |
| + switch operation { |
| + case "Capture": |
| + var got Captured |
| + got, err = f.g.Capture(target.Host) |
| + if got != captured { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "With": |
| + err = f.g.With(ctx, target.Host, captured, allow, effect) |
| + case "BeginFence": |
| + var got FenceTarget |
| + got, err = f.g.BeginFence(ctx, target) |
| + if got != target { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "CompleteFence": |
| + err = f.g.CompleteFence(ctx, bad, []byte("forged"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }) |
| + case "ExistingFence": |
| + var got FenceTarget |
| + got, err = f.g.ExistingFence(ctx, target) |
| + if got != target { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "ResumeFence": |
| + err = f.g.ResumeFence(ctx, target) |
| + success = true |
| + case "FenceReceiptFor": |
| + var got FenceReceipt |
| + got, err = f.g.FenceReceiptFor(ctx, first) |
| + if verify(ctx, got) != nil { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "PendingFences": |
| + var got []FenceTarget |
| + got, err = f.g.PendingFences(ctx) |
| + if len(got) != 1 || got[0] != target { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "CompletedColdPredecessor": |
| + var got FenceReceipt |
| + got, err = f.g.CompletedColdPredecessor(ctx, first.Host, captured, first.Session, first.Op, first.Phase) |
| + if verify(ctx, got) != nil { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "CompletedWriterStops": |
| + var got []FenceReceipt |
| + got, err = f.g.CompletedWriterStops(ctx, first.Host, captured, first.Op, first.Box) |
| + if len(got) != 1 || verify(ctx, got[0]) != nil { |
| + t.Fatal(got) |
| + } |
| + success = true |
| + case "CleanupWith": |
| + err = f.g.CleanupWith(ctx, bad, verify, effect) |
| + case "CleanupRetireInstallationWith": |
| + err = f.g.CleanupRetireInstallationWith(ctx, bad, verify, effect) |
| + case "RetireInstallation": |
| + err = f.g.RetireInstallation(ctx, target.Host, func(*hostclaim.Claim) error { return ErrFenced }) |
| + case "RetireInstallationWith": |
| + err = f.g.RetireInstallationWith(ctx, target.Host, stale, allow, effect) |
| + case "WithNoInstallation": |
| + err = f.g.WithNoInstallation(ctx, target.Host, captured.Incarnation, allow, effect) |
| + case "BeginColdHandover": |
| + bad.Stamp = 99 |
| + bad.Session = 99 |
| + f.obs.Claim.Holder.Stamp = 99 |
| + f.obs.Claim.Holder.Session = 99 |
| + err = f.g.BeginColdHandover(ctx, target, bad, allow) |
| + case "CompleteColdHandover": |
| + err = f.g.CompleteColdHandover(ctx, bad, false, allow, effect) |
| + } |
| + if success && err != nil || !success && err == nil || effects != 0 { |
| + t.Fatal(operation, err, effects) |
| + } |
| + after, _ := store.Read(target.Host + ".json") |
| + if !bytes.Equal(before, after) { |
| + t.Fatal("root bytes changed") |
| + } |
| + pending, err := f.g.PendingFences(ctx) |
| + if err != nil || len(pending) != 1 || pending[0] != target { |
| + t.Fatal("lost recovery", pending, err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootUnavailableNeverUsesAgentRecord(t *testing.T) { |
| + for _, damage := range []string{"missing", "corrupt"} { |
| + t.Run(damage, func(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + disk := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthority(disk) |
| + if damage == "corrupt" { |
| + if err := disk.Write(target.Host+".json", []byte("{")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + effects := 0 |
| + effect := func() error { effects++; return nil } |
| + verify := func(context.Context, FenceReceipt) error { return nil } |
| + calls := map[string]func() error{ |
| + "Capture": func() error { _, err := f.g.Capture(target.Host); return err }, |
| + "With": func() error { return f.g.With(t.Context(), target.Host, captured, allow, effect) }, |
| + "BeginFence": func() error { _, err := f.g.BeginFence(t.Context(), target); return err }, |
| + "CompleteFence": func() error { |
| + return f.g.CompleteFence(t.Context(), target, []byte("receipt"), time.Now(), func(context.Context, FenceTarget, []byte, time.Time) error { return nil }) |
| + }, |
| + "ExistingFence": func() error { _, err := f.g.ExistingFence(t.Context(), target); return err }, |
| + "ResumeFence": func() error { return f.g.ResumeFence(t.Context(), target) }, |
| + "FenceReceiptFor": func() error { _, err := f.g.FenceReceiptFor(t.Context(), target); return err }, |
| + "CompletedColdPredecessor": func() error { |
| + _, err := f.g.CompletedColdPredecessor(t.Context(), target.Host, captured, target.Session, target.Op, target.Phase) |
| + return err |
| + }, |
| + "CompletedWriterStops": func() error { |
| + _, err := f.g.CompletedWriterStops(t.Context(), target.Host, captured, target.Op, target.Box) |
| + return err |
| + }, |
| + "CleanupWith": func() error { return f.g.CleanupWith(t.Context(), target, verify, effect) }, |
| + "CleanupRetireInstallationWith": func() error { return f.g.CleanupRetireInstallationWith(t.Context(), target, verify, effect) }, |
| + "RetireInstallation": func() error { return f.g.RetireInstallation(t.Context(), target.Host, allow) }, |
| + "RetireInstallationWith": func() error { return f.g.RetireInstallationWith(t.Context(), target.Host, captured, allow, effect) }, |
| + "WithNoInstallation": func() error { |
| + return f.g.WithNoInstallation(t.Context(), target.Host, captured.Incarnation, allow, effect) |
| + }, |
| + "CompleteColdHandover": func() error { return f.g.CompleteColdHandover(t.Context(), target, false, allow, effect) }, |
| + } |
| + for name, call := range calls { |
| + if err := call(); err == nil || effects != 0 { |
| + t.Fatal(name, err, effects) |
| + } |
| + } |
| + if damage == "corrupt" { |
| + if _, err := f.g.PendingFences(t.Context()); err == nil { |
| + t.Fatal("corrupt recovery accepted") |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostguard/plan14_rebind_test.go b/internal/hostguard/plan14_rebind_test.go| new file mode 100644 |
| index 000000000..dd5eda861 |
| --- /dev/null |
| +++ b/internal/hostguard/plan14_rebind_test.go |
| @@ -0,0 +1,105 @@ |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPlan14RootRebind(t *testing.T) { |
| + for _, failure := range []string{"none", "admission", "revision", "identity", "record", "box", "enrollment", "missing", "before commit", "crash"} { |
| + t.Run(failure, func(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + store := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthority(store) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app" |
| + old := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "app", Record: "record", Box: target.Box, Enrollment: target.Enrollment, Migration: 1, InventoryRevision: 3} |
| + if err := f.g.EstablishRoot(t.Context(), old, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Now().UTC() |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("retained receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := store.Read(target.Host + ".json") |
| + next := old |
| + next.Stamp++ |
| + next.InventoryRevision = 1 |
| + f.obs.Claim.Holder.Stamp = next.Stamp |
| + f.obs.Claim.Holder.Session = next.Stamp |
| + revision := old.InventoryRevision |
| + if failure == "revision" { |
| + revision++ |
| + } |
| + if failure == "identity" { |
| + next.Account = "other" |
| + } |
| + if failure == "record" { |
| + next.Record = "other" |
| + } |
| + if failure == "box" { |
| + next.Box = "other" |
| + } |
| + if failure == "enrollment" { |
| + next.Enrollment = "other" |
| + } |
| + if failure == "missing" { |
| + captured.Incarnation = "missing" |
| + } |
| + calls, effects := 0, 0 |
| + crash := errors.New("crash") |
| + check := func(*hostclaim.Claim) error { |
| + calls++ |
| + if failure == "admission" || failure == "before commit" && calls == 2 { |
| + return ErrFenced |
| + } |
| + return nil |
| + } |
| + commit := func() error { |
| + if failure == "crash" { |
| + return crash |
| + } |
| + effects++ |
| + return nil |
| + } |
| + err := f.g.RebindRootInventory(t.Context(), captured, revision, next, []byte("intent"), check, commit) |
| + if failure == "crash" { |
| + if !errors.Is(err, crash) { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.g.Root.View(t.Context(), target.Host, captured); !errors.Is(err, ErrFenced) { |
| + t.Fatal("pending allowed", err) |
| + } |
| + f.g.Root = NewRootAuthority(store) |
| + err = f.g.RebindRootInventory(t.Context(), captured, revision, next, []byte("intent"), allow, func() error { effects++; return nil }) |
| + } |
| + if failure != "none" && failure != "crash" { |
| + if err == nil || effects != 0 { |
| + t.Fatal("refusal bypass", err, effects) |
| + } |
| + if failure != "before commit" { |
| + after, _ := store.Read(target.Host + ".json") |
| + if string(after) != string(before) { |
| + t.Fatal("refusal changed root") |
| + } |
| + } |
| + return |
| + } |
| + if err != nil || effects != 1 { |
| + t.Fatal(err, effects) |
| + } |
| + if err = f.g.RebindRootInventory(context.Background(), captured, revision, next, []byte("intent"), allow, func() error { t.Fatal("replay effect"); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + view, err := f.g.Root.View(t.Context(), target.Host, Captured{Incarnation: next.Incarnation, Stamp: next.Stamp}) |
| + if err != nil || view.Installation != next || view.Pending == nil || *view.Pending != target || len(view.Retired) != 1 || view.Retired[0] != captured || len(view.Receipts) != 1 || view.Receipts[0].Target != target || string(view.Receipts[0].Bytes) != "retained receipt" || !view.Receipts[0].EvidenceAt.Equal(at) { |
| + t.Fatal("lost denials", view, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go| index 9524e9a9b..515990b7c 100644 |
| --- a/internal/hostguard/record.go |
| +++ b/internal/hostguard/record.go |
| @@ -29,6 +29,7 @@ type record struct { |
| root bool |
| installation RootInstallation |
| rootRetired []Captured |
| + rebindCommit []byte |
| pendingInventory []byte |
| pendingInstallation *RootInstallation |
| barrier *FenceTarget |
diff --git a/internal/hostguard/root.go b/internal/hostguard/root.go| index 572ba71de..059808de1 100644 |
| --- a/internal/hostguard/root.go |
| +++ b/internal/hostguard/root.go |
| @@ -26,6 +26,7 @@ type RootAuthority struct { |
| func NewRootAuthority(store rootstate.Store) *RootAuthority { return &RootAuthority{store: store} } |
| type rootRecord struct { |
| + RebindCommit []byte `json:",omitempty"` |
| Schema int |
| Installation RootInstallation |
| Retired []Captured |
| @@ -57,6 +58,7 @@ func (a *RootAuthority) read(host string) (record, error) { |
| r.installation = saved.Installation |
| r.rootRetired = saved.Retired |
| r.barrier = saved.Barrier |
| + r.rebindCommit = saved.RebindCommit |
| r.pendingInventory = saved.PendingInventory |
| r.pendingInstallation = saved.PendingInstallation |
| if (len(r.pendingInventory) > 0) != (r.pendingInstallation != nil) { |
| @@ -72,7 +74,7 @@ func (a *RootAuthority) write(r record) error { |
| return ErrMalformedRecord |
| } |
| r.installation.Host, r.installation.Incarnation, r.installation.Stamp = r.Host, r.Incarnation, r.ActiveStamp |
| - b, err := json.Marshal(rootRecord{Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, PendingInventory: r.pendingInventory, PendingInstallation: r.pendingInstallation, State: r}) |
| + b, err := json.Marshal(rootRecord{RebindCommit: r.rebindCommit, Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, PendingInventory: r.pendingInventory, PendingInstallation: r.pendingInstallation, State: r}) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/hostguard/rootrebind.go b/internal/hostguard/rootrebind.go| new file mode 100644 |
| index 000000000..aa58445f7 |
| --- /dev/null |
| +++ b/internal/hostguard/rootrebind.go |
| @@ -0,0 +1,76 @@ |
| +package hostguard |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func (g Guard) RebindRootInventory(ctx context.Context, prior Captured, revision uint64, next RootInstallation, intent []byte, check func(*hostclaim.Claim) error, commit func() error) error { |
| + if g.Root == nil || check == nil || commit == nil || len(intent) == 0 || prior.Incarnation != next.Incarnation || prior.Stamp == 0 || next.Stamp <= prior.Stamp || revision == 0 || next.InventoryRevision == 0 { |
| + return ErrInvalidInput |
| + } |
| + return g.locked(ctx, next.Host, func(d *recordDir) error { |
| + c, err := g.fresh(ctx, next.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if err = check(c); err != nil { |
| + return err |
| + } |
| + if c.Holder.Incarnation != next.Incarnation || c.Holder.Stamp != next.Stamp || c.Holder.Account != next.Account || c.Holder.App != next.App { |
| + return ErrClaimIncarnation |
| + } |
| + r, err := d.read(next.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if r.barrier != nil { |
| + return ErrFenced |
| + } |
| + want := Captured{Incarnation: next.Incarnation, Stamp: next.Stamp} |
| + for _, denied := range r.rootRetired { |
| + if denied == want { |
| + return ErrRetiredStamp |
| + } |
| + } |
| + if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, want) { |
| + return ErrFenced |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target.matchesSession(c, want) { |
| + return ErrFenced |
| + } |
| + } |
| + if r.installation == next && r.pendingInstallation == nil && bytes.Equal(r.rebindCommit, intent) { |
| + return d.sync(next.Host) |
| + } |
| + old := r.installation |
| + if r.Incarnation != prior.Incarnation || r.ActiveStamp != prior.Stamp || old.InventoryRevision != revision || old.Empty || old.Account != next.Account || old.App != next.App || old.Record != next.Record || old.Box != next.Box || old.Enrollment != next.Enrollment || old.Migration != next.Migration { |
| + return ErrRecordIncarnation |
| + } |
| + if r.pendingInstallation != nil && (*r.pendingInstallation != next || !bytes.Equal(r.pendingInventory, intent)) { |
| + return ErrFenced |
| + } |
| + r.pendingInventory = append([]byte(nil), intent...) |
| + r.pendingInstallation = &next |
| + if err = d.write(r); err != nil { |
| + return err |
| + } |
| + c, err = g.fresh(ctx, next.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if err = check(c); err != nil { |
| + return err |
| + } |
| + if err = commit(); err != nil { |
| + return err |
| + } |
| + r.retire() |
| + r.Incarnation, r.ActiveStamp, r.installation = next.Incarnation, next.Stamp, next |
| + r.pendingInventory, r.pendingInstallation = nil, nil |
| + r.rebindCommit = append([]byte(nil), intent...) |
| + return d.write(r) |
| + }) |
| +} |
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go| index 8220c8414..ee05dd70e 100644 |
| --- a/internal/instancectl/server.go |
| +++ b/internal/instancectl/server.go |
| @@ -274,6 +274,7 @@ func (s *Server) Handler() http.Handler { |
| mux.Handle("POST /claim/stop", s.ClaimStop) |
| mux.Handle("POST /claim/retire", s.ClaimStop) |
| mux.Handle("POST /claim/handover", s.ClaimStop) |
| + mux.Handle("POST /claim/recovery-rebind", s.ClaimStop) |
| mux.Handle("POST /claim/journal", s.ClaimStop) |
| mux.Handle("POST /claim/writers-stopped", s.ClaimStop) |
| mux.Handle("POST /claim/launch", s.ClaimStop) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 9b993a90f..66c508dd5 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -472,7 +472,10 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| - {"internal/host/rootinventory.go", "ClaimRootHTTP.commitRootInventoryPending", "key-site", "key:RecordKey", "01d68d11920d03f181586c30f00dbfc63f3db6f25f91619646f3e67e10a54cdd"}, |
| + {"internal/host/rootrecovery_rebind.go", "type recoveryRebindRequest", "key-site", "key:RecordKey", "b5db3e4dcd4807edea1d053eb1787fbf222be97d3f892e2ab66771457a51dc3d"}, |
| + {"internal/host/rootrecovery_rebind.go", "ClaimRootHTTP.rebindRecoveryInventory", "key-site", "key:RecordKey", "70f47ac54692802662b353edb4d89cdb2617fc154dde6f2b698e488b8356baf1"}, |
| + {"internal/host/claimrecovery_remote.go", "claimCrossBoxHandover.installRecoveryRemote", "key-site", "key:RecordKey", "d60c3ab46f7b909fa22c648e883e402345d2652a3c54640c3981b13c5c36e168"}, |
| + {"internal/host/rootinventory.go", "ClaimRootHTTP.commitRootInventoryPending", "key-site", "key:RecordKey", "ade35a06541ea830036d32dace3198b5f41a2506ad7e89c84b663408c3e52014"}, |
| {"internal/host/rootrecord_claim.go", "rootClaimRecord", "key-site", "key:RecordKey,key:SidecarRecordKey", "e7f73adcaa45e4dfefc5743f43e9ab83567ac95a74ae0557e789478a58e64da4"}, |
| {"internal/hostbox/inventory_expected.go", "StopInventoryDB.WriteExpected", "key-site", "key:RecordKey", "8c2824998dcb510ed54c08a74846352c7918fe1ba2d7ffb3dd05596ca81b329b"}, |
| {"internal/hostbox/inventory_expected.go", "type InventoryExpectation", "key-site", "key:RecordKey", "c9d359aac6c5e352f31acfbd66548e09be10a5e621b5bf15da61b4ad3fd9feff"}, |
diff --git a/internal/provision/testdata/plan14-root-reads.tsv b/internal/provision/testdata/plan14-root-reads.tsv| index 2e2102b6a..8d8670a67 100644 |
| --- a/internal/provision/testdata/plan14-root-reads.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads.tsv |
| @@ -51,17 +51,17 @@ |
| @coverage internal/geo 5886c699c4902a1162ee9640471b8d5e3d2fc18f1dd0d43b70b0f8a459d66b02 |
| @coverage internal/geo/data 27dcfc47493fe7ae61cffa81a700a9ac09e8253fdc88e20da486cc651805af68 |
| @coverage internal/geoip 14994dadea58daebdb9772d8591e916353803fb495de7443a4167682c84cbd37 |
| -@coverage internal/host 13ffdfcafd7b4c7140709742d2ec375e48e4664909fc2cdcb758b3f44f605907 |
| +@coverage internal/host 61a31a5fc5c06a6aae267e0810c040061e1401a19d5f4b22aa20cdfc55884254 |
| @coverage internal/host/units d45d8d20dc5c3fc7d00e6106d2a0f3475ef04c6cd988fa0b0717fa04b0dd119f |
| @coverage internal/hostactors e5e2e7daed861dba7b57151a6ba023d6e545dfc53d8c6f894d3217cf320bcaf7 |
| -@coverage internal/hostbox d594aab417712986e964c171629e95c4357b8fdb29ad8f6fe9d971a4de8f4b75 |
| +@coverage internal/hostbox e797d568b8183bc563acb4ae716b9dd4b586d28c6bc231efa3b31a18c0860e23 |
| @coverage internal/hostclaim 242add93e1bc7c1d409710db0b0bbb4cccb3dd0ee85e89f217549f1aacfb41ce |
| @coverage internal/hostevidence d85c7894b2c959aaac24176846e0a5b6a28861003d1207d830827fd90b6c4d78 |
| @coverage internal/hostexec 94f7eae800d6f985552854757c6f011501c17696eeb564af1c15e2dc145d8c73 |
| @coverage internal/hostfence 2705d961a8ffd650e1096c9a87085eef267c2de9c223c1993f249b7711a6c2b2 |
| @coverage internal/hostfiles 08ab5139a8b38d1baee773a7a1056e0b6fb9fbb4e3b5cbb2be0e968cc8bea86e |
| @coverage internal/hostfloor 5bf88a24c5671c612befb8bd1f8b719aa2037e812a34488853b906a95d1650c7 |
| -@coverage internal/hostguard 9c9ce197927f5ed7f2decd6625e83eed5b1d45e668b178b1c840d9fd9c249394 |
| +@coverage internal/hostguard dfc4aa46f6f1185bfcacb26b8f7f8027f191a534889f775fc1ec64916d4bed2b |
| @coverage internal/hostops 36b1499a44f3367ebbcc4cb4f4129978af3cc1a6e2b9e27e55ae18f17afec25c |
| @coverage internal/hostops/fencerelease b8cf08bdb7dfdc622f4f91d089570e0994141e03beef68a070202d985c423513 |
| @coverage internal/hostops/internal/fencerelease 36ec37feda681a0caf19d6b9452f27eb6a45fd8323154920c8fd29fafbac68ca |
| @@ -69,7 +69,7 @@ |
| @coverage internal/hostresolve bfb22dfb0e058a597d650ee90b34ced8571076bbb0e400b429b22de189f7265d |
| @coverage internal/hostwrite 3f0ccc29b9578a090425d1d267b338483b785322ca569bfb3f4c8fc3a84b7018 |
| @coverage internal/infraview 98b62b27e9188fbd97401b9cd9c46f21eee429fcd0b6fb03a61c578b81320f8e |
| -@coverage internal/instancectl 6d56e425d972f95979c97001f87a88a5cd0aeab0da4a2f70ac81e3095a6907d9 |
| +@coverage internal/instancectl 42cde2d57be3e242f91d5128775668f417caf17f1b3b056fd66ef29f666e9dd7 |
| @coverage internal/instanceexec 1dd932505c4619047354d6fd5197b30292ebca96600e63eebb1f6c6c97050800 |
| @coverage internal/instancefs d72ffa8598f988ae6bd265c6488e4615fb76546be8823c1a8e8f9cffe4e95117 |
| @coverage internal/instancekey 21565f446d562ef04ac2c1271eacd59402ae6af957a97ce2603c896575929b14 |
| @@ -1075,14 +1075,14 @@ internal/host/claimbackfill.go newClaimBackfill authority C,G,J4 root-input;kept |
| internal/host/claimcensus.go claimUnitCensus authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@569 82ed33ee9375b7d41d68637cedece421876874f535ebcf801b812f9e3b1bd10e |
| internal/host/claimcleanup_identity.go claimCleanupEnrollment.configureSigner authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@268 2a4b3bd51f544a5bdd11033389f9d6585785567555d3c12695e335799c756cd6 |
| internal/host/claimcleanup_identity.go verifyClaimCleanupEnrollment authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@173 7a48c65538d68dd362442152e099d8542890ba3241afbbbff01a9c153379ca14 |
| -internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@4059,file:Read@8922,file:ReadFile@3603,registry:Host@8949,whole:Route@9037 b47c334a3c683920f237333dba2bbd927137d5ff4a8bfd0ad6320f0d21d2a9a2 |
| +internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@4059,file:Read@8978,file:ReadFile@3603,registry:Host@9005,whole:Route@9093 b5d228088ebf5e3c4fe8f3db225a9ad862f0d14d5cfa01ca25118fe5addc19c6 |
| internal/host/claimconstruction.go claimConstructionProcessStart authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@68 0681ee9abf64de4aca4b4ba035e1732ed2560ca41564eea6cb854a76bee99f45 |
| internal/host/claimconstruction.go refusingRootWake authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Prepare@641,file:Read@123,file:Read@202 20f7779f6e5cb16b383707ff2c16f3ca0318673e48d20245aed65bfba52ba453 |
| internal/host/claimhandover_crossbox.go claimCrossBoxHandover.After authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@2009,file:Exec@716 a0fb9c27f5be5fce8204611f6421522529754474cf52f980582f7afab141d3e2 |
| internal/host/claimhandover_crossbox.go claimCrossBoxHandover.Before authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@533 51fea34804567960b91cb198639939cbbe4a1ed3a00e7262a6723e867893396c |
| internal/host/claimhandover_crossbox.go claimCrossBoxHandover.sourceEvidence authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@444 ab20e27f2c9ca2bf535559cd3d25e6194f29c75d31cfd1cd42e42f08f64c1fdb |
| internal/host/claimhandover_crossbox.go wireClaimAdmission authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@488,file:Exec@872 5cf543fae022f4daf93deef3ae26b573ad5ac60761e2f7c6aa9deaa3f49274b7 |
| -internal/host/claimhandover_recovery.go claimCrossBoxHandover.afterRecovery authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@498,file:Read@1343 515daf170f7ed6a459efd4a2c1bd5a3f2e45f8da8c52d2127e4ed8324d4b7924 |
| +internal/host/claimhandover_recovery.go claimCrossBoxHandover.afterRecovery authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@498,file:Read@1343 32894061300777dd65f7d5b7e2bd0c3f85de6742683cf4650c69f3de458667c9 |
| internal/host/claimhandover_recovery.go claimCrossBoxHandover.installRecoveryInventory authority C,G,J1 root-route;kept=C,G;scope=protected-decision-only whole:Route@1530 cfd8bd887f8da601eee81054d9f1d66a5dbd3271f83ceb7ddd6f772f11d439d2 |
| internal/host/claimhandover_recovery.go claimCrossBoxHandover.rebindRecoveryRows authority C,G,J1 root-route;kept=C,G;scope=protected-decision-only registry:ClaimIncarnation@289,registry:ClaimOwnership@1034,registry:ClaimOwnership@1123,registry:ClaimOwnership@1494,registry:ClaimOwnership@1837,registry:ClaimOwnership@408,registry:ClaimRowID@1673,registry:ClaimRowID@2022,registry:ClaimRowID@2429,registry:ClaimRowID@2581,registry:ClaimStamp@333,registry:ClaimStamp@363,registry:Name@1174,registry:Name@1811,registry:Name@2551,registry:Route@1034,registry:Route@1494,registry:Route@1673,registry:Route@2429,registry:Route@2663,registry:Timers@1107,registry:Timers@1123,registry:Timers@1174,registry:Timers@1760,registry:Timers@1811,registry:Timers@1837,registry:Timers@2022,registry:Timers@2500,whole:ClaimOwnership@1045,whole:ClaimOwnership@1138,whole:ClaimOwnership@1504,whole:ClaimOwnership@1851,whole:ClaimOwnership@412,whole:ColdRows@2761,whole:ColdRows@2766,whole:Route@1039,whole:Route@1498,whole:Route@1678,whole:Route@2433,whole:Route@2667,whole:Route@275,whole:Route@581,whole:Route@589,whole:Route@671,whole:Route@679,whole:Timer@1112,whole:Timer@1128,whole:Timer@1179,whole:Timer@1764,whole:Timer@1815,whole:Timer@1841,whole:Timer@2027,whole:Timer@2504,whole:Timer@594,whole:Timer@632,whole:Timer@684,whole:Timer@722 c500077bc8d671a220341ab335d7cfa08990bfe6e8c54ccaf964e29916d64a64 |
| internal/host/claimhandover_recovery.go claimCrossBoxHandover.recoveryEvidence authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@454 493897b5d66f1755317c8c8806d3b95149ff2d2e00af301862e698e113e6052e |
| @@ -1575,7 +1575,7 @@ internal/hostactors/reservations.go Directory.Start legacy accepted-legacy accep |
| internal/hostactors/roster.go Directory.Lookup legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@102 73f71b6d730e64d1b24acb1ee8e4c972a9df1fd60087098332b93c7f591f0e55 |
| internal/hostactors/roster.go Directory.Read legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@19 01d1bdcd2418b11a845ed8b7ffca117f8239080edc4869c466574da28380153f |
| internal/hostactors/roster.go Directory.Resolve legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@95 aa0b6935d153786574b2bfbe10f19bc077a1884d88057ca1d805c71272acc0c6 |
| -internal/hostbox/coldhandover.go ColdHandover.Run authority C,J2,J4 route-and-input file:Exec@938,file:Lstat@9665,file:OpenRoot@9587,file:Read@1290,registry:Addr@3565,registry:Addrs@3583,registry:Backing@3602,registry:Canary@3659,registry:ClaimIncarnation@3443,registry:ClaimIncarnation@3945,registry:ClaimOwner@10415,registry:ClaimOwner@4027,registry:ClaimOwner@4047,registry:ClaimOwner@5864,registry:ClaimOwner@7292,registry:ClaimOwnership@10007,registry:ClaimOwnership@3413,registry:ClaimOwnership@4391,registry:ClaimOwnership@4609,registry:ClaimOwnership@4642,registry:ClaimRowID@10305,registry:ClaimRowID@10386,registry:ClaimRowID@4065,registry:ClaimRowID@4673,registry:ClaimRowID@7317,registry:ClaimStamp@3488,registry:ClaimStamp@3992,registry:Host@7254,registry:Kind@3521,registry:Name@4727,registry:Name@7273,registry:Platform@3643,registry:Registration@4091,registry:Registration@7342,registry:Replica@3673,registry:Route@10007,registry:Route@4391,registry:Route@4642,registry:Route@5173,registry:Route@5864,registry:Timers@4590,registry:Timers@4609,registry:Timers@4673,registry:Timers@4727,registry:Timers@7129,registry:Tunnel@3623,registry:Unit@3547,whole:ClaimOwnership@10021,whole:ClaimOwnership@3417,whole:ClaimOwnership@4403,whole:ClaimOwnership@4625,whole:ClaimOwnership@4654,whole:ColdRows@10009,whole:ColdRows@11084,whole:ColdRows@11094,whole:ColdRows@4888,whole:ColdRows@4896,whole:ColdRows@4904,whole:ColdRows@4911,whole:ColdRows@5175,whole:ColdRows@5866,whole:ColdRows@7131,whole:Route@10015,whole:Route@3398,whole:Route@3758,whole:Route@4190,whole:Route@4198,whole:Route@4249,whole:Route@4257,whole:Route@4397,whole:Route@4648,whole:Route@5182,whole:Route@5873,whole:Timer@3929,whole:Timer@4203,whole:Timer@4211,whole:Timer@4262,whole:Timer@4300,whole:Timer@4596,whole:Timer@4615,whole:Timer@4679,whole:Timer@4733,whole:Timer@7138 319aaeb4dbd2593293ed6df27cdd2e7f913c6f883945f4dfaac4c5cd27f5fa98 |
| +internal/hostbox/coldhandover.go ColdHandover.Run authority C,J2,J4 route-and-input file:Exec@938,file:Lstat@9749,file:OpenRoot@9671,file:Read@1290,registry:Addr@3565,registry:Addrs@3583,registry:Backing@3602,registry:Canary@3659,registry:ClaimIncarnation@3443,registry:ClaimIncarnation@3945,registry:ClaimOwner@10499,registry:ClaimOwner@4027,registry:ClaimOwner@4047,registry:ClaimOwner@5922,registry:ClaimOwner@7350,registry:ClaimOwnership@10091,registry:ClaimOwnership@3413,registry:ClaimOwnership@4391,registry:ClaimOwnership@4609,registry:ClaimOwnership@4642,registry:ClaimRowID@10389,registry:ClaimRowID@10470,registry:ClaimRowID@4065,registry:ClaimRowID@4673,registry:ClaimRowID@7375,registry:ClaimStamp@3488,registry:ClaimStamp@3992,registry:Host@7312,registry:Kind@3521,registry:Name@4727,registry:Name@7331,registry:Platform@3643,registry:Registration@4091,registry:Registration@7400,registry:Replica@3673,registry:Route@10091,registry:Route@4391,registry:Route@4642,registry:Route@5173,registry:Route@5922,registry:Timers@4590,registry:Timers@4609,registry:Timers@4673,registry:Timers@4727,registry:Timers@7187,registry:Tunnel@3623,registry:Unit@3547,whole:ClaimOwnership@10105,whole:ClaimOwnership@3417,whole:ClaimOwnership@4403,whole:ClaimOwnership@4625,whole:ClaimOwnership@4654,whole:ColdRows@10093,whole:ColdRows@11205,whole:ColdRows@11215,whole:ColdRows@4888,whole:ColdRows@4896,whole:ColdRows@4904,whole:ColdRows@4911,whole:ColdRows@5175,whole:ColdRows@5924,whole:ColdRows@7189,whole:Route@10099,whole:Route@3398,whole:Route@3758,whole:Route@4190,whole:Route@4198,whole:Route@4249,whole:Route@4257,whole:Route@4397,whole:Route@4648,whole:Route@5182,whole:Route@5931,whole:Timer@3929,whole:Timer@4203,whole:Timer@4211,whole:Timer@4262,whole:Timer@4300,whole:Timer@4596,whole:Timer@4615,whole:Timer@4679,whole:Timer@4733,whole:Timer@7196 e091f8a3947789e5788af79e4a690873fff3791fc56651de6c59962b4cc306d1 |
| internal/hostbox/coldhandover.go ColdHandover.retireIntended authority C,J2,J4 route-and-input file:Lstat@2211,registry:ClaimOwner@800,registry:Route@800,whole:ColdRows@802,whole:Route@809 7f147bc8d065bf5de0dc94838bb4f9f569059a8a061d936e9446a7d88a785193 |
| internal/hostbox/coldhandover.go LaunchJournal.HasInstallationEffects authority C,J2,J4 root-input file:QueryContext@460 d773640800e6df699e5ce04e64cd7168892fb23249a5ced454c3f0cb72263810 |
| internal/hostbox/coldhandover.go coldNamespaceEmpty authority C,J2,J4 root-input file:Lstat@222,file:WalkDir@369 6e9ec76f98e6486ae36d02182f428564210c8742539ada6194fd989c31a76400 |
| @@ -4218,12 +4218,12 @@ internal/host/rootroute.go proofBindingSource.needsRestart authority B3,J1 trust |
| internal/host/rootroute.go rootRecordRoute authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery registry:Backing@545,registry:Health@924,registry:Kind@683,registry:Kind@708,registry:Socket@534,registry:Socket@956,registry:Unit@525,registry:Unit@628,registry:Unit@669,whole:Route@1064 765c71d7baeb75901a8af796fe909841ca9e0b700febf9a62305f909fd821dd6 |
| internal/host/rootroute.go sameProofCandidate authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Account@110,registry:Account@89,registry:Addr@273,registry:Addr@291,registry:Addrs@301,registry:Addrs@320,registry:App@123,registry:App@140,registry:Backing@391,registry:Backing@412,registry:Health@331,registry:Health@351,registry:Host@149,registry:Host@167,registry:Kind@363,registry:Kind@381,registry:Socket@241,registry:Socket@261,registry:Unit@178,registry:Unit@212,registry:Unit@230 a817b55fd4f4c480d0230399bc1a161833150f4d5844de4a96e5a2b981c0320e |
| internal/hostguard/recoveryfence.go Guard.recordNames authority C,J1,J4 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:ReadDir@119 e44562b3d64921be270d315e0434c60ebef5dda5087f48344fe70a35fed4ae91 |
| -internal/hostguard/root.go RootAuthority.read authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@175 29b70745b38714684b66b3626772df0ecef5b6dae6f6daa42d9c43347447c703 |
| +internal/hostguard/root.go RootAuthority.read authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@175 68293272d0c8b312ea78cacab333f014e56b283869e1612d32d5edd441053114 |
| internal/rootstate/file_linux.go Identity authority A,B3 descriptor-checked root rollback capability identity/removal file:Fstat@81 eb8b39ca4c1fff35019f0e9cb93a33378173c543060d24517bf1e9ab607df2a5 |
| internal/rootstate/state_linux.go directory.Remove authority A,B3 descriptor-checked root rollback capability identity/removal file:Read@200 884759a5ab53bca8c61f5942a458fc6c4bd4431f0697bff34041d806ad4aeed8 |
| internal/host/proofbinding.go proofBindingSource.prepareRollback authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery registry:Host@129,whole:Route@131 b407f57b45306fbeb3367fcf7d0f68c21254a355977e4232dc70cd1d59be3fb8 |
| internal/host/rootinstallation.go rootInstallationHostApp authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@482,registry:Account@1082,registry:App@1111 caf21fb3208bb812f635bf639cbc609c6a1fcc9e43caa75d23ab8fb680387597 |
| -internal/host/rootinventory.go ClaimRootHTTP.commitRootInventoryPending authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@1038,file:Read@2122,registry:Account@3080,registry:Account@3095,registry:App@3116,registry:App@3127,registry:ClaimIncarnation@2605,registry:ClaimOwner@2655,registry:ClaimOwner@3600,registry:ClaimOwnership@3178,registry:ClaimOwnership@3200,registry:ClaimRowID@2819,registry:ClaimRowID@2896,registry:ClaimStamp@2633,registry:DBPath@3144,registry:DBPath@3158,registry:Host@3050,registry:Host@3062,whole:ClaimOwnership@3182,whole:ClaimOwnership@3210,whole:Route@3035,whole:Route@3273,whole:Route@3486 01d68d11920d03f181586c30f00dbfc63f3db6f25f91619646f3e67e10a54cdd |
| +internal/host/rootinventory.go ClaimRootHTTP.commitRootInventoryPending authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@1038,file:Read@2122,registry:Account@3080,registry:Account@3095,registry:App@3116,registry:App@3127,registry:ClaimIncarnation@2605,registry:ClaimOwner@2655,registry:ClaimOwner@3600,registry:ClaimOwnership@3178,registry:ClaimOwnership@3200,registry:ClaimOwnership@3914,registry:ClaimRowID@2819,registry:ClaimRowID@2896,registry:ClaimStamp@2633,registry:DBPath@3144,registry:DBPath@3158,registry:Host@3050,registry:Host@3062,whole:ClaimOwnership@3182,whole:ClaimOwnership@3210,whole:ClaimOwnership@3924,whole:Route@3035,whole:Route@3273,whole:Route@3486 ade35a06541ea830036d32dace3198b5f41a2506ad7e89c84b663408c3e52014 |
| internal/host/rootinventory.go ClaimRootHTTP.verifyInventoryPath authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Lstat@315,file:OpenFile@556,file:Stat@728 a9071854c0b1a88a6a1034219b2b0a4d4c414676be4f6ced9121c2326deef805 |
| internal/host/rootinventory_state_linux.go ClaimRoleAssembly.prepareStateInventory authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Fstatat@1313,file:Fstatat@2308,file:Open@799,file:Openat@1086 8122e1b079c30af97474d28a384302d0b2cec24db656987dc170bb6601de6ddf |
| internal/host/rootrecord_claim.go rootClaimRecord authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering registry:Account@1601,registry:App@1628,registry:DBPath@1522,whole:Route@1132,whole:Route@1258,whole:Route@1352,whole:Route@1383,whole:Route@1729,whole:Route@385,whole:Route@482,whole:Route@721,whole:Route@860,whole:Route@878 e7f73adcaa45e4dfefc5743f43e9ab83567ac95a74ae0557e789478a58e64da4 |
| @@ -4235,3 +4235,10 @@ internal/hostbox/inventory_registration.go ReconcileAdapter.registerRemote autho |
| internal/hostbox/journalservice.go JournalService.execute authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Create@2579,file:QueryRowContext@1592,file:QueryRowContext@580 cf1e786c49cf930110ae55b687dc838f7424986c041d8f110145bfa1b77d6b60 |
| internal/hostbox/reconcile.go Reconciler.installWithWork authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@392 372be465c9b6285a04fa923222e810ffb7808f2d4ea730e659042c99ad8ca99a |
| internal/host/claimconstruction.go var openClaimRootAuthority authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Open@71 77bde06b9fa9565a32c3add3de28e02d853397aeab892dab202c2985f8cf64c8 |
| +internal/host/claimrecovery_remote.go claimCrossBoxHandover.installRecoveryRemote authority C,J1 root installation and fence authority; recovery rebind and timer identity file:Read@2566,file:Read@912 d60c3ab46f7b909fa22c648e883e402345d2652a3c54640c3981b13c5c36e168 |
| +internal/host/rootinventory_timer.go ClaimRootHTTP.verifyInventoryTimer authority C,J1 root installation and fence authority; recovery rebind and timer identity registry:ClaimIncarnation@943,registry:ClaimIncarnation@969,registry:ClaimOwner@1035,registry:ClaimOwner@299,registry:ClaimRowID@1070,registry:ClaimStamp@1015,registry:ClaimStamp@995,registry:Host@899,registry:Name@921,whole:Timer@709 1b326196f0e14c71c0a2f2efa0117b0831800ededb08d97e3eae9aac4d9c85ed |
| +internal/host/rootrecovery_rebind.go ClaimRootHTTP.rebindRecoveryInventory authority C,J1 root installation and fence authority; recovery rebind and timer identity file:Exec@2260,file:Read@1225,file:Read@1764,registry:Account@2981,registry:App@3018,registry:ClaimIncarnation@3081,registry:ClaimOwner@3261,registry:ClaimRowID@3283,registry:ClaimStamp@3109,registry:Host@2953,whole:Route@3402 70f47ac54692802662b353edb4d89cdb2617fc154dde6f2b698e488b8356baf1 |
| +internal/hostguard/rootrebind.go Guard.RebindRootInventory authority C,J1 root installation and fence authority; recovery rebind and timer identity 3aaeba49034411d7c205a61c9d82ef9886c6361e9cc7e315d6140fce2816bdc5 |
| +internal/hostguard/guard.go Guard.With authority C,J1 root installation and fence authority; recovery rebind and timer identity ecb3279b4a29ba2dfe910fc005775ff1f8030d1e0766cf12b015c87b05fc02d2 |
| +internal/host/rootinventory.go ClaimRootHTTP.recoverRootInventory authority C,J1 root installation and fence authority; recovery rebind and timer identity 320bf6ba97658d637b9f9d18d1d00001964e8159e5b4052cdf7006d6f589f892 |
| +internal/host/rootrecovery_rebind.go ClaimRootHTTP.serveRecoveryRebind authority C,J1 root installation and fence authority; recovery rebind and timer identity 16e4d13a82228ab2badfd6d3a16416f46a383118509bfe5d1c939d21b6742191 |