hostclaim: Plan 11 fix round 1 (root-derived no-process coverage, host-app exe binding, flag-gated enrolled_at, fence-recovery liveness order, relocation admission and stops, writers-closed re-check, installation-scoped R4)
Answers Astra's P1/P2s and Opus's P2/P3s on bd0cc991. Root derives the mandatory DB and container coverage for the no-process witness itself; the root launch binds the executable to the host's app through validateStagedExe; enrolled_at is written only when CARLOS_HOST_CLAIM is set, so flag-off roster bytes match main; pending-fence recovery checks controller liveness before requiring settlement; relocation's predecessor and stop contract is wired through production admission; WritersStopped requires WritersClosed and re-proves units gone after the scan; the R4 witness is scoped to the installation and walks claim containments. Census: Directory.Replace re-digested after review.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
38 files changed,
+949
−100
internal/console/hostreports_test.go+1 −0internal/host/claimconstruction.go+6 −4internal/host/claimhandover_crossbox.go+38 −3internal/host/claimlaunch_authority.go+3 −0internal/host/claimlaunch_fake_test.go+26 −0internal/host/claimlaunch_observations.go+20 −3internal/host/claimlaunch_production.go+16 −1internal/host/claimlaunch_rev3_boundary_test.go+2 −0internal/host/claimlaunch_root.go+9 −1internal/host/claimlaunch_root_test.go+1 −1internal/host/claimlaunch_transport_test.go+0 −18internal/host/claimowners.go+5 −2internal/host/claimstop_kernel_linux.go+50 −21internal/host/claimstop_writers_linux.go+73 −20internal/host/claimstop_writers_linux_test.go+7 −2internal/host/claimwakestop.go+9 −4internal/host/host_test.go+6 −1internal/host/plan11_dispatch_test.go+1 −1internal/host/plan11_fix1_coverage_linux_test.go+54 −0internal/host/plan11_fix1_journal_linux_test.go+100 −0internal/host/plan11_fix1_launch_test.go+62 −0internal/host/plan11_fix1_recovery_test.go+52 −0internal/host/plan11_fix1_relocation_test.go+161 −0internal/host/plan11_fix1_writers_test.go+54 −0internal/host/plan11_stop_launch_test.go+3 −0internal/hostactors/plan11_console_reenroll_test.go+1 −0internal/hostactors/plan11_enrolled_test.go+1 −0internal/hostactors/plan11_fix1_enrolled_test.go+42 −0internal/hostactors/roster.go+5 −1internal/hostbox/launchhosts.go+38 −0internal/hostbox/plan11_fix1_projection_test.go+24 −0internal/hostbox/stopjournal.go+12 −10internal/hostbox/stopjournal_reader.go+4 −1internal/hostguard/fence.go+5 −1internal/hostguard/plan11_fix1_writerstop_test.go+35 −0internal/hostguard/writerstop.go+16 −3internal/instancectl/stagedexe.go+5 −0internal/provision/plan9_b8_census_test.go+2 −2
diff --git a/internal/console/hostreports_test.go b/internal/console/hostreports_test.go| index bcbd776ba..02167f82e 100644 |
| --- a/internal/console/hostreports_test.go |
| +++ b/internal/console/hostreports_test.go |
| @@ -17,6 +17,7 @@ import ( |
| ) |
| func TestHostConsoleAdmissionAfterFloorPublication(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| build := version.Build |
| version.Build = "build123" |
| t.Cleanup(func() { version.Build = build }) |
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go| index 56b615f71..ec57bf6fb 100644 |
| --- a/internal/host/claimconstruction.go |
| +++ b/internal/host/claimconstruction.go |
| @@ -276,12 +276,15 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| if r.Mode == "agent" { |
| wake.Roles.Root = NewClaimRoleRootClient(controlsock.Path) |
| } |
| - var crossBox func(context.Context, hostclaim.Observed, hostclaim.Observed) error |
| + 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} |
| - crossBox = handover.After |
| - wake.BeforeAdmission = claimPredecessorStop(wake, actor.Box, handover.Before) |
| + var relocation *ClaimRelocationOwners |
| + if groupD.Dispatch.Relocation != nil { |
| + relocation = &owners.Relocation |
| + } |
| + wireClaimAdmission(wake, actor.Box, wake.AfterAdmission, handover, relocation) |
| retirement := park.Local{Authorize: func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error { |
| return owners.Authorize(ctx, hostclaim.Observed{Host: c.Host, Claim: c}, o) |
| }, Guard: guard, Claims: store, DataDir: r.DataDir, Box: actor.Box} |
| @@ -291,7 +294,6 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| wake.Operations = groupD.Operations |
| wake.ProbeReady = groupD.ProbeReady |
| } |
| - wake.AfterAdmission = claimAdmissionHandover(actor.Box, coldAdmissionHook(guard, owners.Identity, controlsock.Path), crossBox) |
| wake.Retain = wake.retainCaptured |
| wake.ObserveLoss = func(o hostclaim.Observed, err error) { log.Printf("host claim lease loss for %s: %v", o.Host, err) } |
| wake.Retries = activator.ServingRetries{Claims: store, Box: actor.Box, Enrollment: actor.Enrollment, BootID: strings.TrimSpace(string(boot)), ControllerID: controller, Now: time.Now, NewID: newID} |
diff --git a/internal/host/claimhandover_crossbox.go b/internal/host/claimhandover_crossbox.go| index 766ceca6d..3c1cb5e89 100644 |
| --- a/internal/host/claimhandover_crossbox.go |
| +++ b/internal/host/claimhandover_crossbox.go |
| @@ -18,8 +18,7 @@ import ( |
| // completed fence receipt for the predecessor session. |
| func claimAdmissionHandover(box string, local, crossBox func(context.Context, hostclaim.Observed, hostclaim.Observed) error) func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| return func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| - if crossBox != nil && prior.Claim != nil && prior.Claim.Holder != nil && prior.Claim.Op.Kind == hostclaim.KindLanding && |
| - prior.Claim.Holder.WriterBox != "" && prior.Claim.Holder.WriterBox != box { |
| + if crossBox != nil && claimRemotePredecessor(prior, box) { |
| return crossBox(ctx, prior, admitted) |
| } |
| return local(ctx, prior, admitted) |
| @@ -132,9 +131,45 @@ func (h claimCrossBoxHandover) Before(ctx context.Context, prior hostclaim.Obser |
| // for a predecessor that ran on another box. All other shapes keep Stop. |
| func claimPredecessorStop(w *ClaimWakeAssembly, box string, crossBox func(context.Context, hostclaim.Observed) error) func(context.Context, hostclaim.Observed) error { |
| return func(ctx context.Context, prior hostclaim.Observed) error { |
| - if crossBox != nil && prior.Claim != nil && prior.Claim.Holder != nil && prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "" && prior.Claim.Holder.WriterBox != box { |
| + if crossBox != nil && claimRemotePredecessor(prior, box) { |
| return crossBox(ctx, prior) |
| } |
| return w.Stop(ctx, prior) |
| } |
| } |
| + |
| +// wireClaimAdmission routes the source proof to its operation owner before |
| +// lease acquisition and checks the target installation after admission. |
| +func wireClaimAdmission(w *ClaimWakeAssembly, box string, local func(context.Context, hostclaim.Observed, hostclaim.Observed) error, landing claimCrossBoxHandover, relocation *ClaimRelocationOwners) { |
| + before := landing.Before |
| + after := landing.After |
| + if relocation != nil { |
| + before = func(ctx context.Context, prior hostclaim.Observed) error { |
| + if prior.Claim.Op.Kind != hostclaim.KindRelocating { |
| + return landing.Before(ctx, prior) |
| + } |
| + if relocation.BeforeAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" { |
| + return hostbox.ErrChanged |
| + } |
| + return relocation.BeforeAdmission(ctx, prior) |
| + } |
| + after = func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| + if prior.Claim.Op.Kind != hostclaim.KindRelocating { |
| + return landing.After(ctx, prior, admitted) |
| + } |
| + if relocation.AfterAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" { |
| + return hostbox.ErrChanged |
| + } |
| + return relocation.AfterAdmission(ctx, prior, admitted) |
| + } |
| + } |
| + w.BeforeAdmission = claimPredecessorStop(w, box, before) |
| + w.AfterAdmission = claimAdmissionHandover(box, local, after) |
| +} |
| + |
| +func claimRemotePredecessor(prior hostclaim.Observed, box string) bool { |
| + if prior.Claim == nil || prior.Claim.Holder == nil || prior.Claim.Holder.WriterBox == box { |
| + return false |
| + } |
| + return prior.Claim.Op.Kind == hostclaim.KindRelocating || (prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "") |
| +} |
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go| index 3db463c15..a6f9fd96a 100644 |
| --- a/internal/host/claimlaunch_authority.go |
| +++ b/internal/host/claimlaunch_authority.go |
| @@ -99,6 +99,9 @@ func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbo |
| if _, err := h.launchSpec(q); err != nil { |
| return err |
| } |
| + if err := h.ValidateExecutable(t.Host, q.Executable, q.Spec.Digest); err != nil { |
| + return err |
| + } |
| if err := h.Kernel.VerifyDigest(ctx, q.Executable, r.Executable, q.Spec.Digest); err != nil { |
| return err |
| } |
diff --git a/internal/host/claimlaunch_fake_test.go b/internal/host/claimlaunch_fake_test.go| new file mode 100644 |
| index 000000000..76b0e4581 |
| --- /dev/null |
| +++ b/internal/host/claimlaunch_fake_test.go |
| @@ -0,0 +1,26 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| +) |
| + |
| +type fakeLaunchRoot struct { |
| + protocol string |
| + err error |
| +} |
| + |
| +func (f fakeLaunchRoot) Capability(context.Context) (string, error) { return f.protocol, f.err } |
| +func (fakeLaunchRoot) Launch(context.Context, hostbox.RoleLaunchRequest) (hostbox.RoleLaunchGrant, error) { |
| + return hostbox.RoleLaunchGrant{}, errors.New("unused") |
| +} |
| +func (fakeLaunchRoot) Stop(context.Context, hostbox.RoleLaunchRequest) error { return nil } |
| + |
| +func (f fakeLaunchRoot) KindCapability(context.Context, string) error { |
| + if f.protocol != hostbox.RoleLaunchProtocol { |
| + return activator.ErrClaimIsolationUnavailable |
| + } |
| + return f.err |
| +} |
diff --git a/internal/host/claimlaunch_observations.go b/internal/host/claimlaunch_observations.go| index 6a2966c5f..a8e8de74c 100644 |
| --- a/internal/host/claimlaunch_observations.go |
| +++ b/internal/host/claimlaunch_observations.go |
| @@ -7,16 +7,33 @@ import ( |
| ) |
| // Preserve root's optional observation contracts when isolated launch wraps |
| -// the stop kernel. Unit absence is checked before the inner kernel scan. |
| +// the stop kernel. Unit absence is checked before and after the inner kernel scan. |
| func (k claimUnitStopKernel) VerifyWritersStopped(ctx context.Context, t hostguard.FenceTarget, inv StopInventory, peer StopPeer, hints []stopevidence.DescriptorHint) (WritersKernelEvidence, error) { |
| inner, ok := k.Inner.(stopWritersKernel) |
| - if !ok { |
| + if !ok || k.Journal == nil || k.Units == nil { |
| return WritersKernelEvidence{}, ErrClaimStopUnavailable |
| } |
| + in, err := k.Journal.Read(ctx, t) |
| + if err != nil { |
| + return WritersKernelEvidence{}, err |
| + } |
| + if !in.WritersClosed { |
| + return WritersKernelEvidence{}, ErrClaimStopInventory |
| + } |
| + if err := k.Units.Gone(ctx, t.Host, ""); err != nil { |
| + return WritersKernelEvidence{}, err |
| + } |
| if _, err := k.settleUnits(ctx, t); err != nil { |
| return WritersKernelEvidence{}, err |
| } |
| - return inner.VerifyWritersStopped(ctx, t, inv, peer, hints) |
| + seen, err := inner.VerifyWritersStopped(ctx, t, inv, peer, hints) |
| + if err != nil { |
| + return WritersKernelEvidence{}, err |
| + } |
| + if err := k.Units.Gone(ctx, t.Host, ""); err != nil { |
| + return WritersKernelEvidence{}, err |
| + } |
| + return seen, nil |
| } |
| func (k claimUnitStopKernel) VerifyNoProcess(ctx context.Context, t hostguard.FenceTarget, inv StopInventory) (StopKernelEvidence, error) { |
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index 709d47ee3..f122c3dc6 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -5,6 +5,7 @@ import ( |
| "net/http" |
| "os/exec" |
| "path/filepath" |
| + "strings" |
| "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/hostbox" |
| @@ -77,10 +78,24 @@ func claimLaunchFor(root ClaimRootHTTP, actor func(context.Context) (hostbox.Act |
| ExeRoot: filepath.Join(controlsock.Dir, "exe"), |
| Room: instancectl.DiskRoom(filepath.Join(roots["data"], "instances")), |
| } |
| - launch := &ClaimLaunchRoot{DataRoot: roots["data"], Clock: execlease.SystemClock{}, Authenticate: root.Stop.Authenticate, Guard: coordinator.Guard, Actor: actor, Journal: root.Launches, Units: units, |
| + launch := &ClaimLaunchRoot{ValidateExecutable: claimStagedExecutable(&instancectl.Server{OptDir: roots["opt"], HostApp: instanceRegistryHostApp(nil)}), DataRoot: roots["data"], Clock: execlease.SystemClock{}, Authenticate: root.Stop.Authenticate, Guard: coordinator.Guard, Actor: actor, Journal: root.Launches, Units: units, |
| Kernel: linuxClaimLaunchKernel{OptRoot: roots["opt"], CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}} |
| root.Stop.Coordinator.Kernel = claimUnitStopKernel{Inner: coordinator.Kernel, Journal: root.Launches, Units: units} |
| return ClaimRootWithLaunch{Root: root, Launch: launch}, nil |
| } |
| func claimLaunchAbs(p string) bool { return filepath.IsAbs(p) && filepath.Clean(p) == p } |
| + |
| +func claimStagedExecutable(server *instancectl.Server) func(string, string, string) error { |
| + return func(host, path, digest string) error { |
| + rel, err := filepath.Rel(filepath.Join(server.OptDir, "versions"), path) |
| + if err != nil || !filepath.IsLocal(rel) { |
| + return instancectl.ErrStagedExe |
| + } |
| + version, _, ok := strings.Cut(rel, string(filepath.Separator)) |
| + if !ok { |
| + return instancectl.ErrStagedExe |
| + } |
| + return server.ValidateStagedExe(host, instancectl.StagedExe{Path: path, Version: version, Digest: digest}) |
| + } |
| +} |
diff --git a/internal/host/claimlaunch_rev3_boundary_test.go b/internal/host/claimlaunch_rev3_boundary_test.go| index 34fa12bf4..ceb30b9f9 100644 |
| --- a/internal/host/claimlaunch_rev3_boundary_test.go |
| +++ b/internal/host/claimlaunch_rev3_boundary_test.go |
| @@ -1,3 +1,5 @@ |
| +//go:build linux |
| + |
| package host |
| import ( |
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go| index c076da6a3..980349cb1 100644 |
| --- a/internal/host/claimlaunch_root.go |
| +++ b/internal/host/claimlaunch_root.go |
| @@ -58,6 +58,7 @@ type ClaimLaunchObserved struct { |
| // roster identity, its local launch journal and its own view of the kernel. |
| // A request only names which reservation to run. |
| type ClaimLaunchRoot struct { |
| + ValidateExecutable func(host, path, digest string) error |
| Local hostbox.LocalAuthority |
| Clock execlease.Clock |
| DataRoot, ExecLockDir string |
| @@ -73,7 +74,7 @@ type ClaimLaunchRoot struct { |
| } |
| func (h *ClaimLaunchRoot) complete() bool { |
| - return h != nil && h.Guard.DataDir != "" && h.Guard.Claims != nil && h.Actor != nil && h.Local != nil && h.Clock != nil && h.DataRoot != "" && h.Journal != nil && h.Units != nil && h.Kernel != nil |
| + return h != nil && h.Guard.DataDir != "" && h.Guard.Claims != nil && h.Actor != nil && h.Local != nil && h.Clock != nil && h.DataRoot != "" && h.Journal != nil && h.Units != nil && h.Kernel != nil && h.ValidateExecutable != nil |
| } |
| // Serve answers one validated request for an authenticated peer. |
| @@ -467,6 +468,13 @@ func (k claimUnitStopKernel) RecoverStopped(ctx context.Context, t hostguard.Fen |
| if !ok { |
| return StopKernelEvidence{}, DrainAcknowledgement{}, ErrClaimStopUnavailable |
| } |
| + if controller, ok := k.Inner.(interface { |
| + CheckRecoveryController(context.Context, hostguard.FenceTarget) error |
| + }); ok { |
| + if err := controller.CheckRecoveryController(ctx, t); err != nil { |
| + return StopKernelEvidence{}, DrainAcknowledgement{}, err |
| + } |
| + } |
| ids, err := k.settleUnits(ctx, t) |
| if err != nil { |
| return StopKernelEvidence{}, DrainAcknowledgement{}, err |
diff --git a/internal/host/claimlaunch_root_test.go b/internal/host/claimlaunch_root_test.go| index 17b4274be..9a0d8c712 100644 |
| --- a/internal/host/claimlaunch_root_test.go |
| +++ b/internal/host/claimlaunch_root_test.go |
| @@ -160,7 +160,7 @@ func newLaunchFixture(t *testing.T, op hostclaim.Op) *launchFixture { |
| units := &launchUnits{started: instancectl.ClaimUnitStarted{Unit: instancectl.UnitName(instancectl.RoleRun, host), InvocationID: "unit-inv-1", ControlGroup: instancectl.ClaimUnitControlGroup(host), MainPID: 4242}} |
| kernel := &launchKernel{path: "/opt/carlos/versions/v1/app", want: exe, observed: ClaimLaunchObserved{ProcessStart: "ps-4242", TenantPID: 4243, TenantStart: "ps-4243", Group: hostbox.LaunchFile{Device: 3, Inode: 4}, Executable: exe}} |
| peer := StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"} |
| - root := &ClaimLaunchRoot{Guard: guard, Journal: journal, Units: units, Kernel: kernel, Actor: func(context.Context) (hostbox.Actor, error) { return *actor, nil }} |
| + root := &ClaimLaunchRoot{ValidateExecutable: func(string, string, string) error { return nil }, Guard: guard, Journal: journal, Units: units, Kernel: kernel, Actor: func(context.Context) (hostbox.Actor, error) { return *actor, nil }} |
| reservation := hostbox.LaunchReservation{ID: "attempt-1:main", Role: "main", InvocationID: "inv-1", Inputs: "inputs-1", Target: target, Group: hostbox.LaunchGroup{RelativePath: instancectl.ClaimUnitControlGroup(host)}, Executable: exe} |
| req := hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchStart, Reservation: &reservation, Executable: kernel.path, MemoryMax: cgroup.TierDefault.MemoryMax} |
| f := &launchFixture{root: root, claims: claims, units: units, kernel: kernel, peer: peer, req: req, journal: journal, target: target, actor: actor} |
diff --git a/internal/host/claimlaunch_transport_test.go b/internal/host/claimlaunch_transport_test.go| index 1573d0cc9..63b51d64c 100644 |
| --- a/internal/host/claimlaunch_transport_test.go |
| +++ b/internal/host/claimlaunch_transport_test.go |
| @@ -168,17 +168,6 @@ func TestClaimRoleLaunchEndToEndThroughTheRootProtocol(t *testing.T) { |
| } |
| } |
| -type fakeLaunchRoot struct { |
| - protocol string |
| - err error |
| -} |
| - |
| -func (f fakeLaunchRoot) Capability(context.Context) (string, error) { return f.protocol, f.err } |
| -func (fakeLaunchRoot) Launch(context.Context, hostbox.RoleLaunchRequest) (hostbox.RoleLaunchGrant, error) { |
| - return hostbox.RoleLaunchGrant{}, errors.New("unused") |
| -} |
| -func (fakeLaunchRoot) Stop(context.Context, hostbox.RoleLaunchRequest) error { return nil } |
| - |
| func TestClaimRolePreflightAdmitsOnlyTheCompleteProtocol(t *testing.T) { |
| var nilAssembly *ClaimRoleAssembly |
| for name, a := range map[string]*ClaimRoleAssembly{ |
| @@ -275,10 +264,3 @@ func TestProductionClaimLaunchWrapsTheRootHandler(t *testing.T) { |
| } |
| func iidOf(host string) string { return provision.InstanceID(host) } |
| - |
| -func (f fakeLaunchRoot) KindCapability(context.Context, string) error { |
| - if f.protocol != hostbox.RoleLaunchProtocol { |
| - return activator.ErrClaimIsolationUnavailable |
| - } |
| - return f.err |
| -} |
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go| index 435ac5769..4b580626e 100644 |
| --- a/internal/host/claimowners.go |
| +++ b/internal/host/claimowners.go |
| @@ -59,6 +59,9 @@ func (m ClaimMovesOwners) zero() bool { |
| // session row, and only with CARLOS_HOST_CLAIM, CARLOS_MOVES_PHASE1 and |
| // CARLOS_REGION_HA all on. |
| type ClaimRelocationOwners struct { |
| + // These hooks verify relocation's source release and target installation. |
| + BeforeAdmission func(context.Context, hostclaim.Observed) error |
| + AfterAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error |
| ClaimKindOwners |
| Operations OperationAttempts |
| ProbeReady ClaimProbeReadiness |
| @@ -66,7 +69,7 @@ type ClaimRelocationOwners struct { |
| } |
| func (r ClaimRelocationOwners) zero() bool { |
| - return r.Inputs == nil && r.AdmissionEvidence == nil && r.PublicationEvidence == nil && r.Operations == nil && r.ProbeReady == nil && r.Policy == nil |
| + return r.BeforeAdmission == nil && r.AfterAdmission == nil && r.Inputs == nil && r.AdmissionEvidence == nil && r.PublicationEvidence == nil && r.Operations == nil && r.ProbeReady == nil && r.Policy == nil |
| } |
| // claimRelocationRefusal is the typed refusal for a relocating observation |
| @@ -84,7 +87,7 @@ func claimRelocationC1(getenv func(string) string, movesC1 bool, r ClaimRelocati |
| if !movesC1 || getenv == nil || !regionHAEnabled(getenv) || r.zero() { |
| return false, nil |
| } |
| - if !r.complete() || r.Operations == nil || r.Policy == nil { |
| + if !r.complete() || r.Operations == nil || r.Policy == nil || r.BeforeAdmission == nil || r.AfterAdmission == nil { |
| return false, hostwrite.ErrUnavailable |
| } |
| return true, nil |
diff --git a/internal/host/claimstop_kernel_linux.go b/internal/host/claimstop_kernel_linux.go| index 5ef16c535..1ce3933c3 100644 |
| --- a/internal/host/claimstop_kernel_linux.go |
| +++ b/internal/host/claimstop_kernel_linux.go |
| @@ -298,33 +298,13 @@ func (k LinuxStopKernel) RecoverStopped(ctx context.Context, t hostguard.FenceTa |
| if j.ControllerPID <= 0 || j.ProcessStart == "" || !sameStopSession(j.Target, t) { |
| return refuse(ErrClaimStopIdentity) |
| } |
| - boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + observedBoot, err := recoveryControllerAt(k.ProcRoot, t, j) |
| if err != nil { |
| return refuse(err) |
| } |
| - observedBoot := strings.TrimSpace(string(boot)) |
| - if observedBoot == "" { |
| - return refuse(ErrClaimStopIdentity) |
| - } |
| if observedBoot != t.BootID { |
| return k.recoverAfterReboot(ctx, t, in, j, observedBoot, journal) |
| } |
| - b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", j.ControllerPID)) |
| - if err == nil { |
| - i := strings.LastIndexByte(string(b), ')') |
| - if i < 0 { |
| - return refuse(ErrClaimStopIdentity) |
| - } |
| - f := strings.Fields(string(b[i+1:])) |
| - if len(f) <= 19 { |
| - return refuse(ErrClaimStopIdentity) |
| - } |
| - if f[19] == j.ProcessStart { |
| - return refuse(ErrClaimStopControllerAlive) |
| - } |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return refuse(err) |
| - } |
| if j.PendingRoles { |
| if err := k.resolveRoleIntentions(ctx, t, false); err != nil { |
| return refuse(err) |
| @@ -580,3 +560,52 @@ func (k LinuxStopKernel) readContainedJournal(ctx context.Context, t hostguard.F |
| } |
| return j, nil |
| } |
| + |
| +// CheckRecoveryController lets root restart while the live edge finishes its fence. |
| +func (k LinuxStopKernel) CheckRecoveryController(ctx context.Context, t hostguard.FenceTarget) error { |
| + if _, err := k.realRoots(); err != nil { |
| + return err |
| + } |
| + j, err := k.readContainedJournal(ctx, t) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = recoveryControllerAt(k.ProcRoot, t, j) |
| + return err |
| +} |
| + |
| +func recoveryControllerAt(proc string, t hostguard.FenceTarget, j hostbox.StopKernelJournal) (string, error) { |
| + if j.ControllerPID <= 0 || j.ProcessStart == "" || !sameStopSession(j.Target, t) { |
| + return "", ErrClaimStopIdentity |
| + } |
| + boot, err := os.ReadFile(filepath.Join(proc, "sys/kernel/random/boot_id")) |
| + if err != nil { |
| + return "", err |
| + } |
| + observed := strings.TrimSpace(string(boot)) |
| + if observed == "" { |
| + return "", ErrClaimStopIdentity |
| + } |
| + if observed != t.BootID { |
| + return observed, nil |
| + } |
| + raw, err := os.ReadFile(filepath.Join(proc, fmt.Sprint(j.ControllerPID), "stat")) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return observed, nil |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + i := strings.LastIndexByte(string(raw), ')') |
| + if i < 0 { |
| + return "", ErrClaimStopIdentity |
| + } |
| + fields := strings.Fields(string(raw)[i+1:]) |
| + if len(fields) <= 19 { |
| + return "", ErrClaimStopIdentity |
| + } |
| + if fields[19] == j.ProcessStart { |
| + return "", ErrClaimStopControllerAlive |
| + } |
| + return observed, nil |
| +} |
diff --git a/internal/host/claimstop_writers_linux.go b/internal/host/claimstop_writers_linux.go| index 4ef250e1c..3ff206d34 100644 |
| --- a/internal/host/claimstop_writers_linux.go |
| +++ b/internal/host/claimstop_writers_linux.go |
| @@ -20,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/stopevidence" |
| ) |
| @@ -36,7 +37,7 @@ type stopKernelRoots struct { |
| // stopLaunchHosts is the launch journal's host index. |
| type stopLaunchHosts interface { |
| - HasHost(context.Context, string) (bool, error) |
| + HostLaunches(context.Context, string) ([]hostbox.LaunchInventory, error) |
| } |
| func (k LinuxStopKernel) realRoots() (stopKernelRoots, error) { |
| @@ -102,14 +103,14 @@ func verifyWritersAt(ctx context.Context, roots stopKernelRoots, journal hostbox |
| if err != nil { |
| return WritersKernelEvidence{}, err |
| } |
| - if j.V != 1 || !sameStopSession(j.Target, t) || len(j.Cgroups) == 0 || !j.Settled || j.PendingRoles { |
| + if !j.WritersClosed || j.V != 1 || !sameStopSession(j.Target, t) || len(j.Cgroups) == 0 || !j.Settled || j.PendingRoles { |
| return WritersKernelEvidence{}, ErrClaimStopInventory |
| } |
| groups, err := scanStopCgroups(ctx, roots.Cgroup, j.Cgroups) |
| if err != nil { |
| return WritersKernelEvidence{}, err |
| } |
| - paths, prefixes, err := installationCoverage(roots.Managed, inv, true) |
| + paths, prefixes, err := rootInstallationCoverage(roots.Managed, t.Host, inv, true) |
| if err != nil { |
| return WritersKernelEvidence{}, err |
| } |
| @@ -138,7 +139,7 @@ func verifyWritersAt(ctx context.Context, roots stopKernelRoots, journal hostbox |
| return WritersKernelEvidence{CheckedPaths: paths, CgroupIdentities: groups, Retained: retained}, nil |
| } |
| -// verifyNoProcessAt: the launch journal has no entry for the host, the |
| +// verifyNoProcessAt: the launch journal has no entry for this installation, the |
| // host's containment under the agent's cgroup is absent or empty |
| // throughout, and no process holds any of the installation's files; the |
| // journal is read again after the scan. |
| @@ -146,16 +147,23 @@ func verifyNoProcessAt(ctx context.Context, roots stopKernelRoots, launches stop |
| if launches == nil || t.Session != 0 || !filepath.IsAbs(roots.AgentCgroup) { |
| return StopKernelEvidence{}, ErrClaimStopUnavailable |
| } |
| - if has, err := launches.HasHost(ctx, t.Host); err != nil { |
| + records, err := launches.HostLaunches(ctx, t.Host) |
| + if err != nil { |
| return StopKernelEvidence{}, err |
| - } else if has { |
| - return StopKernelEvidence{}, fmt.Errorf("%w: the launch journal has an entry for %s", ErrClaimStopInventory, t.Host) |
| } |
| - groups, err := noProcessGroups(ctx, roots, t.Host) |
| + for _, in := range records { |
| + if in.Target.Host != t.Host { |
| + return StopKernelEvidence{}, ErrClaimStopInventory |
| + } |
| + if in.Target.Incarnation == t.Incarnation && in.Target.Stamp == t.Stamp { |
| + return StopKernelEvidence{}, ErrClaimStopInventory |
| + } |
| + } |
| + groups, err := noProcessGroups(ctx, roots, t.Host, records...) |
| if err != nil { |
| return StopKernelEvidence{}, err |
| } |
| - paths, prefixes, err := installationCoverage(roots.Managed, inv, false) |
| + paths, prefixes, err := rootInstallationCoverage(roots.Managed, t.Host, inv, false) |
| if err != nil { |
| return StopKernelEvidence{}, err |
| } |
| @@ -170,21 +178,52 @@ func verifyNoProcessAt(ctx context.Context, roots stopKernelRoots, launches stop |
| return StopKernelEvidence{}, fmt.Errorf("%w: pid %d holds an installation file", ErrClaimStopIdentity, held[0].PID) |
| } |
| - if has, err := launches.HasHost(ctx, t.Host); err != nil { |
| - return StopKernelEvidence{}, err |
| - } else if has { |
| - return StopKernelEvidence{}, fmt.Errorf("%w: the launch journal has an entry for %s", ErrClaimStopInventory, t.Host) |
| + again, err := launches.HostLaunches(ctx, t.Host) |
| + if err != nil || !reflect.DeepEqual(records, again) { |
| + return StopKernelEvidence{}, errors.Join(ErrClaimStopInventory, err) |
| } |
| - if again, err := noProcessGroups(ctx, roots, t.Host); err != nil || !reflect.DeepEqual(again, groups) { |
| + if again, err := noProcessGroups(ctx, roots, t.Host, records...); err != nil || !reflect.DeepEqual(again, groups) { |
| return StopKernelEvidence{}, errors.Join(ErrClaimStopInventory, err) |
| } |
| - journal := digestStop(struct { |
| - Host string |
| - Launches bool |
| - }{t.Host, false}) |
| + journal := digestStop(records) |
| return StopKernelEvidence{InventorySHA256: digestStop(inv), LocalJournalSHA256: journal, CgroupIdentities: groups, UnitInvocationIDs: []string{}}, nil |
| } |
| +// rootInstallationCoverage derives mandatory coverage from the root-owned layout. |
| +// Agent entries can add paths but cannot remove the host's database or container. |
| +// Name and prefix matches cover root's mount namespace only. Units.Gone must |
| +// be proved first: isolated namespaces can hide newly created or deleted names. |
| +func rootInstallationCoverage(managed map[string]string, host string, inv StopInventory, dataOnly bool) ([]string, []string, error) { |
| + if !provision.ValidHostShape(host) || !filepath.IsAbs(managed["data"]) { |
| + return nil, nil, ErrClaimStopInventory |
| + } |
| + dir := provision.InstanceDir(managed["data"], host) |
| + rel, err := filepath.Rel(managed["data"], dir) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + entry := StopInventoryEntry{Kind: "path", ManagedRoot: "data", RelativePath: rel, Type: syscall.S_IFDIR, Materialization: "intended"} |
| + info, err := os.Lstat(dir) |
| + if err == nil { |
| + st, ok := info.Sys().(*syscall.Stat_t) |
| + if !ok || !info.IsDir() { |
| + return nil, nil, ErrClaimStopInventory |
| + } |
| + entry.Device, entry.Inode, entry.Materialization = uint64(st.Dev), st.Ino, "materialized" |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return nil, nil, err |
| + } |
| + inv.Entries = append(append([]StopInventoryEntry(nil), inv.Entries...), entry) |
| + paths, prefixes, err := installationCoverage(managed, inv, dataOnly) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + db := provision.NestedDBPath(managed["data"], host) |
| + paths = append(paths, db, db+"-wal", db+"-shm", db+"-journal") |
| + sort.Strings(paths) |
| + return paths, prefixes, nil |
| +} |
| + |
| // installationCoverage returns both the currently named files and the |
| // inventory directory prefixes. The latter keep both root witnesses |
| // effective for nested and open-but-unlinked files, including an installation |
| @@ -326,7 +365,7 @@ func agentUnitCgroup(ctx context.Context) (string, error) { |
| } |
| // noProcessGroups includes every isolated role beneath the host slice. |
| -func noProcessGroups(ctx context.Context, roots stopKernelRoots, host string) ([]string, error) { |
| +func noProcessGroups(ctx context.Context, roots stopKernelRoots, host string, records ...hostbox.LaunchInventory) ([]string, error) { |
| legacy, err := hostContainmentEmpty(ctx, roots.AgentCgroup, host) |
| if err != nil { |
| return nil, err |
| @@ -339,5 +378,19 @@ func noProcessGroups(ctx context.Context, roots stopKernelRoots, host string) ([ |
| if err != nil { |
| return nil, err |
| } |
| - return []string{legacy, isolated}, nil |
| + out := []string{legacy, isolated} |
| + seen := map[string]bool{} |
| + for _, in := range records { |
| + name := cgroup.ExecName(claimContainmentName(in.Target)) |
| + if seen[name] { |
| + continue |
| + } |
| + seen[name] = true |
| + observed, err := emptyContainmentAt(ctx, filepath.Join(roots.AgentCgroup, name), name) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, observed) |
| + } |
| + return out, nil |
| } |
diff --git a/internal/host/claimstop_writers_linux_test.go b/internal/host/claimstop_writers_linux_test.go| index 95c7537b5..3c2059215 100644 |
| --- a/internal/host/claimstop_writers_linux_test.go |
| +++ b/internal/host/claimstop_writers_linux_test.go |
| @@ -141,7 +141,12 @@ func (f fixedKernelJournal) ReadStopKernelJournal(context.Context, hostguard.Fen |
| type fixedLaunchHosts bool |
| -func (f fixedLaunchHosts) HasHost(context.Context, string) (bool, error) { return bool(f), nil } |
| +func (f fixedLaunchHosts) HostLaunches(_ context.Context, host string) ([]hostbox.LaunchInventory, error) { |
| + if !f { |
| + return nil, nil |
| + } |
| + return []hostbox.LaunchInventory{{Target: hostguard.FenceTarget{Host: host, Incarnation: "inc-1", Stamp: 11}}}, nil |
| +} |
| func fileID(t *testing.T, path string) (uint64, uint64) { |
| t.Helper() |
| @@ -170,7 +175,7 @@ func newWritersKernelFixture(t *testing.T) *writersKernelFixture { |
| dev, ino := fileID(t, dir) |
| groups = append(groups, hostbox.StopCgroupIdentity{RelativePath: rel, Device: dev, Inode: ino}) |
| } |
| - fx.journal = hostbox.StopKernelJournal{V: 1, Target: fx.target, Settled: true, Cgroups: groups} |
| + fx.journal = hostbox.StopKernelJournal{WritersClosed: true, V: 1, Target: fx.target, Settled: true, Cgroups: groups} |
| state := filepath.Join(fx.dataRoot, "shop.example") |
| if err := os.Mkdir(state, 0o700); err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/claimwakestop.go b/internal/host/claimwakestop.go| index 53be16d18..2b8fcdc44 100644 |
| --- a/internal/host/claimwakestop.go |
| +++ b/internal/host/claimwakestop.go |
| @@ -276,11 +276,13 @@ func (a ClaimAssembly) collectCapturedStop(ctx context.Context, captured hostcla |
| actor := local.Actor |
| claim, holder := observed.Claim, observed.Claim.Holder |
| obligation := "" |
| - if id, ok := a.landingWriterStop(claim, actor); writerSession && ok { |
| + if id, ok := a.operationWriterStop(claim, actor); writerSession && ok { |
| obligation = id |
| } else if claim.Op.Kind != hostclaim.KindServing { |
| for _, ob := range claim.Obligations { |
| - if ob.Kind == "park-local" && ob.Box == actor.Box && ob.Owner == "box:"+actor.Box && ob.Op == claim.Op.ID { |
| + park := ob.Kind == "park-local" && ob.Owner == "box:"+actor.Box |
| + release := ob.Kind == "relocation-release" && ob.ID == "relocation-release" && ob.Park == nil && ob.Region == actor.Region && ob.Owner == "region:"+actor.Region |
| + if (park || release) && ob.Box == actor.Box && ob.Op == claim.Op.ID { |
| if obligation != "" { |
| return StopResult{}, hostbox.ErrChanged |
| } |
| @@ -347,7 +349,7 @@ func (a ClaimAssembly) stopReaches() func(kind, from, to string) bool { |
| return hostclaim.SessionReaches |
| } |
| -// landingWriterStop is the stop authority for this box's own current landing |
| +// operationWriterStop is the stop authority for this box's admitted operation |
| // writer, at probing, probed or committed (claim Plan 9 D4): before a |
| // restarted probe or a cold wake after the open is admitted, the predecessor |
| // is that writer, and no park-local chore exists or is needed for it. The |
| @@ -356,9 +358,12 @@ func (a ClaimAssembly) stopReaches() func(kind, from, to string) bool { |
| // then finds that receipt for the predecessor session. Bound only with the |
| // moves C1 integration (Wake.Continue: CARLOS_HOST_CLAIM and |
| // CARLOS_MOVES_PHASE1); otherwise main's park-local requirement stands. |
| -func (a ClaimAssembly) landingWriterStop(c *hostclaim.Claim, actor hostbox.Actor) (string, bool) { |
| +func (a ClaimAssembly) operationWriterStop(c *hostclaim.Claim, actor hostbox.Actor) (string, bool) { |
| if a.stopReaches() == nil { |
| return "", false |
| } |
| + if c != nil && c.Op.Kind == hostclaim.KindRelocating { |
| + return hostguard.RelocationWriterStop(c, actor.Box, actor.Region) |
| + } |
| return hostguard.LandingWriterStop(c, actor.Box, actor.Region) |
| } |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 2313b98e3..92d0ac883 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -70,7 +70,12 @@ var expectedUnits = []string{ |
| // serving loops), matching the pattern used across the repo. |
| func requireUnixSockets(t *testing.T) { |
| t.Helper() |
| - l, err := net.Listen("unix", filepath.Join(t.TempDir(), "probe.sock")) |
| + dir, err := os.MkdirTemp("", "unix-probe-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + l, err := net.Listen("unix", filepath.Join(dir, "probe.sock")) |
| if err != nil { |
| if strings.Contains(err.Error(), "operation not permitted") { |
| t.Skipf("sandbox forbids unix sockets: %v", err) |
diff --git a/internal/host/plan11_dispatch_test.go b/internal/host/plan11_dispatch_test.go| index f329cdcd6..3fd765f54 100644 |
| --- a/internal/host/plan11_dispatch_test.go |
| +++ b/internal/host/plan11_dispatch_test.go |
| @@ -45,7 +45,7 @@ func plan11Env(moves, ha string) func(string) string { |
| } |
| func plan11RelocationOwners(calls *[]string) ClaimRelocationOwners { |
| - return ClaimRelocationOwners{ClaimKindOwners: plan9D5Owner{"relocation", calls}.owners(), Operations: &plan9D2Attempts{}, |
| + return ClaimRelocationOwners{BeforeAdmission: func(context.Context, hostclaim.Observed) error { return nil }, AfterAdmission: func(context.Context, hostclaim.Observed, hostclaim.Observed) error { return nil }, ClaimKindOwners: plan9D5Owner{"relocation", calls}.owners(), Operations: &plan9D2Attempts{}, |
| Policy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }} |
| } |
diff --git a/internal/host/plan11_fix1_coverage_linux_test.go b/internal/host/plan11_fix1_coverage_linux_test.go| new file mode 100644 |
| index 000000000..ae2a265cb |
| --- /dev/null |
| +++ b/internal/host/plan11_fix1_coverage_linux_test.go |
| @@ -0,0 +1,54 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +func TestPlan11Fix1NoProcessDerivesCoverage(t *testing.T) { |
| + for _, name := range []string{"db", "wal", "shm", "journal", "container"} { |
| + t.Run(name, func(t *testing.T) { |
| + fx := newWritersKernelFixture(t) |
| + fx.dropAgentHolds(t) |
| + db := provision.NestedDBPath(fx.dataRoot, fx.target.Host) |
| + path := db |
| + switch name { |
| + case "wal": |
| + path += "-wal" |
| + case "shm": |
| + path += "-shm" |
| + case "journal": |
| + path += "-journal" |
| + case "container": |
| + path = filepath.Join(filepath.Dir(db), "nested", "state") |
| + } |
| + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := os.Create(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + pid := strconv.Itoa(os.Getpid()) |
| + if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(fx.proc, pid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + rel, err := filepath.Rel(fx.dataRoot, filepath.Join(filepath.Dir(db), "nonexistent")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.inv.Entries = []StopInventoryEntry{{Kind: "path", ManagedRoot: "data", RelativePath: rel, Type: syscall.S_IFDIR, Materialization: "intended"}} |
| + if _, err := fx.noProcess(t, false); !errors.Is(err, ErrClaimStopIdentity) { |
| + t.Fatalf("inventory omitted real %s holder: %v", name, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan11_fix1_journal_linux_test.go b/internal/host/plan11_fix1_journal_linux_test.go| new file mode 100644 |
| index 000000000..62f044475 |
| --- /dev/null |
| +++ b/internal/host/plan11_fix1_journal_linux_test.go |
| @@ -0,0 +1,100 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +type fix1HostLaunches struct { |
| + records []hostbox.LaunchInventory |
| +} |
| + |
| +func (f fix1HostLaunches) HostLaunches(context.Context, string) ([]hostbox.LaunchInventory, error) { |
| + return f.records, nil |
| +} |
| + |
| +func TestPlan11Fix1NoProcessHistoricalInstallations(t *testing.T) { |
| + for _, shape := range []string{"old-incarnation", "old-stamp", "current"} { |
| + t.Run(shape, func(t *testing.T) { |
| + fx := newWritersKernelFixture(t) |
| + fx.dropAgentHolds(t) |
| + old := fx.target |
| + switch shape { |
| + case "old-incarnation": |
| + old.Incarnation = "old" |
| + case "old-stamp": |
| + old.Stamp-- |
| + } |
| + records := fix1HostLaunches{records: []hostbox.LaunchInventory{{Target: old}}} |
| + target := fx.target |
| + target.Session = 0 |
| + _, err := verifyNoProcessAt(t.Context(), fx.roots(), records, target, fx.inv) |
| + if shape == "current" { |
| + if err == nil { |
| + t.Fatal("current installation journal accepted") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("old installation blocks witness: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan11Fix1NoProcessWalksClaimChildren(t *testing.T) { |
| + for _, populated := range []string{"0", "1"} { |
| + t.Run(populated, func(t *testing.T) { |
| + fx := newWritersKernelFixture(t) |
| + fx.dropAgentHolds(t) |
| + old := fx.target |
| + old.Stamp-- |
| + name := cgroup.ExecName(claimContainmentName(old)) |
| + dir := filepath.Join(fx.agentCgroup, name) |
| + child := filepath.Join(dir, "role-child") |
| + if err := os.MkdirAll(child, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for path, value := range map[string]string{dir: "0", child: populated} { |
| + if err := os.WriteFile(filepath.Join(path, "cgroup.events"), []byte("populated "+value+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + records := fix1HostLaunches{records: []hostbox.LaunchInventory{{Target: old}}} |
| + target := fx.target |
| + target.Session = 0 |
| + ev, err := verifyNoProcessAt(t.Context(), fx.roots(), records, target, fx.inv) |
| + if populated == "1" { |
| + if err == nil { |
| + t.Fatal("populated claim child missed") |
| + } |
| + return |
| + } |
| + if err != nil || len(ev.CgroupIdentities) != 3 { |
| + t.Fatalf("claim group not witnessed: %+v %v", ev, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan11Fix1NoProcessReadsRootJournal(t *testing.T) { |
| + f := newLaunchFixture(t, servingOp()) |
| + fx := newWritersKernelFixture(t) |
| + fx.dropAgentHolds(t) |
| + target := fx.target |
| + target.Session = 0 |
| + target.Stamp++ |
| + if _, err := verifyNoProcessAt(t.Context(), fx.roots(), f.journal, target, fx.inv); err != nil { |
| + t.Fatalf("historical persisted launch blocks new installation: %v", err) |
| + } |
| + target.Stamp-- |
| + if _, err := verifyNoProcessAt(t.Context(), fx.roots(), f.journal, target, fx.inv); err == nil { |
| + t.Fatal("persisted current installation accepted") |
| + } |
| +} |
diff --git a/internal/host/plan11_fix1_launch_test.go b/internal/host/plan11_fix1_launch_test.go| new file mode 100644 |
| index 000000000..b618aa69c |
| --- /dev/null |
| +++ b/internal/host/plan11_fix1_launch_test.go |
| @@ -0,0 +1,62 @@ |
| +package host |
| + |
| +import ( |
| + "crypto/sha256" |
| + "fmt" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/release" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestPlan11Fix1RootBindsHostApp(t *testing.T) { |
| + for _, shape := range []string{"own", "other-account", "other-app", "root-binary", "missing-provider"} { |
| + t.Run(shape, func(t *testing.T) { |
| + f := newLaunchFixture(t, servingOp()) |
| + opt := t.TempDir() |
| + path := release.VersionPath(opt, "bap", "v1", "app-a") |
| + switch shape { |
| + case "other-account": |
| + path = release.VersionPath(opt, "cat", "v1", "app-a") |
| + case "other-app": |
| + path = release.VersionPath(opt, "bap", "v1", "app-b") |
| + case "root-binary": |
| + path = filepath.Join(opt, "bin", "helper") |
| + } |
| + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + data := []byte("fixture executable") |
| + if err := os.WriteFile(path, data, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.req.Executable, f.req.Spec.Executable, f.kernel.path = path, path, path |
| + digest := fmt.Sprintf("%x", sha256.Sum256(data)) |
| + f.req.Spec.Digest, f.kernel.digest = digest, digest |
| + hash, err := f.req.Spec.Hash() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.req.Reservation.Inputs = hash |
| + f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = hash }) |
| + server := &instancectl.Server{OptDir: opt, HostApp: func(string) (string, string, error) { return "bap", "app-a", nil }} |
| + f.root.ValidateExecutable = claimStagedExecutable(server) |
| + if shape == "missing-provider" { |
| + f.root.ValidateExecutable = nil |
| + } |
| + _, err = f.root.Serve(t.Context(), f.peer, f.req) |
| + if shape == "own" { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return |
| + } |
| + if err == nil { |
| + t.Fatal("root accepted a build outside the host app") |
| + } |
| + f.noReservation(t, shape) |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan11_fix1_recovery_test.go b/internal/host/plan11_fix1_recovery_test.go| new file mode 100644 |
| index 000000000..2b60fb9ac |
| --- /dev/null |
| +++ b/internal/host/plan11_fix1_recovery_test.go |
| @@ -0,0 +1,52 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +type fix1LiveRecovery struct{ recoveryKernelFunc } |
| + |
| +func (fix1LiveRecovery) CheckRecoveryController(context.Context, hostguard.FenceTarget) error { |
| + return ErrClaimStopControllerAlive |
| +} |
| + |
| +func TestPlan11Fix1PendingFenceRestartsWithLiveController(t *testing.T) { |
| + f := newLaunchFixture(t, servingOp()) |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.units.goneErr = errors.New("tenant unit still running") |
| + c, req := servingStopFixture(t) |
| + c.Inventory = exactFixtureInventory{c.Inventory} |
| + c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) { |
| + return DrainAcknowledgement{}, errors.New("root crashed after fence") |
| + }) |
| + if _, err := c.FenceAndStop(t.Context(), req); err == nil { |
| + t.Fatal("expected interrupted fence") |
| + } |
| + pending, err := c.Guard.PendingFences(t.Context()) |
| + if err != nil || len(pending) != 1 { |
| + t.Fatalf("pending=%v err=%v", pending, err) |
| + } |
| + c.Kernel = claimUnitStopKernel{Journal: f.journal, Units: f.units, Inner: fix1LiveRecovery{recoveryKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) { |
| + t.Fatal("recovery ran while controller lives") |
| + return StopKernelEvidence{}, DrainAcknowledgement{}, nil |
| + })}} |
| + h := ClaimStopHTTP{Coordinator: c} |
| + if err := h.RecoverPending(t.Context()); err != nil { |
| + t.Fatalf("root control cannot restart: %v", err) |
| + } |
| + response := httptest.NewRecorder() |
| + h.ServeHTTP(response, httptest.NewRequest("GET", "/claim/stop", nil)) |
| + if response.Code == 0 { |
| + t.Fatal("root control did not serve") |
| + } |
| + pending, err = c.Guard.PendingFences(t.Context()) |
| + if err != nil || len(pending) != 1 { |
| + t.Fatalf("live fence was completed: %v %v", pending, err) |
| + } |
| +} |
diff --git a/internal/host/plan11_fix1_relocation_test.go b/internal/host/plan11_fix1_relocation_test.go| new file mode 100644 |
| index 000000000..da8e5d712 |
| --- /dev/null |
| +++ b/internal/host/plan11_fix1_relocation_test.go |
| @@ -0,0 +1,161 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "os/exec" |
| + "testing" |
| +) |
| + |
| +func TestPlan11Fix1RelocationAdmissionHooks(t *testing.T) { |
| + for _, writer := range []string{"source", "", "target"} { |
| + t.Run(writer, func(t *testing.T) { |
| + calls := []string{} |
| + w := &ClaimWakeAssembly{Stop: func(context.Context, hostclaim.Observed) error { calls = append(calls, "local-stop"); return nil }} |
| + local := func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + calls = append(calls, "local-after") |
| + return nil |
| + } |
| + reloc := ClaimRelocationOwners{BeforeAdmission: func(context.Context, hostclaim.Observed) error { |
| + calls = append(calls, "relocation-before") |
| + return nil |
| + }, AfterAdmission: func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + calls = append(calls, "relocation-after") |
| + return nil |
| + }} |
| + wireClaimAdmission(w, "target", local, claimCrossBoxHandover{}, &reloc) |
| + o := plan11Relocating("probing") |
| + o.Claim.Op.Exec = "target" |
| + o.Claim.Holder.WriterBox = writer |
| + if err := w.stopPredecessor(t.Context(), o); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := w.AfterAdmission(t.Context(), o, o); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"relocation-before", "relocation-after"} |
| + if writer == "target" { |
| + want = []string{"local-stop", "local-after"} |
| + } |
| + if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] { |
| + t.Fatalf("calls=%v want=%v", calls, want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan11Fix1CollectRelocationStops(t *testing.T) { |
| + for _, phase := range []string{"releasing", "probing", "serving"} { |
| + t.Run(phase, func(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + c.admit(t, f) |
| + o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(cl *hostclaim.Claim) error { |
| + cl.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "reloc", Phase: phase, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"region": "ie", "source_box": "box-1", "target_box": "box-1"}} |
| + if phase == "releasing" { |
| + return hostclaim.QueueRelocationRelease("reloc", "box-1", "ie", "retirement")(cl) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + local, err := c.assembly.localStopIdentity(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + seen := false |
| + sentinel := errors.New("collected") |
| + c.assembly.collectStop = func(_ context.Context, req StopRequest, _ StopDrainer) (StopResult, error) { |
| + seen = true |
| + want := hostguard.WriterStopObligation(o.Claim.Holder.Session) |
| + if phase == "releasing" { |
| + want = "relocation-release" |
| + } |
| + if req.ObligationID != want { |
| + t.Errorf("obligation=%q want=%q", req.ObligationID, want) |
| + } |
| + return StopResult{}, sentinel |
| + } |
| + c.wake.Continue = hostclaim.SessionContinues |
| + _, err = c.assembly.collectCapturedStop(t.Context(), o, local, claimLaunchOwner{BootID: local.BootID, ControllerID: local.ControllerID}, phase != "releasing") |
| + if !seen || !errors.Is(err, sentinel) { |
| + t.Fatalf("relocation stop never collected: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan11Fix1RelocationComposedAdmission(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + if _, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{ID: "reloc", Kind: hostclaim.KindRelocating, Phase: "probing", Exec: "box-1", Participants: []string{"source", "box-1"}, Intent: map[string]string{"source_box": "source", "target_box": "box-1", "region": "ie"}} |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + c.wake.Inputs = plan9D2Inputs{recoveryInputs{f}} |
| + c.wake.Continue = hostclaim.SessionContinues |
| + c.wake.Operations = &plan9D2Attempts{} |
| + c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| + before, after := 0, 0 |
| + refusal := errors.New("release proof refused") |
| + owners := ClaimRelocationOwners{ |
| + BeforeAdmission: func(context.Context, hostclaim.Observed) error { before++; return refusal }, |
| + AfterAdmission: func(_ context.Context, prior, admitted hostclaim.Observed) error { |
| + after++ |
| + if admitted.Claim.Holder.WriterBox != "box-1" || admitted.Claim.Holder.Session <= prior.Claim.Holder.Session { |
| + t.Fatal("handover did not receive admitted writer") |
| + } |
| + return nil |
| + }, |
| + } |
| + wireClaimAdmission(c.wake, "box-1", func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + t.Fatal("remote predecessor reached local handover") |
| + return nil |
| + }, claimCrossBoxHandover{}, &owners) |
| + prior := f.read() |
| + if err := c.wake.stopPredecessor(t.Context(), prior); !errors.Is(err, refusal) { |
| + t.Fatalf("release refusal lost: %v", err) |
| + } |
| + refusal = nil |
| + if err := c.wake.stopPredecessor(t.Context(), prior); 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.pending[recoveryHost] = &claimWakeAttempt{observed: prior, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| + policy := recoveryPolicy |
| + policy.Probe = true |
| + admitted, err := c.runtime.Admit(t.Context(), registry.Route{Host: recoveryHost}, prior, policy) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before != 2 || after != 1 || len(f.requests) != 0 { |
| + t.Fatalf("before=%d after=%d local stops=%d", before, after, len(f.requests)) |
| + } |
| + if admitted.Claim.Op.Kind != hostclaim.KindRelocating { |
| + t.Fatal("lost relocation") |
| + } |
| +} |
| + |
| +func TestPlan11Fix1RelocationRequiresHandoverOwners(t *testing.T) { |
| + for _, missing := range []string{"before", "after"} { |
| + var calls []string |
| + owners := plan11RelocationOwners(&calls) |
| + if missing == "before" { |
| + owners.BeforeAdmission = nil |
| + } else { |
| + owners.AfterAdmission = nil |
| + } |
| + if _, err := claimRelocationC1(plan11Env("1", "1"), true, owners); err == nil { |
| + t.Fatalf("accepted missing %s hook", missing) |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan11_fix1_writers_test.go b/internal/host/plan11_fix1_writers_test.go| new file mode 100644 |
| index 000000000..29b2e9df5 |
| --- /dev/null |
| +++ b/internal/host/plan11_fix1_writers_test.go |
| @@ -0,0 +1,54 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/stopevidence" |
| + "testing" |
| +) |
| + |
| +type fix1WritersScan struct { |
| + stopKernelFunc |
| + scan func() |
| +} |
| + |
| +func (k fix1WritersScan) VerifyWritersStopped(context.Context, hostguard.FenceTarget, StopInventory, StopPeer, []stopevidence.DescriptorHint) (WritersKernelEvidence, error) { |
| + if k.scan != nil { |
| + k.scan() |
| + } |
| + return WritersKernelEvidence{}, nil |
| +} |
| + |
| +func TestPlan11Fix1WritersMustRemainClosed(t *testing.T) { |
| + for _, shape := range []string{"open", "unit-appears", "closed"} { |
| + t.Run(shape, func(t *testing.T) { |
| + f := newLaunchFixture(t, servingOp()) |
| + if shape != "open" { |
| + if err := f.journal.CloseWriters(t.Context(), f.target); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + scans := 0 |
| + k := claimUnitStopKernel{Journal: f.journal, Units: f.units, Inner: fix1WritersScan{scan: func() { |
| + scans++ |
| + if shape == "unit-appears" { |
| + f.units.goneErr = errors.New("unit started during scan") |
| + } |
| + }}} |
| + _, err := k.VerifyWritersStopped(t.Context(), f.target, StopInventory{}, f.peer, nil) |
| + if shape == "closed" { |
| + if err != nil || scans != 1 { |
| + t.Fatalf("closed: %v, scans=%d", err, scans) |
| + } |
| + return |
| + } |
| + if err == nil { |
| + t.Fatal("writers-stopped accepted an open writer boundary") |
| + } |
| + if shape == "open" && scans != 0 { |
| + t.Fatal("scanned before writers closed") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan11_stop_launch_test.go b/internal/host/plan11_stop_launch_test.go| index 01a55c468..07898139e 100644 |
| --- a/internal/host/plan11_stop_launch_test.go |
| +++ b/internal/host/plan11_stop_launch_test.go |
| @@ -27,6 +27,9 @@ func TestPlan11IsolatedWrapperPreservesRootObservations(t *testing.T) { |
| if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| t.Fatal(err) |
| } |
| + if err := f.journal.CloseWriters(t.Context(), f.target); err != nil { |
| + t.Fatal(err) |
| + } |
| inner := &plan11CompositeKernel{} |
| k := claimUnitStopKernel{Inner: inner, Journal: f.journal, Units: f.units} |
| writers, ok := any(k).(stopWritersKernel) |
diff --git a/internal/hostactors/plan11_console_reenroll_test.go b/internal/hostactors/plan11_console_reenroll_test.go| index 1473d85a2..7e58b2aa9 100644 |
| --- a/internal/hostactors/plan11_console_reenroll_test.go |
| +++ b/internal/hostactors/plan11_console_reenroll_test.go |
| @@ -7,6 +7,7 @@ import ( |
| ) |
| func TestPlan11ConsoleReenrollmentGetsItsOwnTime(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| d, sg, before := fixture(t) |
| next := Clone(before.Roster) |
| next.Consoles["console"] = Console{Enrollment: strings.Repeat("d", 32), InstanceID: "console", Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket} |
diff --git a/internal/hostactors/plan11_enrolled_test.go b/internal/hostactors/plan11_enrolled_test.go| index 25a94426e..e2bdf5a1d 100644 |
| --- a/internal/hostactors/plan11_enrolled_test.go |
| +++ b/internal/hostactors/plan11_enrolled_test.go |
| @@ -11,6 +11,7 @@ import ( |
| ) |
| func TestPlan11EnrollmentTimestampSignedImmutable(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ctx := context.Background() |
| d, sg, before := fixture(t) |
| next := Clone(before.Roster) |
diff --git a/internal/hostactors/plan11_fix1_enrolled_test.go b/internal/hostactors/plan11_fix1_enrolled_test.go| new file mode 100644 |
| index 000000000..ef2dfcc45 |
| --- /dev/null |
| +++ b/internal/hostactors/plan11_fix1_enrolled_test.go |
| @@ -0,0 +1,42 @@ |
| +package hostactors |
| + |
| +import ( |
| + "bytes" |
| + "github.com/carlosframework/platform/internal/release" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestPlan11Fix1EnrollmentFlagBytes(t *testing.T) { |
| + for _, flag := range []string{"", "1"} { |
| + t.Run("flag="+flag, func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", flag) |
| + d, sg, before := fixture(t) |
| + next := Clone(before.Roster) |
| + b := next.Boxes["box"] |
| + b.BoxID, b.Enrollment, b.Cloud.InstanceID = "second", strings.Repeat("c", 32), "i-second" |
| + next.Boxes["second"] = b |
| + next.Consoles["console"] = Console{Enrollment: strings.Repeat("d", 32), InstanceID: "console", Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket} |
| + got, err := d.Replace(t.Context(), sg, before, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := d.Store.Get(t.Context(), Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if flag == "" { |
| + next.Revision = before.Roster.Revision + 1 |
| + want, err := release.SignRootEnvelope(sg, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(raw, want) || bytes.Contains(raw, []byte("enrolled_at")) { |
| + t.Fatal("flag-off enrollment changed legacy signed bytes") |
| + } |
| + } else if got.Roster.Boxes["second"].EnrolledAt.IsZero() || got.Roster.Consoles["console"].EnrolledAt.IsZero() { |
| + t.Fatal("flag-on enrollment missing timestamps") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go| index 177e5e624..70114f142 100644 |
| --- a/internal/hostactors/roster.go |
| +++ b/internal/hostactors/roster.go |
| @@ -6,6 +6,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "math" |
| + "os" |
| "reflect" |
| "regexp" |
| "slices" |
| @@ -193,7 +194,10 @@ func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observ |
| // Assign new enrollment times inside the signed roster CAS. Older entries |
| // keep an unknown time; replacing it with today's time would fabricate evidence. |
| next = Clone(next) |
| - now := time.Now().UTC() |
| + var now time.Time |
| + if strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" { |
| + now = time.Now().UTC() |
| + } |
| for ref, b := range next.Boxes { |
| if _, exists := before.Roster.Boxes[ref]; !exists { |
| if !b.EnrolledAt.IsZero() { |
diff --git a/internal/hostbox/launchhosts.go b/internal/hostbox/launchhosts.go| new file mode 100644 |
| index 000000000..5b61b6163 |
| --- /dev/null |
| +++ b/internal/hostbox/launchhosts.go |
| @@ -0,0 +1,38 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| +) |
| + |
| +// HostLaunches is root's local journal view, including older installations |
| +// whose claim-named cgroups still need an emptiness check. |
| +func (j *LaunchJournal) HostLaunches(ctx context.Context, host string) ([]LaunchInventory, error) { |
| + if j == nil || j.db == nil || j.remote != nil || !component(host) { |
| + return nil, ErrUnsupported |
| + } |
| + rows, err := journalExecutorFor(ctx, j.db).QueryContext(ctx, "SELECT identity, body FROM launches ORDER BY identity") |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []LaunchInventory |
| + for rows.Next() { |
| + var key string |
| + var body []byte |
| + if err := rows.Scan(&key, &body); err != nil { |
| + return nil, err |
| + } |
| + var in LaunchInventory |
| + if err := json.Unmarshal(body, &in); err != nil { |
| + return nil, err |
| + } |
| + if !validLaunchOwner(in.Target) || launchKey(in.Target) != key || !in.Completed.Valid() { |
| + return nil, ErrTypedInventory |
| + } |
| + if in.Target.Host == host { |
| + out = append(out, in) |
| + } |
| + } |
| + return out, rows.Err() |
| +} |
diff --git a/internal/hostbox/plan11_fix1_projection_test.go b/internal/hostbox/plan11_fix1_projection_test.go| new file mode 100644 |
| index 000000000..01389aa6d |
| --- /dev/null |
| +++ b/internal/hostbox/plan11_fix1_projection_test.go |
| @@ -0,0 +1,24 @@ |
| +package hostbox |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "reflect" |
| + "testing" |
| +) |
| + |
| +func TestPlan11Fix1StopProjectionIncludesUnitIDs(t *testing.T) { |
| + target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"} |
| + in := LaunchInventory{Target: target, NoState: true, WritersClosed: true, Reservations: []LaunchReservation{{ID: "first", State: UnitSettled, Group: LaunchGroup{RelativePath: "carlos.slice/carlos-habc.slice/carlos-run@shop.example.service"}}}} |
| + before, err := launchStopKernelJournal(in, target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in.Reservations = append(in.Reservations, LaunchReservation{ID: "second", State: UnitSettled, Group: LaunchGroup{RelativePath: "carlos.slice/carlos-habc.slice/carlos-run@shop.example.service"}}) |
| + after, err := launchStopKernelJournal(in, target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if reflect.DeepEqual(before, after) { |
| + t.Fatal("new settled unit reservation invisible in scan projection") |
| + } |
| +} |
diff --git a/internal/hostbox/stopjournal.go b/internal/hostbox/stopjournal.go| index 4939016a0..43b9019ed 100644 |
| --- a/internal/hostbox/stopjournal.go |
| +++ b/internal/hostbox/stopjournal.go |
| @@ -12,16 +12,18 @@ type StopCgroupIdentity struct { |
| InvocationID string |
| } |
| type StopKernelJournal struct { |
| - Completed ProducerCompletion `json:",omitzero"` |
| - ControllerPID int |
| - ProcessStart string |
| - V int |
| - Target hostguard.FenceTarget |
| - Settled bool |
| - PendingRoles bool |
| - Cgroups []StopCgroupIdentity |
| - Files []StopFileIdentity |
| - ProducerIDs []string |
| + WritersClosed bool |
| + UnitReservationIDs []string |
| + Completed ProducerCompletion `json:",omitzero"` |
| + ControllerPID int |
| + ProcessStart string |
| + V int |
| + Target hostguard.FenceTarget |
| + Settled bool |
| + PendingRoles bool |
| + Cgroups []StopCgroupIdentity |
| + Files []StopFileIdentity |
| + ProducerIDs []string |
| } |
| type StopKernelJournalReader interface { |
| ReadStopKernelJournal(context.Context, hostguard.FenceTarget) (StopKernelJournal, error) |
diff --git a/internal/hostbox/stopjournal_reader.go b/internal/hostbox/stopjournal_reader.go| index 6e23b825f..bf16914db 100644 |
| --- a/internal/hostbox/stopjournal_reader.go |
| +++ b/internal/hostbox/stopjournal_reader.go |
| @@ -16,8 +16,11 @@ func launchStopKernelJournal(in LaunchInventory, target hostguard.FenceTarget) ( |
| if !in.Completed.Valid() || !SameLaunchOwner(in.Target, target) || (!in.NoState && len(in.Files) == 0) { |
| return StopKernelJournal{}, ErrTypedInventory |
| } |
| - out := StopKernelJournal{Completed: in.Completed, ControllerPID: in.ControllerPID, ProcessStart: in.ProcessStart, V: 1, Target: in.Target, Settled: true, ProducerIDs: append([]string{}, in.ProducerIDs...)} |
| + out := StopKernelJournal{WritersClosed: in.WritersClosed, Completed: in.Completed, ControllerPID: in.ControllerPID, ProcessStart: in.ProcessStart, V: 1, Target: in.Target, Settled: true, ProducerIDs: append([]string{}, in.ProducerIDs...)} |
| for _, r := range in.Reservations { |
| + if IsUnitReservation(r) { |
| + out.UnitReservationIDs = append(out.UnitReservationIDs, r.ID) |
| + } |
| if r.State == "intended" { |
| out.PendingRoles = true |
| } |
diff --git a/internal/hostguard/fence.go b/internal/hostguard/fence.go| index aa49f91ff..dee8d1b05 100644 |
| --- a/internal/hostguard/fence.go |
| +++ b/internal/hostguard/fence.go |
| @@ -267,7 +267,11 @@ func StopAuthorityMatches(c *hostclaim.Claim, t FenceTarget) bool { |
| // A writer-session stop names no chore, so it is only ever the |
| // landing's own current writer on this box (claim Plan 9 D4). |
| if writerStop(t.ObligationID) { |
| - if id, ok := LandingWriterStop(c, t.Box, t.Region); !ok || id != t.ObligationID { |
| + id, ok := LandingWriterStop(c, t.Box, t.Region) |
| + if c.Op.Kind == hostclaim.KindRelocating { |
| + id, ok = RelocationWriterStop(c, t.Box, t.Region) |
| + } |
| + if !ok || id != t.ObligationID { |
| return false |
| } |
| } |
diff --git a/internal/hostguard/plan11_fix1_writerstop_test.go b/internal/hostguard/plan11_fix1_writerstop_test.go| new file mode 100644 |
| index 000000000..20479241e |
| --- /dev/null |
| +++ b/internal/hostguard/plan11_fix1_writerstop_test.go |
| @@ -0,0 +1,35 @@ |
| +package hostguard |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "testing" |
| +) |
| + |
| +func TestPlan11Fix1RelocationWriterStopAuthority(t *testing.T) { |
| + for _, phase := range []string{"probing", "serving"} { |
| + for _, fault := range []string{"none", "source", "region", "session", "duplicate", "phase", "warm"} { |
| + t.Run(phase+"/"+fault, func(t *testing.T) { |
| + c, target := plan9D4Writer(phase) |
| + c.Op.Kind = hostclaim.KindRelocating |
| + switch fault { |
| + case "source": |
| + c.Holder.WriterBox = "source" |
| + case "region": |
| + target.Region = "other" |
| + case "session": |
| + target.ObligationID = WriterStopObligation(9) |
| + case "duplicate": |
| + c.Holder.Admissions = append(c.Holder.Admissions, c.Holder.Admissions[1]) |
| + case "phase": |
| + c.Op.Phase = "releasing" |
| + target.Phase = "releasing" |
| + case "warm": |
| + c.Holder.Admissions[1].Mode = "warm" |
| + } |
| + if got := StopAuthorityMatches(c, target); got != (fault == "none") { |
| + t.Fatalf("authority=%v", got) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/hostguard/writerstop.go b/internal/hostguard/writerstop.go| index 3fe22a7cf..eb19d45c2 100644 |
| --- a/internal/hostguard/writerstop.go |
| +++ b/internal/hostguard/writerstop.go |
| @@ -34,12 +34,16 @@ func writerStop(obligation string) bool { |
| // first probe, whose predecessor is the source's session on another box, is |
| // never box's writer here. |
| func LandingWriter(c *hostclaim.Claim, box string) (hostclaim.AdmissionRecord, bool) { |
| + return operationWriter(c, box, hostclaim.KindLanding) |
| +} |
| + |
| +func operationWriter(c *hostclaim.Claim, box, kind string) (hostclaim.AdmissionRecord, bool) { |
| if c == nil || c.Holder == nil || box == "" || c.Holder.Session == 0 { |
| return hostclaim.AdmissionRecord{}, false |
| } |
| h := c.Holder |
| - if c.Op.Kind != hostclaim.KindLanding || c.Op.ID == "" || c.Op.Exec != box || h.WriterBox != box || |
| - !hostclaim.SessionPhase(hostclaim.KindLanding, c.Op.Phase) { |
| + if c.Op.Kind != kind || c.Op.ID == "" || c.Op.Exec != box || h.WriterBox != box || |
| + !hostclaim.SessionPhase(kind, c.Op.Phase) { |
| return hostclaim.AdmissionRecord{}, false |
| } |
| var found hostclaim.AdmissionRecord |
| @@ -61,7 +65,16 @@ func LandingWriter(c *hostclaim.Claim, box string) (hostclaim.AdmissionRecord, b |
| // LandingWriter holds, the session was admitted in region, and no chore of |
| // the claim carries the same id. Any other claim has no writer stop. |
| func LandingWriterStop(c *hostclaim.Claim, box, region string) (string, bool) { |
| - a, ok := LandingWriter(c, box) |
| + return operationWriterStop(c, box, region, hostclaim.KindLanding) |
| +} |
| + |
| +// RelocationWriterStop authorizes only this box's admitted relocation writer. |
| +func RelocationWriterStop(c *hostclaim.Claim, box, region string) (string, bool) { |
| + return operationWriterStop(c, box, region, hostclaim.KindRelocating) |
| +} |
| + |
| +func operationWriterStop(c *hostclaim.Claim, box, region, kind string) (string, bool) { |
| + a, ok := operationWriter(c, box, kind) |
| if !ok || region == "" || a.Region != region { |
| return "", false |
| } |
diff --git a/internal/instancectl/stagedexe.go b/internal/instancectl/stagedexe.go| index 34dbc66ad..39537c3e8 100644 |
| --- a/internal/instancectl/stagedexe.go |
| +++ b/internal/instancectl/stagedexe.go |
| @@ -77,6 +77,11 @@ func (s *Server) validateStagedExe(host string, x StagedExe) error { |
| return nil |
| } |
| +// ValidateStagedExe applies the same host-app check to root claim launches. |
| +func (s *Server) ValidateStagedExe(host string, x StagedExe) error { |
| + return s.validateStagedExe(host, x) |
| +} |
| + |
| // refuseStagedExe answers 403 for a start whose staged executable root |
| // cannot vouch for, and reports whether it did. A request that names none |
| // is not refused here. It is the one place both start paths (a generation's |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 2dcb31132..a10d23242 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -626,7 +626,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/genselect/select.go", "requestLocation", "key-site", "lit:instances", "12ef5b8f94be7575ec7ab5f169736320ee6938440387fe5ea99b96f2a528cca0"}, |
| {"internal/host/bootstrap.go", "const tmpfilesConf", "key-site", "lit:instances", "71d83f240cb1c3ab6f9dd2954b1f3e93b90133362ec12193746f60e75aeced7d"}, |
| {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "f75c2a0360970669bc72ef73285ee530dbb8ddc4457ddd73ad91f4939731b75c"}, |
| - {"internal/host/claimlaunch_production.go", "claimLaunchFor", "key-site", "lit:instances", "c89a42536732dbd0399332d3d73260e72011c0231e5a4adfdc8a5ce4f0f49069"}, |
| + {"internal/host/claimlaunch_production.go", "claimLaunchFor", "key-site", "lit:instances", "2b56f3c99b28fe7c2def2eb963db5de76f5ee69ea8834c84124c74801a214034"}, |
| {"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"}, |
| {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"}, |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
| @@ -657,7 +657,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"}, |
| {"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"}, |
| {"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"}, |
| - {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "c5bd625b9136e6c861b49f5bba706876342507d759974244b73026ee09458576"}, |
| + {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "99d6830102942188370207b21a87245056b1a56af1f95344b0337091e720823f"}, |
| {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "5d554df4562248c6e6467f5009910dc4e5502bc09679baa55f0d2cd1bdc06037"}, |
| {"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"}, |
| {"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"}, |