carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 5d6c1c11e9d8098fcd8f2cd39f5e0d145ff8c04c parent 47ebe0d
38 files changed, +949 −100
  • internal/console/hostreports_test.go +1 −0
  • internal/host/claimconstruction.go +6 −4
  • internal/host/claimhandover_crossbox.go +38 −3
  • internal/host/claimlaunch_authority.go +3 −0
  • internal/host/claimlaunch_fake_test.go +26 −0
  • internal/host/claimlaunch_observations.go +20 −3
  • internal/host/claimlaunch_production.go +16 −1
  • internal/host/claimlaunch_rev3_boundary_test.go +2 −0
  • internal/host/claimlaunch_root.go +9 −1
  • internal/host/claimlaunch_root_test.go +1 −1
  • internal/host/claimlaunch_transport_test.go +0 −18
  • internal/host/claimowners.go +5 −2
  • internal/host/claimstop_kernel_linux.go +50 −21
  • internal/host/claimstop_writers_linux.go +73 −20
  • internal/host/claimstop_writers_linux_test.go +7 −2
  • internal/host/claimwakestop.go +9 −4
  • internal/host/host_test.go +6 −1
  • internal/host/plan11_dispatch_test.go +1 −1
  • internal/host/plan11_fix1_coverage_linux_test.go +54 −0
  • internal/host/plan11_fix1_journal_linux_test.go +100 −0
  • internal/host/plan11_fix1_launch_test.go +62 −0
  • internal/host/plan11_fix1_recovery_test.go +52 −0
  • internal/host/plan11_fix1_relocation_test.go +161 −0
  • internal/host/plan11_fix1_writers_test.go +54 −0
  • internal/host/plan11_stop_launch_test.go +3 −0
  • internal/hostactors/plan11_console_reenroll_test.go +1 −0
  • internal/hostactors/plan11_enrolled_test.go +1 −0
  • internal/hostactors/plan11_fix1_enrolled_test.go +42 −0
  • internal/hostactors/roster.go +5 −1
  • internal/hostbox/launchhosts.go +38 −0
  • internal/hostbox/plan11_fix1_projection_test.go +24 −0
  • internal/hostbox/stopjournal.go +12 −10
  • internal/hostbox/stopjournal_reader.go +4 −1
  • internal/hostguard/fence.go +5 −1
  • internal/hostguard/plan11_fix1_writerstop_test.go +35 −0
  • internal/hostguard/writerstop.go +16 −3
  • internal/instancectl/stagedexe.go +5 −0
  • internal/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"},