carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev bb7eb3ac8c0dcfffa154719791f2b1c02c84b478 parent b436907
25 files changed, +1683 −63
  • internal/host/claimconstruction.go +1 −1
  • internal/host/claimhandover_crossbox.go +1 −0
  • internal/host/claimhandover_recovery.go +3 −0
  • internal/host/claimhandover_recovery_test.go +27 −2
  • internal/host/claimrecovery_remote.go +118 −0
  • internal/host/claimretire_transport.go +5 −1
  • internal/host/plan14_recovery_source_test.go +103 −47
  • internal/host/rootinstallation_test.go +46 −0
  • internal/host/rootinventory.go +16 −0
  • internal/host/rootinventory_timer.go +33 −0
  • internal/host/rootinventory_timer_test.go +95 −0
  • internal/host/rootrecovery_process_test.go +116 −0
  • internal/host/rootrecovery_rebind.go +197 −0
  • internal/host/rootrecovery_rebind_test.go +146 −0
  • internal/hostbox/coldhandover.go +19 −1
  • internal/hostbox/plan14_cold_process_test.go +306 −0
  • internal/hostguard/guard.go +3 −0
  • internal/hostguard/plan14_public_matrix_test.go +242 −0
  • internal/hostguard/plan14_rebind_test.go +105 −0
  • internal/hostguard/record.go +1 −0
  • internal/hostguard/root.go +3 −1
  • internal/hostguard/rootrebind.go +76 −0
  • internal/instancectl/server.go +1 −0
  • internal/provision/plan9_b8_census_test.go +4 −1
  • internal/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