WIP files seed checkpoint 33 (not for merge)
Boot-aware handover identity: equal device numbers across boots no longer skip persistent verification; audited for the same fast path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 files changed,
+265
−34
internal/host/claimfilesseed_integration_linux_test.go+3 −3internal/host/claimfilesseed_reboot_rootrunner_linux_test.go+3 −0internal/host/claimfilesseed_renumber_rootrunner_linux_test.go+1 −0internal/host/claimfilesseed_restore_linux_test.go+7 −0internal/host/claimhandover_recovery_test.go+7 −2internal/host/claimservinghandover_linux_test.go+13 −6internal/host/claimservinghandover_reboot_linux.go+7 −1internal/host/claimservinghandover_reboot_linux_test.go+120 −4internal/host/claimservinghandover_reboot_other_test.go+11 −0internal/host/claimservingparkroot_linux_test.go+5 −1internal/host/claimservingpeer_rootrunner_linux_test.go+0 −1internal/host/claimservingretire_linux_test.go+6 −2internal/hostbox/servinghandover.go+5 −7internal/hostbox/servinghandover_linux_test.go+71 −1internal/provision/plan9_b8_census_test.go+1 −1internal/provision/testdata/plan14-root-reads-seed.tsv+5 −5
CI cancelled — superseded by a newer push — run details
CI log
=== gate === cd launchctx && go vet ./... && go test ./... -count=1 ok amadan.net/carlos/platform.git/launchctx 0.011s ? amadan.net/carlos/platform.git/launchctx/controlname [no test files] ? amadan.net/carlos/platform.git/launchctx/wireid [no test files] go vet ./... gofmt -l .: clean go test ./... -count=1 -timeout 30m amadan: job cancelled — a newer push superseded it
diff --git a/internal/host/claimfilesseed_integration_linux_test.go b/internal/host/claimfilesseed_integration_linux_test.go| index 6b6057683..fc5416e83 100644 |
| --- a/internal/host/claimfilesseed_integration_linux_test.go |
| +++ b/internal/host/claimfilesseed_integration_linux_test.go |
| @@ -245,10 +245,10 @@ func TestFilesSeedReportsSkippedPaths(t *testing.T) { |
| func TestFilesSeedPreparationChangedController(t *testing.T) { |
| witness := filesSeedLiveWitness{configure: func(*ClaimWakeAssembly) {}, serve: func(*testing.T, *servingPreparation, hostclaim.Observed, activator.ClaimStart) {}} |
| witness.prepare = func(p *servingPreparation) { |
| - p.local.BootID, p.local.ControllerID, p.local.PID, p.local.ProcessStart = "new-boot", "new-controller", 88, "new-start" |
| - p.f.f.peer = StopPeer{UID: 2001, PID: 88, ProcessStart: "new-start", BootID: "new-boot", ControllerID: "new-controller"} |
| + p.local.ControllerID, p.local.PID, p.local.ProcessStart = "new-controller", 88, "new-start" |
| + p.f.f.peer = StopPeer{UID: 2001, PID: 88, ProcessStart: "new-start", BootID: p.local.BootID, ControllerID: "new-controller"} |
| p.f.p.root.Stop.Coordinator.CarlosUID = 2001 |
| - p.wake.Retries.BootID, p.wake.Retries.ControllerID = "new-boot", "new-controller" |
| + p.wake.Retries.BootID, p.wake.Retries.ControllerID = p.local.BootID, "new-controller" |
| if len(p.wake.pending) != 0 { |
| t.Fatal("rebuilding active fixture") |
| } |
diff --git a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go| index 5035026bc..a308ae29a 100644 |
| --- a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| @@ -233,6 +233,9 @@ func TestRootRebootFilesSeedTwoHostPhase2(t *testing.T) { |
| if strings.Contains(t.Name(), "Renumber") && entry.Device == uint64(current.Dev) { |
| t.Fatal("real filesystem device did not renumber") |
| } |
| + if !strings.Contains(t.Name(), "Renumber") && (entry.Device != uint64(current.Dev) || entry.Inode != current.Ino) { |
| + t.Fatal("unchanged-number reboot control changed identity") |
| + } |
| t.Logf("reboot directory host=%s recordedDevice=%d currentDevice=%d recordedInode=%d currentInode=%d", savedHost.Host, entry.Device, current.Dev, entry.Inode, current.Ino) |
| } |
| } |
diff --git a/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go b/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go| index 3249a63e3..9b3121677 100644 |
| --- a/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go |
| @@ -101,6 +101,7 @@ func seedRunnerRenumberDisk(t *testing.T) { |
| func TestRootServingHandoverDirectoryProof(t *testing.T) { |
| servingRootRunner(t) |
| TestServingHandoverRebootDirectoryProof(t) |
| + TestServingHandoverRebootEqualNumbers(t) |
| } |
| func TestRootServingParkRetainedDoesNotBlockRetirement(t *testing.T) { |
diff --git a/internal/host/claimfilesseed_restore_linux_test.go b/internal/host/claimfilesseed_restore_linux_test.go| index 57a8c0b00..cd45329ea 100644 |
| --- a/internal/host/claimfilesseed_restore_linux_test.go |
| +++ b/internal/host/claimfilesseed_restore_linux_test.go |
| @@ -53,6 +53,13 @@ type servingPreparation struct { |
| func newServingPreparation(t *testing.T, f *servingRootFixture, boot, controller string, configure ...func(*ClaimWakeAssembly)) *servingPreparation { |
| t.Helper() |
| + if boot == "boot" { |
| + var err error |
| + boot, err = f.p.ledger.fs.(*servingParkFS).currentBoot() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| p := &servingPreparation{f: f, local: claimStopLocal{Actor: f.actor, BootID: boot, ControllerID: controller, PID: 77, ProcessStart: controller + "-start"}} |
| f.f.peer = StopPeer{UID: 1001, PID: p.local.PID, ProcessStart: p.local.ProcessStart, BootID: boot, ControllerID: controller} |
| launchFixture := plan9D5Fixture(t) |
diff --git a/internal/host/claimhandover_recovery_test.go b/internal/host/claimhandover_recovery_test.go| index c2e52e469..991b6e0d9 100644 |
| --- a/internal/host/claimhandover_recovery_test.go |
| +++ b/internal/host/claimhandover_recovery_test.go |
| @@ -95,7 +95,11 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| f := newRecoveryFixture(t) |
| // Production reads claims and publishes evidence in the same bucket. |
| f.evidence = f.claims |
| - former := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + recordedBoot, parkRuntime := servingRecoveryFilesystem(t, f.guard.DataDir) |
| + if boot == "boot-1" { |
| + boot = recordedBoot |
| + } |
| + former := f.controller(recordedBoot, "controller-1", 4101, "start-1") |
| admitted := former.admit(t, f) |
| f.revision = 8 |
| restarted := f.controller(boot, "controller-2", 4202, "start-2") |
| @@ -116,7 +120,7 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if receipt.Target.ControllerID != "controller-1" || receipt.Target.BootID != "boot-1" || receipt.Target.DirectoryRevision != "7" { |
| + if receipt.Target.ControllerID != "controller-1" || receipt.Target.BootID != recordedBoot || receipt.Target.DirectoryRevision != "7" { |
| t.Fatalf("recovered tuple was rewritten: %+v", receipt.Target) |
| } |
| base := t.TempDir() |
| @@ -184,6 +188,7 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| 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")}}} |
| + root.servingParks = parkRuntime |
| root.RequireNewWork = func(context.Context) error { return nil } |
| root.InventoryActor = func(context.Context) (hostbox.Actor, error) { |
| actor := restarted.local.Actor |
diff --git a/internal/host/claimservinghandover_linux_test.go b/internal/host/claimservinghandover_linux_test.go| index 30bba16f1..32cf7bc8a 100644 |
| --- a/internal/host/claimservinghandover_linux_test.go |
| +++ b/internal/host/claimservinghandover_linux_test.go |
| @@ -5,6 +5,7 @@ package host |
| import ( |
| "context" |
| "encoding/json" |
| + "net" |
| "os" |
| "path/filepath" |
| "reflect" |
| @@ -109,7 +110,7 @@ func TestServingHandoverThenPrelaunchParkReplay(t *testing.T) { |
| if err != nil || installed != (hostguard.Captured{Incarnation: f.current.Incarnation, Stamp: f.current.Stamp}) { |
| t.Fatalf("installation: %+v %v", installed, err) |
| } |
| - target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "controller"}) |
| + target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: f.receipt.Target.BootID, ControllerID: "controller"}) |
| if err := f.f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/next", Device: 1, Inode: 99}}}); err != nil { |
| t.Fatal(err) |
| } |
| @@ -204,8 +205,13 @@ func runServingColdHandoverBoundary(t *testing.T, mount func(*testing.T, string) |
| func TestServingHandoverLegacy(t *testing.T) { |
| f, _, coordinator := newServingLegacyRootSetup(t, false) |
| root := servingHandoverRoot(t, f) |
| + boot, err := servingParkBootID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.f.peer.BootID = boot |
| o := f.f.read() |
| - req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: o.Claim.Holder.Stamp, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: "boot", ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"} |
| + req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: o.Claim.Holder.Stamp, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: boot, ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"} |
| if _, err := coordinator.FenceAndStop(t.Context(), req); err != nil { |
| t.Fatal(err) |
| } |
| @@ -282,7 +288,7 @@ func TestServingHandoverRefusesForeignAuthority(t *testing.T) { |
| t.Fatal(err) |
| } |
| case "prelaunch": |
| - target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "controller"}) |
| + target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: f.receipt.Target.BootID, ControllerID: "controller"}) |
| if err = f.f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/next", Device: 1, Inode: 99}}}); err != nil { |
| t.Fatal(err) |
| } |
| @@ -315,10 +321,11 @@ func TestServingHandoverRefusesForeignAuthority(t *testing.T) { |
| func TestServingStoppedWriterSecondHandover(t *testing.T) { |
| f := newServingRootFixture(t, true) |
| root := servingHandoverRoot(t, f) |
| - f.f.peer = StopPeer{UID: 1001, PID: 77, ProcessStart: "edge-start", BootID: "boot", ControllerID: "controller"} |
| + f.f.peer = StopPeer{UID: 1001, PID: 77, ProcessStart: "edge-start", BootID: f.receipt.Target.BootID, ControllerID: "controller"} |
| coordinator := f.f.root(nil) |
| coordinator.Evidence, coordinator.Inventory = f.f.claims, root.Journals.Inventory |
| root.Stop.Coordinator = coordinator |
| + root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return f.f.peer, nil } |
| f.p.root.Stop.Coordinator = coordinator |
| socket := servingRootSocket(t, root) |
| transfer := coldHandoverWith(handoverRecoveryIdentity(t, f.actor), socket, func(context.Context, hostclaim.Observed) (hostguard.FenceReceipt, error) { |
| @@ -327,7 +334,7 @@ func TestServingStoppedWriterSecondHandover(t *testing.T) { |
| if err := transfer(t.Context(), f.predecessor, f.f.read()); err != nil { |
| t.Fatal(err) |
| } |
| - target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "controller"}) |
| + target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: f.receipt.Target.BootID, ControllerID: "controller"}) |
| if err := f.f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/next", Device: 1, Inode: 99}}}); err != nil { |
| t.Fatal(err) |
| } |
| @@ -353,7 +360,7 @@ func TestServingStoppedWriterSecondHandover(t *testing.T) { |
| t.Fatal(err) |
| } |
| o := f.f.read() |
| - req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: f.current.Incarnation, InstallationStamp: f.current.Stamp, WriterSession: f.current.Session, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: "boot", ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"} |
| + req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: f.current.Incarnation, InstallationStamp: f.current.Stamp, WriterSession: f.current.Session, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: f.receipt.Target.BootID, ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"} |
| drain := stopDrainFunc(func(_ context.Context, req StopRequest, fence string) (DrainAcknowledgement, error) { |
| return DrainClaimAcknowledgement(fence, req.ControllerID, "edge-start", nil), nil |
| }) |
diff --git a/internal/host/claimservinghandover_reboot_linux.go b/internal/host/claimservinghandover_reboot_linux.go| index 527f2de6f..2852c6718 100644 |
| --- a/internal/host/claimservinghandover_reboot_linux.go |
| +++ b/internal/host/claimservinghandover_reboot_linux.go |
| @@ -27,9 +27,15 @@ func (h ClaimRootHTTP) verifyServingHandoverDirectory(ctx context.Context, prior |
| return hostbox.ErrChanged |
| } |
| boot, err := fs.currentBoot() |
| - if err != nil || prior.BootID == boot || !validServingParkBootID(prior.BootID) { |
| + if err != nil || !validServingParkBootID(prior.BootID) { |
| return hostbox.ErrChanged |
| } |
| + if prior.BootID == boot { |
| + if actual.Dev != e.Device { |
| + return hostbox.ErrChanged |
| + } |
| + return ctx.Err() |
| + } |
| slot, err := l.slot(servingParkAuthorization{Successor: servingArtifactIdentity{Host: prior.Host, Box: prior.Box}}) |
| if err != nil { |
| return err |
diff --git a/internal/host/claimservinghandover_reboot_linux_test.go b/internal/host/claimservinghandover_reboot_linux_test.go| index f23c6df88..f2a5d8040 100644 |
| --- a/internal/host/claimservinghandover_reboot_linux_test.go |
| +++ b/internal/host/claimservinghandover_reboot_linux_test.go |
| @@ -3,8 +3,14 @@ |
| package host |
| import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "testing" |
| "github.com/carlosframework/platform/internal/hostbox" |
| @@ -12,7 +18,7 @@ import ( |
| ) |
| func TestServingHandoverRebootDirectoryProof(t *testing.T) { |
| - for _, fault := range []string{"", "same-boot", "foreign-host", "inode", "handle", "mount", "missing-record"} { |
| + for _, fault := range []string{"", "same-boot", "same-boot-renumber", "missing-boot", "boot-read", "foreign-host", "inode", "handle", "mount", "missing-record"} { |
| t.Run(fault, func(t *testing.T) { |
| f, r := servingRebootPreparationFixture(t, "complete") |
| if err := f.p.commitServingReboot(t.Context(), r.Authorization.ID); err != nil { |
| @@ -20,11 +26,18 @@ func TestServingHandoverRebootDirectoryProof(t *testing.T) { |
| } |
| fs := f.p.ledger.fs.(*servingParkFS) |
| prior := hostguard.FenceTarget{Host: r.Authorization.Successor.Host, Box: r.Authorization.Successor.Box, Incarnation: r.Authorization.Successor.Incarnation, Stamp: r.Authorization.Successor.Stamp, BootID: r.Source.BootID} |
| - e := hostbox.StopInventoryEntry{Kind: "path", ManagedRoot: "data", RelativePath: "instances/" + prior.Host, Device: r.Source.Directory.Device + 1, Inode: r.Source.Directory.Inode, Type: 0040000} |
| + e := hostbox.StopInventoryEntry{Kind: "path", ManagedRoot: "data", RelativePath: "instances/" + prior.Host, Device: r.Source.Directory.Device, Inode: r.Source.Directory.Inode, Type: 0040000} |
| a := hostbox.Artifact{Dev: r.Source.Directory.Device, Ino: e.Inode, Type: e.Type} |
| switch fault { |
| - case "same-boot": |
| + case "same-boot", "same-boot-renumber": |
| prior.BootID = servingOtherBoot |
| + if fault == "same-boot-renumber" { |
| + e.Device++ |
| + } |
| + case "missing-boot": |
| + prior.BootID = "" |
| + case "boot-read": |
| + fs.boot = func() (string, error) { return "", os.ErrPermission } |
| case "foreign-host": |
| prior.Host = "foreign.example" |
| case "inode": |
| @@ -54,9 +67,112 @@ func TestServingHandoverRebootDirectoryProof(t *testing.T) { |
| } |
| h := servingTransportRoot(t, f) |
| err := h.verifyServingHandoverDirectory(t.Context(), prior, e, a) |
| - if (err == nil) != (fault == "") { |
| + if (err == nil) != (fault == "" || fault == "same-boot") { |
| t.Fatalf("fault %q: %v", fault, err) |
| } |
| }) |
| } |
| } |
| + |
| +func TestServingHandoverRebootEqualNumbers(t *testing.T) { |
| + for _, changed := range []bool{false, true} { |
| + t.Run(fmt.Sprint(changed), func(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + root := servingHandoverRoot(t, f) |
| + next := func() hostbox.ColdSuccessor { |
| + return hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: "7", ETag: f.f.read().ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session} |
| + } |
| + if err := root.servingHandover().Run(t.Context(), f.receipt.Target, next()); err != nil { |
| + t.Fatal(err) |
| + } |
| + a, err := f.park() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.tags(f.current.Stamp) |
| + if err := f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil { |
| + t.Fatal(err) |
| + } |
| + prior := servingStop(t, f.f, f.f.read(), f.receipt.Target.BootID).Target |
| + o := f.f.read() |
| + admitted, err := hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: f.current.Session, ObservedGeneration: f.current.Generation, Box: f.actor.Box, Region: f.actor.Region, PredecessorRegion: f.actor.Region, Mode: "cold", NewGeneration: "G3"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.current, err = servingWriterIdentity(admitted, f.actor.Box, f.actor.Region) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fs := f.p.ledger.fs.(*servingParkFS) |
| + fs.boot = func() (string, error) { return servingOtherBoot, nil } |
| + if prior.BootID == servingOtherBoot { |
| + t.Fatal("boots must differ") |
| + } |
| + if err := f.p.commitServingReboot(t.Context(), a.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed { |
| + handle := fs.handle |
| + fs.handle = func(d *os.File) (servingParkPersistentDirectory, error) { |
| + p, err := handle(d) |
| + if filepath.Base(d.Name()) == prior.Host { |
| + p.Handle += "00" |
| + } |
| + return p, err |
| + } |
| + } |
| + h := root.servingHandover() |
| + verify := h.VerifyDirectory |
| + calls := 0 |
| + h.VerifyDirectory = func(ctx context.Context, p hostguard.FenceTarget, e hostbox.StopInventoryEntry, a hostbox.Artifact) error { |
| + calls++ |
| + if a.Dev != e.Device || a.Ino != e.Inode || a.Type != e.Type { |
| + t.Fatal("numeric identities must be equal") |
| + } |
| + return verify(ctx, p, e, a) |
| + } |
| + before, err := h.Registry.Get(prior.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := next() |
| + err = h.Run(t.Context(), prior, n) |
| + if changed { |
| + if !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatal("changed persistent evidence accepted", err) |
| + } |
| + after, err := h.Registry.Get(prior.Host) |
| + if err != nil || !reflect.DeepEqual(before, after) { |
| + t.Fatal("registry changed", err) |
| + } |
| + if _, err := h.Inventory.ReadInstallation(t.Context(), n.Host, n.Incarnation, n.Stamp); !errors.Is(err, sql.ErrNoRows) { |
| + t.Fatal("successor inventory written", err) |
| + } |
| + } else { |
| + if err != nil { |
| + t.Fatal("unchanged filesystem refused", err) |
| + } |
| + if err := h.Run(t.Context(), prior, n); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + if calls < 5 { |
| + t.Fatal("missing boundary verification", calls) |
| + } |
| + } |
| + if calls == 0 { |
| + t.Fatal("persistent verifier bypassed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func servingRecoveryFilesystem(t *testing.T, data string) (string, *servingParkRuntime) { |
| + t.Helper() |
| + disk := &rollbackDisk{dir: t.TempDir()} |
| + fs := newServingParkFS(data, disk) |
| + boot, err := fs.currentBoot() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return boot, &servingParkRuntime{authority: &servingParkRoot{ledger: &servingParkLedger{state: disk, fs: fs}}} |
| +} |
diff --git a/internal/host/claimservinghandover_reboot_other_test.go b/internal/host/claimservinghandover_reboot_other_test.go| new file mode 100644 |
| index 000000000..97d45422e |
| --- /dev/null |
| +++ b/internal/host/claimservinghandover_reboot_other_test.go |
| @@ -0,0 +1,11 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import "testing" |
| + |
| +func servingRecoveryFilesystem(t *testing.T, _ string) (string, *servingParkRuntime) { |
| + t.Helper() |
| + t.Skip("serving directory verification requires Linux") |
| + return "", nil |
| +} |
diff --git a/internal/host/claimservingparkroot_linux_test.go b/internal/host/claimservingparkroot_linux_test.go| index 4b0b588e7..6ab192cb1 100644 |
| --- a/internal/host/claimservingparkroot_linux_test.go |
| +++ b/internal/host/claimservingparkroot_linux_test.go |
| @@ -101,7 +101,11 @@ func newServingRootFixture(t *testing.T, launched bool) *servingRootFixture { |
| InventoryActor: func(context.Context) (hostbox.Actor, error) { return fx.actor, nil }, RequireNewWork: func(context.Context) error { return nil }} |
| fx.install(first) |
| fx.tags(fx.prior.Stamp) |
| - fx.receipt = servingStop(t, f, f.read()) |
| + boot, err := fs.currentBoot() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.receipt = servingStop(t, f, f.read(), boot) |
| target := fx.receipt.Target |
| if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/parent", Device: 1, Inode: 2}}}); err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/claimservingpeer_rootrunner_linux_test.go b/internal/host/claimservingpeer_rootrunner_linux_test.go| index ca10ef57a..b22aaab39 100644 |
| --- a/internal/host/claimservingpeer_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpeer_rootrunner_linux_test.go |
| @@ -389,7 +389,6 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| return nil |
| } |
| h := servingTransportRoot(t, f) |
| - h.servingParks.authority = servingPeerParkTrace{servingParkAuthority: h.servingParks.authority, root: f.p, t: t} |
| path := filepath.Join(root, "root.sock") |
| listener, err := net.Listen("unix", path) |
| if err != nil { |
diff --git a/internal/host/claimservingretire_linux_test.go b/internal/host/claimservingretire_linux_test.go| index 743262d4f..1c3189306 100644 |
| --- a/internal/host/claimservingretire_linux_test.go |
| +++ b/internal/host/claimservingretire_linux_test.go |
| @@ -159,9 +159,13 @@ func servingAdmit(t *testing.T, f *recoveryFixture, in *ClaimLaunchInputs, gener |
| } |
| return admitted |
| } |
| -func servingStop(t *testing.T, f *recoveryFixture, o hostclaim.Observed) hostguard.FenceReceipt { |
| +func servingStop(t *testing.T, f *recoveryFixture, o hostclaim.Observed, boots ...string) hostguard.FenceReceipt { |
| t.Helper() |
| - target := recoveryTuple(o, claimStopLocal{Actor: hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enrollment", Revision: 7}, BootID: "boot", ControllerID: "controller"}) |
| + boot := "boot" |
| + if len(boots) != 0 { |
| + boot = boots[0] |
| + } |
| + target := recoveryTuple(o, claimStopLocal{Actor: hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enrollment", Revision: 7}, BootID: boot, ControllerID: "controller"}) |
| target.FenceID, target.ClaimGen = "stop-"+o.Claim.Holder.Generation, o.Claim.Gen |
| if _, err := f.guard.BeginFence(t.Context(), target); err != nil { |
| t.Fatal(err) |
diff --git a/internal/hostbox/servinghandover.go b/internal/hostbox/servinghandover.go| index 5414a5e5a..983c007c2 100644 |
| --- a/internal/hostbox/servinghandover.go |
| +++ b/internal/hostbox/servinghandover.go |
| @@ -253,13 +253,11 @@ func (h ServingHandover) directory(ctx context.Context, prior hostguard.FenceTar |
| if a.Ino != e.Inode || a.Type != e.Type { |
| return a, ErrChanged |
| } |
| - if a.Dev != e.Device { |
| - if h.VerifyDirectory == nil { |
| - return a, ErrChanged |
| - } |
| - if err := h.VerifyDirectory(ctx, prior, e, a); err != nil { |
| - return a, err |
| - } |
| + if h.VerifyDirectory == nil { |
| + return a, ErrChanged |
| + } |
| + if err := h.VerifyDirectory(ctx, prior, e, a); err != nil { |
| + return a, err |
| } |
| return a, nil |
| } |
diff --git a/internal/hostbox/servinghandover_linux_test.go b/internal/hostbox/servinghandover_linux_test.go| index 34a0b7008..ccc114113 100644 |
| --- a/internal/hostbox/servinghandover_linux_test.go |
| +++ b/internal/hostbox/servinghandover_linux_test.go |
| @@ -4,6 +4,7 @@ package hostbox |
| import ( |
| "context" |
| + "database/sql" |
| "errors" |
| "fmt" |
| "os" |
| @@ -104,7 +105,12 @@ func servingHandoverFixture(t *testing.T, deviceDelta ...uint64) (ServingHandove |
| return ErrChanged |
| } |
| return nil |
| - }}, Validate: func(_ context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, _ ColdSuccessor, _ StopInventory, i hostguard.RootInstallation) error { |
| + }}, VerifyDirectory: func(_ context.Context, p hostguard.FenceTarget, e StopInventoryEntry, a Artifact) error { |
| + if p.BootID != "boot" || a.Dev != e.Device { |
| + return ErrChanged |
| + } |
| + return nil |
| + }, Validate: func(_ context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, _ ColdSuccessor, _ StopInventory, i hostguard.RootInstallation) error { |
| if p != prior || i != installation || !reflect.DeepEqual(c, successor.Claim) { |
| return ErrChanged |
| } |
| @@ -244,3 +250,67 @@ func TestServingHandoverRebindsOnlyProvenDirectory(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestServingHandoverEqualNumbersRequireProof(t *testing.T) { |
| + for _, boundary := range []string{"initial", "intent", "barrier", "inventory-write", "complete-journal"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + h, p, n := servingHandoverFixture(t) |
| + changed := boundary == "initial" |
| + calls := 0 |
| + h.VerifyDirectory = func(_ context.Context, _ hostguard.FenceTarget, e StopInventoryEntry, a Artifact) error { |
| + calls++ |
| + if e.Device != a.Dev || e.Inode != a.Ino || e.Type != a.Type { |
| + t.Fatal("numeric identities differ") |
| + } |
| + if changed { |
| + return ErrChanged |
| + } |
| + return nil |
| + } |
| + if boundary == "inventory-write" { |
| + validate := h.Validate |
| + h.Validate = func(ctx context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, n ColdSuccessor, in StopInventory, i hostguard.RootInstallation) error { |
| + err := validate(ctx, c, p, n, in, i) |
| + if calls == 3 { |
| + changed = true |
| + } |
| + return err |
| + } |
| + } else if boundary != "initial" { |
| + h.checkpoint = func(point string) error { |
| + if point == boundary { |
| + return errors.New("interrupted") |
| + } |
| + return nil |
| + } |
| + if h.Run(t.Context(), p, n) == nil { |
| + t.Fatal("interruption missed") |
| + } |
| + h.checkpoint = nil |
| + changed = true |
| + } |
| + before, err := h.Registry.Get(p.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inventory, invErr := h.Inventory.ReadInstallation(t.Context(), n.Host, n.Incarnation, n.Stamp) |
| + if err := h.Run(t.Context(), p, n); !errors.Is(err, ErrChanged) { |
| + t.Fatal("unproven directory accepted", err) |
| + } |
| + if calls == 0 { |
| + t.Fatal("proof skipped") |
| + } |
| + after, err := h.Registry.Get(p.Host) |
| + if err != nil || !reflect.DeepEqual(before, after) { |
| + t.Fatal("registry changed", err) |
| + } |
| + got, gotErr := h.Inventory.ReadInstallation(t.Context(), n.Host, n.Incarnation, n.Stamp) |
| + if !reflect.DeepEqual(inventory, got) || !errors.Is(gotErr, invErr) { |
| + t.Fatal("inventory changed", gotErr) |
| + } |
| + if boundary != "complete-journal" && !errors.Is(gotErr, sql.ErrNoRows) { |
| + t.Fatal("successor inventory exists", gotErr) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index ba5efc8dd..b05b4084d 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -479,7 +479,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/claimservingmaintenance_linux.go", "claimServingRoom", "key-site", "lit:instances", "ce0cb25c53adfd3dba7344a98049f44fc3f0b3e633b22ad37299dea81503e403"}, |
| // rev3=PROPOSED-seed: local instances mount/state references; no deployment record writer. Root authority remains in the seed overlay. |
| {"internal/host/claimservingpark_linux.go", "servingParkFS.parent", "key-site", "lit:instances", "1d241a7d446bbbade6e460efd911863f8a5246b8ac1d47b2548034e3feb2f216"}, |
| - {"internal/host/claimservinghandover_reboot_linux.go", "ClaimRootHTTP.verifyServingHandoverDirectory", "key-site", "lit:instances", "19e8315f54850af3a5ddc04ac6e2e9d9035b2392c9eae7c22e05000d956b7e1e"}, |
| + {"internal/host/claimservinghandover_reboot_linux.go", "ClaimRootHTTP.verifyServingHandoverDirectory", "key-site", "lit:instances", "03e6aa32c980b6fc3512c88bbf5e93f18e84606c36357810bede2b7d3df25b12"}, |
| {"internal/host/claimservingparksource_linux.go", "servingParkStateDirectory", "key-site", "lit:instances", "ec4a121330c5c8f27343f21af0bbe458cced885d4754e96c0d87b12d9b4a8f35"}, |
| {"internal/host/rootinventory.go", "ClaimRootHTTP.recoverRootInventory", "key-site", "key:RecordKey", "10d09b5ab6d07d316437233c9a65bf0bcdfe1083db4d0769fa8303b255dfd0ac"}, |
| {"internal/host/proofnamed.go", "proofBindingSource.record", "key-site", "key:RecordKey", "10a99ff4b355907831a904010b7019d480ce93f2f595e99e076d3c702d10ca2b"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index a3dc77643..54b4c9a74 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -302,7 +302,7 @@ internal/hostbox/servinghandover.go ServingHandover.Recover authority C,J4 rev3= |
| internal/hostbox/servinghandover.go ServingHandover.Run authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; original stopped predecessor evidence remains immutable; successful persistent directory proof stores current device in successor inventory registry:Account@2154,registry:Addr@2373,registry:Addrs@2391,registry:App@2198,registry:Backing@2410,registry:Canary@2467,registry:ClaimOwner@5615,registry:ClaimOwnership@2841,registry:ClaimOwnership@3057,registry:ClaimOwnership@3090,registry:ClaimRowID@3120,registry:ClaimRowID@5686,registry:ClaimRowID@5860,registry:DBPath@2286,registry:Kind@2128,registry:Name@3174,registry:Name@5825,registry:Platform@2451,registry:Replica@2481,registry:Route@2841,registry:Route@3090,registry:Route@5615,registry:Route@5686,registry:Socket@2234,registry:Timers@3039,registry:Timers@3057,registry:Timers@3120,registry:Timers@3174,registry:Timers@5768,registry:Tunnel@2431,registry:Unit@2355,whole:ClaimOwnership@2853,whole:ClaimOwnership@3073,whole:ClaimOwnership@3102,whole:ColdRows@3514,whole:ColdRows@3522,whole:ColdRows@3530,whole:ColdRows@3537,whole:ColdRows@5617,whole:ColdRows@5688,whole:ColdRows@5770,whole:ColdRows@6351,whole:ColdRows@6361,whole:Route@2114,whole:Route@2643,whole:Route@2651,whole:Route@2701,whole:Route@2709,whole:Route@2847,whole:Route@3096,whole:Route@5623,whole:Route@5694,whole:Timer@2656,whole:Timer@2664,whole:Timer@2714,whole:Timer@2752,whole:Timer@3045,whole:Timer@3063,whole:Timer@3126,whole:Timer@3180,whole:Timer@5776 bc5399896f9509e846062aba6bbefcaff5f9b4712a400a9b3f2473966d680b3f |
| internal/hostbox/servinghandover.go ServingHandover.at authority C,J4 rev3=PROPOSED-seed; in-process interruption seam; nil in production 0178e5fcfb2cc194fb4f33f0f230b52849ddeb87a4b7c4e6cfb640e8b2f00b8a |
| internal/hostbox/servinghandover.go ServingHandover.validate authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; unchanged inventory and stopped predecessor plus directory inode/type and boot-aware device proof file:Exec@258,registry:ClaimIncarnation@1906,registry:ClaimOwner@1379,registry:ClaimOwner@1980,registry:ClaimOwnership@1874,registry:ClaimRowID@2015,registry:ClaimStamp@1949,registry:Name@1860,registry:Registration@2052,registry:Route@1033,registry:Route@1379,registry:Timers@1806,registry:Timers@2249,whole:ClaimOwnership@1880,whole:ColdRows@1035,whole:ColdRows@1381,whole:ColdRows@1808,whole:ColdRows@2251,whole:Route@1042,whole:Route@1388,whole:Timer@1815,whole:Timer@2258 5935f65fef167c9e5c2265fb7fe5f25148f6aaa25f90c46ae970de9ad8fd7c0d |
| -internal/hostbox/servinghandover.go type ServingHandover authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; adds fail-closed directory verification callback, used only on changed device d92c342005a7d2a240716c60bb56d55a742bb5a54757e45b642c3c5625b99eb8 |
| +internal/hostbox/servinghandover.go type ServingHandover authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; fail-closed directory verification callback invoked for every directory observation d92c342005a7d2a240716c60bb56d55a742bb5a54757e45b642c3c5625b99eb8 |
| internal/hostbox/servingpark.go LaunchJournal.RootSessionLaunches authority C,J4 rev3=PROPOSED-seed; root-local persisted launch records for exact host/incarnation/stamp/session/box/enrollment; refuses agent journal transport cbbb9378bc53ac499972bed5fb654e05856a9afd7256d55323a717140ffcba18 |
| internal/hostbox/servingpark_service.go JournalService.ExecuteServingPark authority C,J4 rev3=PROPOSED-seed; exact journal request and authenticated enrollment; durable pending receipt before filesystem effects, completed reply after effects; uncertain outcomes retain request df93855480924b39cf49edbeedd136799a7e327860ee4dec7742796d3d428333 |
| internal/hostbox/servingpark_service.go JournalService.prepareServingPark authority C,J4 rev3=PROPOSED-seed; SQLite peer, digest, reply and retention boundary; exact completed replay, bounded request age and clock-rollback rejection before callback; existing bounded receipt pruning file:QueryRowContext@1050,file:QueryRowContext@331 83f1bae59de484a9d1cae09ce8e359aa173634c091203ea3ce4f6e8639e20d5e |
| @@ -325,8 +325,8 @@ system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, |
| system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins ee25513ed72f9b8338d95682c4c76b4ced26ec1f060528fc5804da9afbfbfd83 |
| @coverage . bf3d70b6a2c5d769a3c580c9d5511231499b788d9c08a542895de8381e5a39c9 |
| @coverage internal/activator 9bc3f0f89cc3902c34917f1c6b2f86a778cec0bed499e5ea783fac9d93c95a15 |
| -@coverage internal/host 6753f47be8c2d263dd1fd116fb112a39a6184fe8400b26046a74f9462c15e1af |
| -@coverage internal/hostbox 86337ee9d1f7a4bdd15e331cac712c55d39da4cc26a7c013573a7a9535fe3975 |
| +@coverage internal/host 53fdcf5a89a9fb4e81ce6d04d330fa1d035c1ca3e982b1a61358079c65b2eb76 |
| +@coverage internal/hostbox 8830f7182eed4547c79c8b3b6ccbae071b2675eb0ce86196063aff64a4879405 |
| @coverage internal/hostfiles a38dbcd9789ba4acd237e88c834f2b2bfca9847b0d43c32b0116dc8996d7a142 |
| @coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6 |
| internal/host/rootexecution_recovery.go recoverRootExecutionBeforeUpdate authority D rev3=PROPOSED-seed; extracted update recovery ordering; root instance-control identity; broker durable controller state then best-effort local park and retirement journals; retained park failures cannot gate update eb97b162b0d712dd2fe8971984353ebc10efe1142077a3c2531781dd969cbbf5 |
| @@ -352,10 +352,10 @@ internal/host/claimrestore_observation.go claimRestoreObservation.snapshot autho |
| internal/host/claimrestore_observation.go sameClaimObservation authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; agent; exact ETag and complete claim comparison; same writer alone is insufficient 460d2a7e29552be9aa2d5a90a962b8f1fea6ba7b494239a4d35f43d4efb9462b |
| internal/host/claimrestore_observation.go type claimRestoreObservation authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; agent; tracks only Prepare and proven own CAS results for Materialize 60f66cb892d1a4a43b7168c07ce3049eac30c7a1c4a018af646c38350114dd55 |
| internal/host/claimretire_local.go claimArtifactRetirement.Retire authority C,G,J4 rev3=PROPOSED-seed; FS-R11/run31; agent; existing guarded db retirement, reports its actual before/after CAS to the runtime file:Read@900 d724a762d208822fe5e3c19d7ee03df0f911e1e7e20c05597702b42e57581bcc |
| -internal/host/claimservinghandover_reboot_linux.go ClaimRootHTTP.verifyServingHandoverDirectory authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; reads private completed retirement and slot, current boot and bound namespace; exact host/inode/type and persistent filesystem/handle against current authorized mount before rebinding device; no deletion authority 19e8315f54850af3a5ddc04ac6e2e9d9035b2392c9eae7c22e05000d956b7e1e |
| +internal/host/claimservinghandover_reboot_linux.go ClaimRootHTTP.verifyServingHandoverDirectory authority C,J4 rev3=PROPOSED-seed; run33; root; numeric device identity only with valid same-boot stop evidence; every cross-boot transfer requires historical persistent directory evidence even when device numbers match 03e6aa32c980b6fc3512c88bbf5e93f18e84606c36357810bede2b7d3df25b12 |
| internal/host/claimservinghandover_reboot_other.go ClaimRootHTTP.verifyServingHandoverDirectory authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; refuses unsupported non-Linux proof without reads 0932178d6251804b979c84d0131cfbc9f8bec2aa73d88256e77eee9d1526e68a |
| internal/host/claimwakeassembly.go type ClaimLaunchInputs authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; agent; private callback carries own Restore CAS receipt without granting claim authority 25fd2977645bf4382c54aedef246b915eb63768da3c3d48be862797013182fcf |
| -internal/hostbox/servinghandover.go ServingHandover.directory authority C,J4 rev3=PROPOSED-seed; FS-R11/run31; root; descriptor-relative capture requires exact inode/type; device mismatch additionally requires production root-private persistent proof a6e6c6faabb9f74c4229bafec2473a66769dc7010b15653d0e3bfa66112ca69e |
| +internal/hostbox/servinghandover.go ServingHandover.directory authority C,J4 rev3=PROPOSED-seed; run33; root; every directory capture invokes boot-aware verification, including validation, replay and successor inventory write 9bdb9d021846b705e833fe07be1b30727e7ee0f7fc69cca293ffebc05c5681ba |
| internal/park/local.go Local.RetireSuperseded legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed; census-ruling-FS-R11; agent; existing guarded retirement remains legacy; returns its actual CAS before/after observations rather than substituting a reread file:Read@2127 e2245dfcfebbf206ccfbd6be5dc937a03639614af2a085f4deeb186413ab7a02 |
| @legacy 79007308de276cec4b99a8c01183456d9a93733db3f3781dc51f08ea9385b101 internal/activator/servingretry.go ServingRetries.Wrap legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed; census-ruling-FS-R11;census-ruling-FS-R6; agent; existing route/input authority remains legacy; reports exact successful retry CAS to the runtime, which refuses unrelated changes file:Prepare@128,file:Prepare@4325,file:Prepare@4455,file:Read@2888,file:Read@3531,file:Read@5054,file:Read@6084,file:Read@6768,whole:Route@1212,whole:Route@1550,whole:Route@1648,whole:Route@1948,whole:Route@2360,whole:Route@4473,whole:Route@5421,whole:Route@6634,whole:Route@876,whole:Route@954 e6ba56e2fc95197e5b765e2aa7e78d8366a351a5cb98e4720d000bb9975c3a34 |
| @legacy 68f5b655f5909a4e2f24d1357fd19c86c60b0a89a43846940cb7f72158e90181 internal/activator/servingretry.go ServingRetries.settle legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed; census-ruling-FS-R11; agent; existing claim selection remains legacy; returns exact successful retry settlement CAS through optional callback file:Read@264,file:Read@786 4d51344dfa9560d7bf5f5569efd5e00f902beb840e26afef6c1c2504e6f22fea |