carlos / platform Public

claim Plan 14 track E checkpoint 59 (not for merge)

Always-running controller boot and adoption progress.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 205170e09c3e77d85eb487f65af08f006b79d0d4 parent 8d7aa3b
17 files changed, +1264 −124
  • internal/host/claimlaunch_authority.go +5 −0
  • internal/host/claimlaunch_root.go +6 −0
  • internal/host/claimstop_run.go +3 −0
  • internal/host/claimunit_controller.go +15 −0
  • internal/host/claimunit_controller_durability_root_linux_test.go +124 −0
  • internal/host/claimunit_controller_linux_test.go +40 −29
  • internal/host/claimunit_controller_other.go +12 −0
  • internal/host/claimunit_controller_owner_linux.go +523 −0
  • internal/host/claimunit_controller_owner_linux_test.go +244 −0
  • internal/host/{claimunit_controller_process_linux_test.go => claimunit_controller_process_linux.go} +97 −10
  • internal/host/claimunit_controller_reboot_root_linux_test.go +90 −0
  • internal/host/claimunit_production.go +22 −2
  • internal/host/claimunit_startup_test.go +12 −1
  • internal/host/plan14_controller_transport_root_linux_test.go +33 −51
  • internal/host/plan14_installed_launch_root_linux_test.go +3 −22
  • internal/provision/testdata/plan14-root-reads-e.tsv +32 −6
  • internal/provision/testdata/plan14-root-reads-p3.tsv +3 −3

CI failed — run details

CI log
=== gate ===
go: downloading golang.org/x/crypto v0.53.0
go: downloading golang.org/x/net v0.56.0
go: downloading golang.org/x/text v0.38.0
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
go: downloading golang.org/x/tools v0.47.0
go: downloading golang.org/x/mod v0.37.0
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	307.994s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
?   	github.com/carlosframework/platform/cmd/console	[no test files]
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.321s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	251.068s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.484s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.159s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.679s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.697s
ok  	github.com/carlosframework/platform/internal/changelog	0.336s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	393.650s
2026/10/16 13:30:21 console: email doors will refuse — SES provisioner unavailable (region eu-west-1): boom: no AWS credentials on this box
2026/10/16 13:30:21 console: platform email domains will emit records for the member instead of CARLOS publishing them — Route53 DNS writer unavailable: boom: route53 unreachable
2026/10/16 13:30:21 console: CONSOLE_GEOIP_DB /tmp/TestOpenGeoIPIsOptionalAndNeverFatal4141572334/001/missing.mmdb is unusable, so the IP step of region guesses is off: verifier: exit status 2
2026/10/16 13:30:21 console: GeoIP database loaded from ../../internal/geo/testdata/GeoIP2-City-Test.mmdb
2026/10/16 13:30:21 console: GeoIP database loaded from ../../internal/geo/testdata/GeoIP2-City-Test.mmdb
2026/10/16 13:30:21 console: CONSOLE_GEOIP_DB ../../internal/geo/testdata/GeoIP2-City-Test.mmdb expired (GeoLite2, 30 days after build) and was closed. replace it with a current edition, destroy the old file, and restart
2026/10/16 13:30:21 console: CONSOLE_GEOIP_DB ../../internal/geo/testdata/GeoIP2-City-Test.mmdb is a GeoLite2 database more than 30 days old; it was not opened. replace it with a current edition and destroy the old file
2026/10/16 13:30:21 console: CONSOLE_GEOIP_DB /tmp/TestConsoleGeoReaderNeverYieldsATypedNil2746781865/001/missing.mmdb is unusable, so the IP step of region guesses is off: verifier: exit status 2
2026/10/16 13:30:21 console: CARLOS_HIBERNATE_IDLE "soon": want a positive duration like 15m or 90s; the carlos db door is off
--- FAIL: TestClaimEnabledCommandStartupWithInstanceFixture (0.08s)
    --- FAIL: TestClaimEnabledCommandStartupWithInstanceFixture/untrusted-roster (0.04s)
        claim_startup_test.go:115: startup: exit status 1
            hostops: required contract unavailable
    --- FAIL: TestClaimEnabledCommandStartupWithInstanceFixture/enabled (0.04s)
        claim_startup_test.go:115: startup: exit status 1
            hostops: required contract unavailable
FAIL
FAIL	github.com/carlosframework/platform/internal/consolecmd	2.892s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.688s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.382s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.005s
ok  	github.com/carlosframework/platform/internal/edge	26.751s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.175s
ok  	github.com/carlosframework/platform/internal/gate	1.657s
ok  	github.com/carlosframework/platform/internal/genselect	0.018s
ok  	github.com/carlosframework/platform/internal/geo	0.023s
ok  	github.com/carlosframework/platform/internal/geoip	0.036s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.214s
ok  	github.com/carlosframework/platform/internal/host	177.649s
ok  	github.com/carlosframework/platform/internal/hostactors	0.366s
ok  	github.com/carlosframework/platform/internal/hostbox	2.664s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.485s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	4.948s
ok  	github.com/carlosframework/platform/internal/hostfence	0.030s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.545s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	0.991s
ok  	github.com/carlosframework/platform/internal/hostops	1.570s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.004s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.449s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.145s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.009s
ok  	github.com/carlosframework/platform/internal/infraview	0.003s
ok  	github.com/carlosframework/platform/internal/instancectl	5.245s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.735s
ok  	github.com/carlosframework/platform/internal/instancefs	0.009s
ok  	github.com/carlosframework/platform/internal/instancekey	0.003s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.010s
ok  	github.com/carlosframework/platform/internal/layout	1.523s
ok  	github.com/carlosframework/platform/internal/lease	9.828s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.009s
ok  	github.com/carlosframework/platform/internal/ledger	0.026s
ok  	github.com/carlosframework/platform/internal/mail	0.172s
ok  	github.com/carlosframework/platform/internal/maintenance	0.091s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.526s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.063s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	21.178s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.760s
ok  	github.com/carlosframework/platform/internal/objstore	0.181s
ok  	github.com/carlosframework/platform/internal/ops	0.179s
ok  	github.com/carlosframework/platform/internal/park	0.195s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	36.592s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.449s
ok  	github.com/carlosframework/platform/internal/relay	7.251s
ok  	github.com/carlosframework/platform/internal/release	22.419s
ok  	github.com/carlosframework/platform/internal/rollout	3.841s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.059s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.579s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.500s
ok  	github.com/carlosframework/platform/internal/seeds	0.010s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.097s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.513s
ok  	github.com/carlosframework/platform/internal/status	0.046s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.237s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.551s
FAIL
make: *** [Makefile:18: test] Error 1

amadan: step gate failed: exit status 2

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go
index 45aa0e48c..00313c586 100644
--- a/internal/host/claimlaunch_authority.go
+++ b/internal/host/claimlaunch_authority.go
@@ -68,6 +68,11 @@ func (h *ClaimLaunchRoot) launchSpec(q hostbox.RoleLaunchRequest) (instancectl.L
// authorize is always called under the host guard, including inside
// StartChecked after namespace/tier preparation and immediately before start.
func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest, c *hostclaim.Claim) error {
+ if h.unitControllers != nil {
+ if err := h.unitControllers.authorize(ctx, peer, q); err != nil {
+ return err
+ }
+ }
t, r := q.Reservation.Target, *q.Reservation
if err := h.checkUnitBinding(ctx, t, false); err != nil {
return err
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go
index 354b7a57d..a557bd5aa 100644
--- a/internal/host/claimlaunch_root.go
+++ b/internal/host/claimlaunch_root.go
@@ -62,6 +62,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 {
+ unitControllers claimUnitControllerAuthority
boundReady func(context.Context, string, instancectl.ClaimUnitStarted) error
unitProgram func(claimUnitShape) (claimUnitProgram, error)
unitBindings *claimUnitBindings
@@ -100,6 +101,11 @@ func (h *ClaimLaunchRoot) Serve(ctx context.Context, peer StopPeer, q hostbox.Ro
if peer.UID == 0 || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" {
return hostbox.RoleLaunchResponse{}, ErrClaimStopIdentity
}
+ if h.unitControllers != nil {
+ if err := h.unitControllers.authorize(ctx, peer, q); err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ }
out := hostbox.RoleLaunchResponse{V: hostbox.RoleLaunchV, Protocol: hostbox.RoleLaunchProtocol}
switch q.Op {
case hostbox.RoleLaunchInputs:
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go
index 304add7e6..4c891822d 100644
--- a/internal/host/claimstop_run.go
+++ b/internal/host/claimstop_run.go
@@ -17,6 +17,9 @@ import (
)
func RunInstanceControl(args []string) {
+ if runClaimUnitController(args) {
+ return
+ }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := registerProductionLaunchVerifier(); err != nil {
diff --git a/internal/host/claimunit_controller.go b/internal/host/claimunit_controller.go
new file mode 100644
index 000000000..11d5f06db
--- /dev/null
+++ b/internal/host/claimunit_controller.go
@@ -0,0 +1,15 @@
+package host
+
+import (
+ "context"
+ "net"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+)
+
+type claimUnitControllerAuthority interface {
+ authorize(context.Context, StopPeer, hostbox.RoleLaunchRequest) error
+ authenticate(context.Context, net.Conn) (StopPeer, error)
+ start(context.Context, claimUnitBinding) error
+ close(context.Context) error
+}
diff --git a/internal/host/claimunit_controller_durability_root_linux_test.go b/internal/host/claimunit_controller_durability_root_linux_test.go
new file mode 100644
index 000000000..4f21b0f34
--- /dev/null
+++ b/internal/host/claimunit_controller_durability_root_linux_test.go
@@ -0,0 +1,124 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14UnitControllerDurabilityRootRunner(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Fatal("root required")
+ }
+ uid, gid := generationRunnerRegistryUser(t)
+ for _, point := range []string{"rename", "parent-sync"} {
+ for _, after := range []bool{false, true} {
+ t.Run(point+map[bool]string{false: "-before", true: "-after"}[after], func(t *testing.T) {
+ dir := generationRootRunnerDir(t)
+ state, err := rootstate.Open(filepath.Join(dir, "controllers"), true, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer state.Close()
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := plan14ControllerCommand(t)
+ cmd.Env = append(cmd.Env, "PLAN14_UNIT_CONTROLLER_PRODUCTION=1")
+ process, err := startClaimUnitControllerProcess(cmd, claimUnitControllerReady)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer process.Close(t.Context())
+ raw, err := os.ReadFile("/proc/" + strconv.Itoa(process.peer.PID) + "/status")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for key, want := range map[string]string{
+ "Uid:": strings.Repeat(strconv.Itoa(uid)+" ", 4),
+ "Gid:": strings.Repeat(strconv.Itoa(gid)+" ", 4),
+ "Groups:": "",
+ "CapEff:": "0000000000000000",
+ "CapPrm:": "0000000000000000",
+ "CapAmb:": "0000000000000000",
+ } {
+ found := false
+ for _, line := range strings.Split(string(raw), "\n") {
+ if strings.HasPrefix(line, key) {
+ found = true
+ if strings.Join(strings.Fields(strings.TrimPrefix(line, key)), " ") != strings.TrimSpace(want) {
+ t.Fatal("controller retained privilege", line)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("missing controller identity field", key)
+ }
+ }
+ injected := errors.New("controller persistence interrupted")
+ hit := false
+ faulty := rootstate.WithWriteFault(state, func(_ string, stage string, effect func() error) error {
+ if stage != point {
+ return effect()
+ }
+ hit = true
+ if after {
+ if err := effect(); err != nil {
+ return err
+ }
+ }
+ return injected
+ })
+ owner := newClaimUnitControllers(b, faulty)
+ if err := owner.register(t.Context(), binding, process); !errors.Is(err, injected) || !hit {
+ t.Fatal("durability fault missed", err)
+ }
+ if len(owner.live) != 0 {
+ t.Fatal("uncertain persistence granted authority")
+ }
+ reopened, err := rootstate.Open(filepath.Join(dir, "controllers"), false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ recovered := newClaimUnitControllers(b, reopened)
+ rec, err := recovered.read(in.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ if err := process.Alive(t.Context()); err != nil {
+ t.Fatal("unrecorded process changed", err)
+ }
+ if err := process.Close(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ if err != nil || rec.Closed || rec.Peer != process.peer {
+ t.Fatal("uncertain record changed owner", rec, err)
+ }
+ if err := recovered.recover(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ if process.Alive(t.Context()) == nil {
+ t.Fatal("recovery acknowledged an unjoined process")
+ }
+ if len(recovered.live) != 0 {
+ t.Fatal("recovery resurrected controller authority")
+ }
+ route := binding.Shape.route()
+ if recovered.authorize(t.Context(), process.peer, hostbox.RoleLaunchRequest{Op: hostbox.RoleLaunchInputs, Route: &route}) == nil {
+ t.Fatal("closed record admitted work")
+ }
+ })
+ }
+ }
+}
diff --git a/internal/host/claimunit_controller_linux_test.go b/internal/host/claimunit_controller_linux_test.go
index 860214035..ccf0fd69c 100644
--- a/internal/host/claimunit_controller_linux_test.go
+++ b/internal/host/claimunit_controller_linux_test.go
@@ -4,7 +4,6 @@ package host
import (
"context"
- "encoding/json"
"errors"
"io"
"os"
@@ -22,34 +21,23 @@ func TestPlan14UnitControllerProcessChild(t *testing.T) {
if os.Getenv("PLAN14_UNIT_CONTROLLER_CHILD") != "1" {
return
}
+ if os.Getenv("PLAN14_UNIT_CONTROLLER_PRODUCTION") == "1" {
+ runClaimUnitController([]string{"-claim-unit-controller"})
+ os.Exit(0)
+ }
if os.Getenv("PLAN14_UNIT_CONTROLLER_NODUMP") == "1" {
if unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) != nil {
os.Exit(1)
}
}
+ if os.Getenv("PLAN14_UNIT_CONTROLLER_SILENT") == "1" {
+ _, _ = io.Copy(io.Discard, os.Stdin)
+ os.Exit(0)
+ }
os.Stdout.Write([]byte{1})
if os.Getenv("PLAN14_UNIT_CONTROLLER_RPC") == "1" {
- decoder := json.NewDecoder(os.Stdin)
- encoder := json.NewEncoder(os.Stdout)
- for {
- var call plan14UnitControllerCall
- if err := decoder.Decode(&call); err != nil {
- if err == io.EOF {
- os.Exit(0)
- }
- os.Exit(1)
- }
- ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
- reply, err := NewClaimRoleRootClient(call.Socket).call(ctx, call.Request)
- cancel()
- out := plan14UnitControllerReply{Response: reply}
- if err != nil {
- out.Failure = err.Error()
- }
- if encoder.Encode(out) != nil {
- os.Exit(1)
- }
- }
+ claimUnitControllerLoop(os.Stdin, os.Stdout)
+ os.Exit(0)
}
if _, err := io.Copy(io.Discard, os.Stdin); err != nil {
os.Exit(1)
@@ -234,11 +222,34 @@ func TestPlan14UnitControllerParentChannelLoss(t *testing.T) {
}
}
-type plan14UnitControllerCall struct {
- Socket string
- Request hostbox.RoleLaunchRequest
-}
-type plan14UnitControllerReply struct {
- Response hostbox.RoleLaunchResponse
- Failure string
+func TestPlan14UnitControllerCallCancellation(t *testing.T) {
+ cmd := plan14ControllerCommand(t)
+ cmd.Env = append(cmd.Env, "PLAN14_UNIT_CONTROLLER_SILENT=1")
+ worker, err := startClaimUnitControllerProcess(cmd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer worker.Close(t.Context())
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan error, 1)
+ go func() {
+ _, err := worker.call(ctx, "/run/nonexistent.sock", hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchCapability})
+ done <- err
+ }()
+ time.Sleep(20 * time.Millisecond)
+ cancel()
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("canceled channel retained its controller")
+ }
+ if worker.Alive(t.Context()) == nil {
+ t.Fatal("uncertain channel remained authorized")
+ }
+ if _, err := worker.call(t.Context(), "/run/nonexistent.sock", hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchCapability}); err == nil {
+ t.Fatal("uncertain reply reused")
+ }
}
diff --git a/internal/host/claimunit_controller_other.go b/internal/host/claimunit_controller_other.go
new file mode 100644
index 000000000..49e5eabc4
--- /dev/null
+++ b/internal/host/claimunit_controller_other.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func newClaimUnitControllers(*claimUnitBindings, rootstate.Store, ...*ClaimLaunchRoot) claimUnitControllerAuthority {
+ return nil
+}
+func runClaimUnitController([]string) bool { return false }
diff --git a/internal/host/claimunit_controller_owner_linux.go b/internal/host/claimunit_controller_owner_linux.go
new file mode 100644
index 000000000..d61415c33
--- /dev/null
+++ b/internal/host/claimunit_controller_owner_linux.go
@@ -0,0 +1,523 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlsock"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+ "golang.org/x/sys/unix"
+)
+
+type claimUnitControllerRecord struct {
+ Binding claimUnitBinding
+ Peer StopPeer
+ Closed bool
+}
+
+type claimUnitControllers struct {
+ mu sync.Mutex
+ bindings *claimUnitBindings
+ state rootstate.Store
+ live map[string]*claimUnitControllerProcess
+ root *ClaimLaunchRoot
+ transports []func(context.Context) error
+ closeMu sync.Mutex
+ closing bool
+}
+
+func newClaimUnitControllers(b *claimUnitBindings, state rootstate.Store, root ...*ClaimLaunchRoot) *claimUnitControllers {
+ o := &claimUnitControllers{bindings: b, state: state, live: map[string]*claimUnitControllerProcess{}}
+ if len(root) == 1 {
+ o.root = root[0]
+ }
+ return o
+}
+
+func (o *claimUnitControllers) read(host string) (claimUnitControllerRecord, error) {
+ var rec claimUnitControllerRecord
+ if o.state == nil || !provision.ValidHostShape(host) {
+ return rec, os.ErrPermission
+ }
+ raw, err := o.state.Read(host + ".json")
+ if err != nil {
+ return rec, err
+ }
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if len(raw) > rootstate.MaxRecord || dec.Decode(&rec) != nil || dec.Decode(new(any)) != io.EOF || !rec.Binding.valid() || rec.Binding.Owner.Host != host || rec.Binding.Retiring || rec.Peer.UID == 0 || rec.Peer.PID <= 0 || rec.Peer.ProcessStart == "" || rec.Peer.BootID == "" || rec.Peer.ControllerID == "" {
+ return claimUnitControllerRecord{}, os.ErrPermission
+ }
+ return rec, nil
+}
+
+func (o *claimUnitControllers) write(rec claimUnitControllerRecord) error {
+ raw, err := json.Marshal(rec)
+ if err != nil {
+ return err
+ }
+ return o.state.Write(rec.Binding.Owner.Host+".json", raw)
+}
+
+func (o *claimUnitControllers) register(ctx context.Context, binding claimUnitBinding, process *claimUnitControllerProcess) error {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if o.closing || process == nil || process.peer.UID == 0 || !binding.valid() || binding.Retiring {
+ return os.ErrPermission
+ }
+ for _, live := range o.live {
+ if live.peer == process.peer {
+ return os.ErrPermission
+ }
+ }
+ current, err := o.bindings.check(ctx, binding.Owner.Host, nil)
+ if err != nil {
+ return err
+ }
+ if current != binding {
+ return os.ErrPermission
+ }
+ if err := process.Alive(ctx); err != nil {
+ return err
+ }
+ unlock, err := o.state.Lock(ctx, "controllers")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ previous, err := o.read(binding.Owner.Host)
+ if err == nil {
+ if !previous.Closed || o.live[binding.Owner.Host] != nil {
+ return os.ErrPermission
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if err := o.write(claimUnitControllerRecord{Binding: binding, Peer: process.peer}); err != nil {
+ return err
+ }
+ o.live[binding.Owner.Host] = process
+ return nil
+}
+
+func (o *claimUnitControllers) authorize(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest) error {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ host := ""
+ if q.Route != nil {
+ host = q.Route.Host
+ }
+ if q.Reservation != nil {
+ host = q.Reservation.Target.Host
+ }
+ for name, process := range o.live {
+ if process.peer != peer {
+ continue
+ }
+ if err := process.Alive(ctx); err != nil {
+ return err
+ }
+ rec, err := o.read(name)
+ if err != nil {
+ return err
+ }
+ if rec.Closed || rec.Peer != peer {
+ return os.ErrPermission
+ }
+ current, err := o.bindings.check(ctx, name, nil)
+ if q.Op == hostbox.RoleLaunchStop {
+ current, err = o.bindings.read(name)
+ current.Retiring = false
+ current.Retired = false
+ }
+ if err != nil {
+ return err
+ }
+ if current != rec.Binding {
+ return os.ErrPermission
+ }
+ if q.Op == hostbox.RoleLaunchCapability {
+ if q.Kind != "" && q.Kind != hostclaim.KindServing {
+ return os.ErrPermission
+ }
+ return nil
+ }
+ if host != name {
+ return os.ErrPermission
+ }
+ if q.Route != nil && !current.Shape.matches(*q.Route) {
+ return os.ErrPermission
+ }
+ if q.Reservation != nil {
+ target := q.Reservation.Target
+ in := current.Owner
+ if target.Incarnation != in.Incarnation || target.Stamp != in.Stamp || target.Box != in.Box || target.Enrollment != in.Enrollment || target.BootID != peer.BootID || target.ControllerID != peer.ControllerID {
+ return os.ErrPermission
+ }
+ }
+ return nil
+ }
+ if host != "" {
+ binding, err := o.bindings.read(host)
+ if err == nil && binding.Shape.Backing == "" {
+ return os.ErrPermission
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+ return nil
+}
+
+func (o *claimUnitControllers) authenticate(ctx context.Context, conn net.Conn) (StopPeer, error) {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if o.closing {
+ return StopPeer{}, os.ErrClosed
+ }
+ for host, process := range o.live {
+ p, err := instancectl.AuthenticatePeerTuple(conn, process.peer.UID, process.peer.PID, process.peer.ControllerID)
+ if err != nil {
+ continue
+ }
+ peer := StopPeer{UID: p.UID, PID: p.PID, ProcessStart: p.ProcessStart, BootID: p.BootID, ControllerID: p.ControllerID}
+ if peer != process.peer {
+ return StopPeer{}, os.ErrPermission
+ }
+ if err := process.Alive(ctx); err != nil {
+ return StopPeer{}, err
+ }
+ rec, err := o.read(host)
+ if err != nil {
+ return StopPeer{}, err
+ }
+ if rec.Closed || rec.Peer != peer {
+ return StopPeer{}, os.ErrPermission
+ }
+ return peer, nil
+ }
+ return StopPeer{}, os.ErrPermission
+}
+
+func (o *claimUnitControllers) recover(ctx context.Context, host string) error {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ unlock, err := o.state.Lock(ctx, "controllers")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ rec, err := o.read(host)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if live := o.live[host]; live != nil && live.peer != rec.Peer {
+ return os.ErrPermission
+ }
+ if rec.Closed {
+ if live := o.live[host]; live != nil {
+ if err := live.Close(ctx); err != nil {
+ return err
+ }
+ delete(o.live, host)
+ }
+ return nil
+ }
+ binding := release.GenerationBinding{ControllerPID: rec.Peer.PID, ControllerStart: rec.Peer.ProcessStart}
+ binding.Target.BootID = rec.Peer.BootID
+ kernel := generationKernel{proc: "/proc"}
+ if kernel.controllerGone(ctx, binding) != nil {
+ handle, err := openClaimUnitControllerProcess(ctx, rec.Peer)
+ if err != nil {
+ return err
+ }
+ defer handle.Close()
+ if err := handle.Stop(ctx); err != nil {
+ return err
+ }
+ ticker := time.NewTicker(10 * time.Millisecond)
+ defer ticker.Stop()
+ for kernel.controllerGone(ctx, binding) != nil {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ }
+ }
+ }
+ if live := o.live[host]; live != nil {
+ if err := live.Close(ctx); err != nil {
+ return err
+ }
+ }
+ rec.Closed = true
+ if err := o.write(rec); err != nil {
+ return err
+ }
+ delete(o.live, host)
+ return nil
+}
+
+func (o *claimUnitControllers) close(ctx context.Context) error {
+ o.closeMu.Lock()
+ defer o.closeMu.Unlock()
+ o.mu.Lock()
+ o.closing = true
+ transports := append([]func(context.Context) error(nil), o.transports...)
+ o.mu.Unlock()
+ var errs []error
+ for _, stop := range transports {
+ if err := stop(ctx); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ for host, process := range o.live {
+ if err := process.Close(ctx); err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ rec, err := o.read(host)
+ if err == nil && rec.Peer != process.peer {
+ err = os.ErrPermission
+ }
+ if err == nil {
+ rec.Closed = true
+ err = o.write(rec)
+ }
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ delete(o.live, host)
+ }
+ if len(errs) == 0 {
+ o.transports = nil
+ if o.state != nil {
+ errs = append(errs, o.state.Close())
+ o.state = nil
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func (o *claimUnitControllers) start(ctx context.Context, binding claimUnitBinding) (resultErr error) {
+ if !claimNamedUnit4aComplete {
+ return hostbox.ErrUnsupported
+ }
+ if o.state == nil {
+ state, err := rootstate.Open("/var/lib/carlos-claims/unit-controllers", true, false)
+ if err != nil {
+ return err
+ }
+ o.state = state
+ }
+ if err := o.recover(ctx, binding.Owner.Host); err != nil {
+ return err
+ }
+ cmd := exec.Command("/proc/self/exe", "instance-control", "-claim-unit-controller")
+ cmd.Dir = "/"
+ cmd.Env = []string{}
+
+ process, err := startClaimUnitControllerProcess(cmd, claimUnitControllerReady)
+ if err != nil {
+ return err
+ }
+ started := false
+ var stopTransport func(context.Context) error
+ defer func() {
+ if !started {
+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if stopTransport != nil {
+ resultErr = errors.Join(resultErr, stopTransport(cleanup))
+ }
+ resultErr = errors.Join(resultErr, process.Close(cleanup))
+ }
+ }()
+ if err := o.register(ctx, binding, process); err != nil {
+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ return errors.Join(err, process.Close(cleanup))
+ }
+ if o.root == nil {
+ return os.ErrPermission
+ }
+ if err := rootstate.EnsurePublicDir(controlsock.Dir); err != nil {
+ return err
+ }
+ socket := filepath.Join(controlsock.Dir, "unit-"+digestStop(process.peer)[:24]+".sock")
+ ln, err := net.Listen("unix", socket)
+ if err != nil {
+ return err
+ }
+ if err := os.Chmod(socket, 0666); err != nil {
+ ln.Close()
+ return err
+ }
+ stop, err := o.serveLaunch(o.root, ln, participationRelease)
+ if err != nil {
+ ln.Close()
+ return err
+ }
+ stopTransport = stop
+ if _, err := process.call(ctx, socket, hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchCapability}); err != nil {
+ return err
+ }
+ o.mu.Lock()
+ if o.closing {
+ o.mu.Unlock()
+ return os.ErrClosed
+ }
+ o.transports = append(o.transports, stop)
+ o.mu.Unlock()
+ started = true
+ return nil
+}
+
+func runClaimUnitController(args []string) bool {
+ if len(args) != 1 || args[0] != "-claim-unit-controller" {
+ return false
+ }
+ u, err := user.Lookup("carlos")
+ if err != nil {
+ return true
+ }
+ uid, err := strconv.Atoi(u.Uid)
+ if err != nil || uid <= 0 {
+ return true
+ }
+ gid, err := strconv.Atoi(u.Gid)
+ if err != nil || gid < 0 {
+ return true
+ }
+ if os.Geteuid() == 0 {
+ if syscall.Setgroups([]int{}) != nil || syscall.Setgid(gid) != nil || syscall.Setuid(uid) != nil {
+ return true
+ }
+ }
+ if os.Getuid() != uid || os.Geteuid() != uid || os.Getgid() != gid || os.Getegid() != gid || unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) != nil {
+ return true
+ }
+ if _, err := os.Stdout.Write([]byte{1}); err != nil {
+ return true
+ }
+ claimUnitControllerLoop(os.Stdin, os.Stdout)
+ return true
+}
+
+type claimUnitControllerCall struct {
+ Socket string
+ Request hostbox.RoleLaunchRequest
+}
+type claimUnitControllerReply struct {
+ Response hostbox.RoleLaunchResponse
+ Failed bool
+}
+
+func claimUnitControllerLoop(input io.Reader, output io.Writer) {
+ decoder, encoder := json.NewDecoder(input), json.NewEncoder(output)
+ for {
+ var call claimUnitControllerCall
+ if decoder.Decode(&call) != nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), hostbox.RequestLimit)
+ reply, err := NewClaimRoleRootClient(call.Socket).call(ctx, call.Request)
+ cancel()
+ if encoder.Encode(claimUnitControllerReply{Response: reply, Failed: err != nil}) != nil {
+ return
+ }
+ }
+}
+
+type claimUnitControllerConnKey struct{}
+
+func (o *claimUnitControllers) serveLaunch(root *ClaimLaunchRoot, ln net.Listener, lock *release.ReleaseExclusion) (func(context.Context) error, error) {
+ if root == nil || ln == nil || lock == nil {
+ return nil, os.ErrPermission
+ }
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/claim/launch" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ conn, _ := r.Context().Value(claimUnitControllerConnKey{}).(net.Conn)
+ peer, err := o.authenticate(r.Context(), conn)
+ if err != nil {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ q, err := hostbox.DecodeRoleLaunchRequest(r.Body)
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ var result hostbox.RoleLaunchResponse
+ err = lock.With(r.Context(), func(ctx context.Context) error {
+ if err := o.authorize(ctx, peer, q); err != nil {
+ return err
+ }
+ var err error
+ result, err = root.Serve(ctx, peer, q)
+ return err
+ })
+ if err != nil {
+ w.WriteHeader(claimLaunchStatus(err))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(result)
+ })
+ server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: hostbox.RequestLimit, WriteTimeout: hostbox.RequestLimit, ConnContext: func(ctx context.Context, c net.Conn) context.Context {
+ return context.WithValue(ctx, claimUnitControllerConnKey{}, c)
+ }}
+ done := make(chan struct{})
+ go func() { defer close(done); _ = server.Serve(ln) }()
+ return func(ctx context.Context) error {
+ err := server.Shutdown(ctx)
+ if err != nil {
+ _ = server.Close()
+ }
+ <-done
+ return err
+ }, nil
+}
+
+func claimUnitControllerReady(output *os.File) error {
+ if err := output.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+ return err
+ }
+ var signal [1]byte
+ if _, err := io.ReadFull(output, signal[:]); err != nil {
+ return err
+ }
+ if signal[0] != 1 {
+ return os.ErrPermission
+ }
+ return output.SetReadDeadline(time.Time{})
+}
diff --git a/internal/host/claimunit_controller_owner_linux_test.go b/internal/host/claimunit_controller_owner_linux_test.go
new file mode 100644
index 000000000..08d8b36d8
--- /dev/null
+++ b/internal/host/claimunit_controller_owner_linux_test.go
@@ -0,0 +1,244 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+func TestPlan14UnitControllerOwnerScope(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ worker, err := startClaimUnitControllerProcess(plan14ControllerCommand(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer worker.Close(t.Context())
+ state := &rollbackMemory{files: map[string][]byte{}}
+ owners := newClaimUnitControllers(b, state)
+ if err := owners.register(t.Context(), binding, worker); err != nil {
+ t.Fatal(err)
+ }
+ route := shape.route()
+ q := hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchInputs, Route: &route}
+ if err := owners.authorize(t.Context(), worker.peer, q); err != nil {
+ t.Fatal(err)
+ }
+ for _, change := range []func(*hostguard.RootInstallation){
+ func(in *hostguard.RootInstallation) { in.Host = "sibling.example" },
+ func(in *hostguard.RootInstallation) { in.Stamp++ },
+ func(in *hostguard.RootInstallation) { in.Incarnation = "foreign" },
+ func(in *hostguard.RootInstallation) { in.Enrollment = "foreign" },
+ } {
+ foreign := binding.Owner
+ change(&foreign)
+ request := hostbox.RoleLaunchRequest{Op: hostbox.RoleLaunchStart, Reservation: &hostbox.LaunchReservation{Target: hostguard.FenceTarget{Host: foreign.Host, Incarnation: foreign.Incarnation, Stamp: foreign.Stamp, Box: foreign.Box, Enrollment: foreign.Enrollment, BootID: worker.peer.BootID, ControllerID: worker.peer.ControllerID}}}
+ if owners.authorize(t.Context(), worker.peer, request) == nil {
+ t.Fatal("foreign installation admitted", foreign)
+ }
+ }
+ peer := worker.peer
+ peer.PID++
+ if owners.authorize(t.Context(), peer, q) == nil {
+ t.Fatal("edge or sibling obtained named authority")
+ }
+ ordinary := q
+ ordinaryRoute := route
+ ordinaryRoute.Host = "ordinary.example"
+ ordinary.Route = &ordinaryRoute
+ if owners.authorize(t.Context(), worker.peer, ordinary) == nil {
+ t.Fatal("controller obtained sibling authority")
+ }
+ capability := hostbox.RoleLaunchRequest{Op: hostbox.RoleLaunchCapability}
+ if err := owners.authorize(t.Context(), worker.peer, capability); err != nil {
+ t.Fatal(err)
+ }
+ capability.Kind = "files-seed"
+ if owners.authorize(t.Context(), worker.peer, capability) == nil {
+ t.Fatal("controller obtained seed authority")
+ }
+ binding.Retiring = true
+ if err := b.write(binding); err != nil {
+ t.Fatal(err)
+ }
+ if owners.authorize(t.Context(), worker.peer, q) == nil {
+ t.Fatal("retiring installation remained admitted")
+ }
+}
+
+func TestPlan14UnitControllerOwnerDurability(t *testing.T) {
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ worker, err := startClaimUnitControllerProcess(plan14ControllerCommand(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer worker.Close(t.Context())
+ state := &rollbackMemory{files: map[string][]byte{}}
+ owners := newClaimUnitControllers(b, state)
+ failed := &controllerFailWrite{rollbackMemory: state}
+ owners.state = failed
+ if owners.register(t.Context(), binding, worker) == nil {
+ t.Fatal("failed persistence granted authority")
+ }
+ if len(owners.live) != 0 {
+ t.Fatal("failed registration published controller")
+ }
+ owners.state = state
+ if err := owners.register(t.Context(), binding, worker); err != nil {
+ t.Fatal(err)
+ }
+ replacement, err := startClaimUnitControllerProcess(plan14ControllerCommand(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer replacement.Close(t.Context())
+ if owners.register(t.Context(), binding, replacement) == nil {
+ t.Fatal("overwrote live controller")
+ }
+ recovered := newClaimUnitControllers(b, state)
+ if len(recovered.live) != 0 {
+ t.Fatal("disk record alone granted authority")
+ }
+ if err := recovered.recover(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ if worker.Alive(t.Context()) == nil {
+ t.Fatal("recovery did not close exact controller")
+ }
+ if err := worker.Close(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if err := recovered.register(t.Context(), binding, replacement); err != nil {
+ t.Fatal(err)
+ }
+ if err := recovered.close(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if replacement.Alive(t.Context()) == nil {
+ t.Fatal("shutdown left controller alive")
+ }
+}
+
+type controllerFailWrite struct{ *rollbackMemory }
+
+func (*controllerFailWrite) Write(string, []byte) error { return os.ErrPermission }
+
+func TestPlan14UnitControllerOwnerCorruptRecovery(t *testing.T) {
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ state := &rollbackMemory{files: map[string][]byte{in.Host + ".json": []byte("{}")}}
+ owners := newClaimUnitControllers(b, state)
+ if err := owners.recover(context.Background(), in.Host); !errors.Is(err, os.ErrPermission) {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan14UnitControllerOwnerRechecksDurableBinding(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ worker, err := startClaimUnitControllerProcess(plan14ControllerCommand(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer worker.Close(t.Context())
+ state := &rollbackMemory{files: map[string][]byte{}}
+ owners := newClaimUnitControllers(b, state)
+ if err := owners.register(t.Context(), binding, worker); err != nil {
+ t.Fatal(err)
+ }
+ route := shape.route()
+ q := hostbox.RoleLaunchRequest{Op: hostbox.RoleLaunchInputs, Route: &route}
+ raw, err := state.Read(in.Host + ".json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, attack := range []string{"missing", "corrupt", "closed", "rebound"} {
+ t.Run(attack, func(t *testing.T) {
+ defer state.Write(in.Host+".json", raw)
+ switch attack {
+ case "missing":
+ state.Remove(in.Host + ".json")
+ case "corrupt":
+ state.Write(in.Host+".json", []byte("{}"))
+ default:
+ rec, err := owners.read(in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if attack == "closed" {
+ rec.Closed = true
+ } else {
+ rec.Binding.Owner.Stamp++
+ }
+ if err := owners.write(rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if owners.authorize(t.Context(), worker.peer, q) == nil {
+ t.Fatal("changed durable authority admitted", attack)
+ }
+ })
+ }
+ if err := owners.authorize(t.Context(), worker.peer, q); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan14UnitControllerStartupGateCreatesNothing(t *testing.T) {
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ owners := newClaimUnitControllers(b, nil)
+ if err := owners.start(t.Context(), binding); !errors.Is(err, hostbox.ErrUnsupported) {
+ t.Fatal(err)
+ }
+ if owners.state != nil || len(owners.live) != 0 {
+ t.Fatal("closed activation gate created controller authority")
+ }
+}
+
+func TestPlan14UnitControllerSameOwnerRecoveryRetries(t *testing.T) {
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ state := &rollbackMemory{files: map[string][]byte{}}
+ owners := newClaimUnitControllers(b, state)
+ for i := 0; i < 2; i++ {
+ process, err := startClaimUnitControllerProcess(plan14ControllerCommand(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer process.Close(t.Context())
+ if err := owners.register(t.Context(), binding, process); err != nil {
+ t.Fatal("replacement registration", err)
+ }
+ if err := process.Close(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if err := owners.recover(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ if len(owners.live) != 0 {
+ t.Fatal("joined predecessor blocked retry")
+ }
+ }
+}
diff --git a/internal/host/claimunit_controller_process_linux_test.go b/internal/host/claimunit_controller_process_linux.go
similarity index 67%
rename from internal/host/claimunit_controller_process_linux_test.go
rename to internal/host/claimunit_controller_process_linux.go
index 7fa603d81..985464762 100644
--- a/internal/host/claimunit_controller_process_linux_test.go
+++ b/internal/host/claimunit_controller_process_linux.go
@@ -4,6 +4,7 @@ package host
import (
"context"
+ "encoding/json"
"errors"
"os"
"os/exec"
@@ -11,7 +12,9 @@ import (
"strings"
"sync"
"syscall"
+ "time"
+ "github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/release"
"golang.org/x/sys/unix"
)
@@ -23,13 +26,16 @@ type claimUnitControllerHandle struct {
}
type claimUnitControllerProcess struct {
- handle *claimUnitControllerHandle
- peer StopPeer
- input *os.File
- done chan struct{}
- closeMu sync.Mutex
- waitErr error
- closed bool
+ handle *claimUnitControllerHandle
+ peer StopPeer
+ input *os.File
+ done chan struct{}
+ closeMu sync.Mutex
+ waitErr error
+ closed bool
+ output *os.File
+ rpcMu sync.Mutex
+ rpcFailed bool
}
func observedClaimUnitController(ctx context.Context, pid int) (StopPeer, error) {
@@ -175,10 +181,26 @@ func (h *claimUnitControllerHandle) Close() error {
return err
}
-func startClaimUnitControllerProcess(cmd *exec.Cmd) (*claimUnitControllerProcess, error) {
- if cmd == nil || cmd.Stdin != nil || cmd.Process != nil {
+func startClaimUnitControllerProcess(cmd *exec.Cmd, ready ...func(*os.File) error) (*claimUnitControllerProcess, error) {
+ if cmd == nil || cmd.Stdin != nil || cmd.Process != nil || len(ready) > 1 {
return nil, os.ErrPermission
}
+ var replies, replyWriter *os.File
+ if cmd.Stdout == nil {
+ var err error
+ replies, replyWriter, err = os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ defer replyWriter.Close()
+ cmd.Stdout = replyWriter
+ }
+ keepReplies := false
+ defer func() {
+ if replies != nil && !keepReplies {
+ replies.Close()
+ }
+ }()
input, output, err := os.Pipe()
if err != nil {
return nil, err
@@ -209,6 +231,14 @@ func startClaimUnitControllerProcess(cmd *exec.Cmd) (*claimUnitControllerProcess
if fd < 0 {
return failed(os.ErrPermission)
}
+ if len(ready) == 1 {
+ if ready[0] == nil || replies == nil {
+ return failed(os.ErrPermission)
+ }
+ if err := ready[0](replies); err != nil {
+ return failed(err)
+ }
+ }
peer, err := observedClaimUnitController(context.Background(), cmd.Process.Pid)
if err != nil {
return failed(err)
@@ -217,7 +247,8 @@ func startClaimUnitControllerProcess(cmd *exec.Cmd) (*claimUnitControllerProcess
if err := h.alive(context.Background()); err != nil {
return failed(err)
}
- p := &claimUnitControllerProcess{handle: h, peer: peer, input: output, done: make(chan struct{})}
+ p := &claimUnitControllerProcess{handle: h, peer: peer, input: output, output: replies, done: make(chan struct{})}
+ keepReplies = true
go func() { p.waitErr = cmd.Wait(); close(p.done) }()
return p, nil
}
@@ -247,6 +278,9 @@ func (p *claimUnitControllerProcess) Close(ctx context.Context) error {
case <-p.done:
}
p.input.Close()
+ if p.output != nil {
+ p.output.Close()
+ }
var exit *exec.ExitError
if p.waitErr != nil && !errors.As(p.waitErr, &exit) {
return p.waitErr
@@ -262,3 +296,56 @@ func (p *claimUnitControllerProcess) Close(ctx context.Context) error {
p.closed = true
return nil
}
+
+func (p *claimUnitControllerProcess) call(ctx context.Context, socket string, request hostbox.RoleLaunchRequest) (result hostbox.RoleLaunchResponse, resultErr error) {
+ p.rpcMu.Lock()
+ defer p.rpcMu.Unlock()
+ if p.output == nil || p.rpcFailed {
+ return hostbox.RoleLaunchResponse{}, os.ErrPermission
+ }
+ if err := p.Alive(ctx); err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ deadline := time.Now().Add(hostbox.RequestLimit)
+ if end, ok := ctx.Deadline(); ok && end.Before(deadline) {
+ deadline = end
+ }
+ if err := p.input.SetWriteDeadline(deadline); err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ if err := p.output.SetReadDeadline(deadline); err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ interrupted := make(chan struct{})
+ stopCancel := context.AfterFunc(ctx, func() {
+ _ = p.input.SetWriteDeadline(time.Now())
+ _ = p.output.SetReadDeadline(time.Now())
+ close(interrupted)
+ })
+ defer func() {
+ if !stopCancel() {
+ <-interrupted
+ }
+ }()
+ received := false
+ defer func() {
+ if !received {
+ p.rpcFailed = true
+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ resultErr = errors.Join(resultErr, ctx.Err(), p.Close(cleanup))
+ }
+ }()
+ if err := json.NewEncoder(p.input).Encode(claimUnitControllerCall{Socket: socket, Request: request}); err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ var reply claimUnitControllerReply
+ if err := json.NewDecoder(p.output).Decode(&reply); err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ received = true
+ if reply.Failed {
+ return hostbox.RoleLaunchResponse{}, os.ErrPermission
+ }
+ return reply.Response, nil
+}
diff --git a/internal/host/claimunit_controller_reboot_root_linux_test.go b/internal/host/claimunit_controller_reboot_root_linux_test.go
new file mode 100644
index 000000000..7d73b950a
--- /dev/null
+++ b/internal/host/claimunit_controller_reboot_root_linux_test.go
@@ -0,0 +1,90 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const plan14ControllerRebootDir = "/var/lib/carlos-rootrunner-unit-controller-reboot"
+
+func TestRootRebootUnitControllerOwnerPhase1(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Fatal("root required")
+ }
+ generationRunnerRegistryUser(t)
+ if err := os.Mkdir(plan14ControllerRebootDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ state, err := rootstate.Open(filepath.Join(plan14ControllerRebootDir, "controllers"), true, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer state.Close()
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := plan14ControllerCommand(t)
+ cmd.Env = append(cmd.Env, "PLAN14_UNIT_CONTROLLER_PRODUCTION=1")
+ process, err := startClaimUnitControllerProcess(cmd, claimUnitControllerReady)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer process.Close(t.Context())
+ owner := newClaimUnitControllers(b, state)
+ if err := owner.register(t.Context(), binding, process); err != nil {
+ t.Fatal(err)
+ }
+ rec, err := owner.read(in.Host)
+ if err != nil || rec.Closed || rec.Peer != process.peer {
+ t.Fatal("lost live controller", rec, err)
+ }
+}
+
+func TestRootRebootUnitControllerOwnerPhase2(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Fatal("root required")
+ }
+ state, err := rootstate.Open(filepath.Join(plan14ControllerRebootDir, "controllers"), false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer state.Close()
+ owner := newClaimUnitControllers(nil, state)
+ rec, err := owner.read("shop.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ boot, err := (generationKernel{proc: "/proc"}).boot()
+ if err != nil || rec.Peer.BootID == boot {
+ t.Fatal("actual reboot required", err)
+ }
+ sibling, err := startClaimUnitControllerProcess(plan14ControllerCommand(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sibling.Close(t.Context())
+ if err := owner.recover(t.Context(), rec.Binding.Owner.Host); err != nil {
+ t.Fatal(err)
+ }
+ closed, err := owner.read(rec.Binding.Owner.Host)
+ if err != nil || !closed.Closed || closed.Peer != rec.Peer || closed.Binding != rec.Binding {
+ t.Fatal("reboot changed ownership", closed, err)
+ }
+ if err := owner.recover(context.Background(), rec.Binding.Owner.Host); err != nil {
+ t.Fatal("recovery replay", err)
+ }
+ if err := sibling.Alive(t.Context()); err != nil {
+ t.Fatal("recovery killed unrelated process", err)
+ }
+ if err := os.RemoveAll(plan14ControllerRebootDir); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go
index a049a5ccc..2b8b14c49 100644
--- a/internal/host/claimunit_production.go
+++ b/internal/host/claimunit_production.go
@@ -121,6 +121,7 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
return material.verify(binding)
}}
h.Launch.Units, h.Launch.unitBindings = dispatch, b
+ h.Launch.unitControllers = newClaimUnitControllers(b, nil, h.Launch)
h.Launch.unitProgram = readClaimUnitProgram
h.Launch.boundReady = func(ctx context.Context, host string, started instancectl.ClaimUnitStarted) error {
binding, err := b.read(host)
@@ -160,8 +161,14 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
closePrevious := a.Close
a.Close = func(ctx context.Context) error {
var err error
+ if h.Launch.unitControllers != nil {
+ err = h.Launch.unitControllers.close(ctx)
+ if err != nil {
+ return err
+ }
+ }
if closePrevious != nil {
- err = closePrevious(ctx)
+ err = errors.Join(err, closePrevious(ctx))
}
return errors.Join(err, state.Close(), materialState.Close())
}
@@ -187,7 +194,7 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
shape := shapes[host]
// Named services must keep their operator definition until a reserved
// boot/adoption controller can keep them running independently of edge.
- if !claimNamedUnit4aComplete || shape.Backing == "" {
+ if !claimNamedUnit4aComplete {
log.Print(hostbox.ErrUnsupported)
continue
}
@@ -204,6 +211,7 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
log.Print(e)
continue
}
+ var controllerBinding claimUnitBinding
if e = guard.WithLocal(ctx, host, func() error {
current, e := guard.Root.Installation(ctx, host, hostguard.Captured{Incarnation: next.Owner.Incarnation, Stamp: next.Owner.Stamp})
if e != nil {
@@ -225,9 +233,21 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
if e = claimEffectiveUnit(ctx, binding.Shape, base.Systemctl); e != nil {
return e
}
+ if shape.Backing == "" {
+ if h.Launch.unitControllers == nil {
+ return hostbox.ErrUnsupported
+ }
+ controllerBinding = binding
+ }
return projectClaimBoundSocket(binding.Shape)
}); e != nil {
log.Print(e)
+ continue
+ }
+ if controllerBinding.Owner.Host != "" {
+ if e = h.Launch.unitControllers.start(ctx, controllerBinding); e != nil {
+ log.Print(e)
+ }
}
}
if len(hosts) != 0 {
diff --git a/internal/host/claimunit_startup_test.go b/internal/host/claimunit_startup_test.go
index 629c13e27..58d1a8853 100644
--- a/internal/host/claimunit_startup_test.go
+++ b/internal/host/claimunit_startup_test.go
@@ -64,10 +64,17 @@ func TestPlan14ProductionUnitStartupIsolation(t *testing.T) {
f.root.Units = base
wrapped := ClaimRootWithLaunch{Launch: f.root}
wrapped.Root.Stop.Coordinator.Kernel = claimUnitStopKernel{Units: base}
- a := &ClaimAssembly{}
+ previousClosed := false
+ a := &ClaimAssembly{Close: func(context.Context) error { previousClosed = true; return nil }}
if err := bindProductionClaimUnits(t.Context(), &wrapped, a); err != nil {
t.Fatal("one unit killed root startup", err)
}
+ original := wrapped.Launch.unitControllers
+ wrapped.Launch.unitControllers = claimUnitControllerCloseFailure{original}
+ if err := a.Close(t.Context()); err == nil || previousClosed {
+ t.Fatal("unjoined controller closed shared authority", err)
+ }
+ wrapped.Launch.unitControllers = original
defer a.Close(t.Context())
for _, units := range []ClaimLaunchUnits{wrapped.Launch.Units, wrapped.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel).Units} {
p := units.(interface {
@@ -83,3 +90,7 @@ func TestPlan14ProductionUnitStartupIsolation(t *testing.T) {
})
}
}
+
+type claimUnitControllerCloseFailure struct{ claimUnitControllerAuthority }
+
+func (claimUnitControllerCloseFailure) close(context.Context) error { return os.ErrPermission }
diff --git a/internal/host/plan14_controller_transport_root_linux_test.go b/internal/host/plan14_controller_transport_root_linux_test.go
index 7a635e2c5..e446c6665 100644
--- a/internal/host/plan14_controller_transport_root_linux_test.go
+++ b/internal/host/plan14_controller_transport_root_linux_test.go
@@ -4,14 +4,12 @@ package host
import (
"context"
- "encoding/json"
- "errors"
+ "github.com/carlosframework/platform/internal/rootstate"
"net"
- "net/http"
"os"
"os/exec"
"path/filepath"
- "sync"
+ "strconv"
"syscall"
"testing"
"time"
@@ -21,23 +19,28 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-func plan14ControllerRootTransport(t *testing.T, root *ClaimLaunchRoot, worker *claimUnitControllerProcess, output *os.File, lock *release.ReleaseExclusion) func(context.Context, hostbox.RoleLaunchRequest) (hostbox.RoleLaunchResponse, error) {
+func plan14ControllerRootTransport(t *testing.T, root *ClaimLaunchRoot, host string, worker *claimUnitControllerProcess, lock *release.ReleaseExclusion) func(context.Context, hostbox.RoleLaunchRequest) (hostbox.RoleLaunchResponse, error) {
t.Helper()
- authenticate := func(ctx context.Context, conn net.Conn) (StopPeer, error) {
- if err := worker.Alive(ctx); err != nil {
- return StopPeer{}, err
- }
- p, err := instancectl.AuthenticatePeerTuple(conn, worker.peer.UID, worker.peer.PID, worker.peer.ControllerID)
- if err != nil {
- return StopPeer{}, err
- }
- peer := StopPeer{UID: p.UID, PID: p.PID, ProcessStart: p.ProcessStart, BootID: p.BootID, ControllerID: p.ControllerID}
- if peer != worker.peer {
- return StopPeer{}, os.ErrPermission
- }
- return peer, nil
+ dir := filepath.Join("/var/lib", "carlos-rootrunner-controller-"+strconv.Itoa(worker.peer.PID))
+ if err := os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ state, err := rootstate.Open(dir, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { state.Close() })
+ owners := newClaimUnitControllers(root.unitBindings, state, root)
+ binding, err := root.unitBindings.read(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := owners.register(t.Context(), binding, worker); err != nil {
+ t.Fatal(err)
}
- root.Authenticate = authenticate
+ root.unitControllers = owners
+ root.Authenticate = owners.authenticate
socket := filepath.Join("/run/carlos-control", "independent-controller.sock")
ln, err := net.Listen("unix", socket)
if err != nil {
@@ -47,16 +50,18 @@ func plan14ControllerRootTransport(t *testing.T, root *ClaimLaunchRoot, worker *
ln.Close()
t.Fatal(err)
}
- ctx, cancel := context.WithCancel(context.Background())
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if err := lock.With(r.Context(), func(ctx context.Context) error { root.ServeHTTP(w, r.WithContext(ctx)); return nil }); err != nil {
- http.Error(w, "refused", http.StatusConflict)
+ stop, err := owners.serveLaunch(root, ln, lock)
+ if err != nil {
+ ln.Close()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := stop(ctx); err != nil {
+ t.Error(err)
}
})
- server := &instancectl.Server{ClaimStop: handler, PeerOK: func(conn net.Conn) error { _, err := authenticate(ctx, conn); return err }}
- done := make(chan struct{})
- go func() { defer close(done); server.Serve(ctx, ln) }()
- t.Cleanup(func() { cancel(); <-done })
if _, err := NewClaimRoleRootClient(socket).Capability(t.Context()); err == nil {
t.Fatal("root caller substituted for the per-host controller")
}
@@ -68,31 +73,8 @@ func plan14ControllerRootTransport(t *testing.T, root *ClaimLaunchRoot, worker *
if out, err := sibling.CombinedOutput(); err != nil {
t.Fatalf("same-UID sibling admitted: %v %s", err, out)
}
- var mu sync.Mutex
return func(ctx context.Context, request hostbox.RoleLaunchRequest) (hostbox.RoleLaunchResponse, error) {
- mu.Lock()
- defer mu.Unlock()
- if err := ctx.Err(); err != nil {
- return hostbox.RoleLaunchResponse{}, err
- }
- deadline := time.Now().Add(time.Minute)
- if end, ok := ctx.Deadline(); ok && end.Before(deadline) {
- deadline = end
- }
- if err := output.SetReadDeadline(deadline); err != nil {
- return hostbox.RoleLaunchResponse{}, err
- }
- if err := json.NewEncoder(worker.input).Encode(plan14UnitControllerCall{Socket: socket, Request: request}); err != nil {
- return hostbox.RoleLaunchResponse{}, err
- }
- var reply plan14UnitControllerReply
- if err := json.NewDecoder(output).Decode(&reply); err != nil {
- return hostbox.RoleLaunchResponse{}, err
- }
- if reply.Failure != "" {
- return hostbox.RoleLaunchResponse{}, errors.New(reply.Failure)
- }
- return reply.Response, nil
+ return worker.call(ctx, socket, request)
}
}
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go
index bfbed70b8..6695d48c0 100644
--- a/internal/host/plan14_installed_launch_root_linux_test.go
+++ b/internal/host/plan14_installed_launch_root_linux_test.go
@@ -16,7 +16,6 @@ import (
"os/exec"
"path/filepath"
"strings"
- "syscall"
"testing"
"time"
@@ -410,22 +409,12 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string, runtimeUpdate ..
}
}
var worker *claimUnitControllerProcess
- var controllerOutput *os.File
var join func()
if independentController {
cmd := plan14ControllerCommand(t)
cmd.Path, cmd.Args[0] = instancectl.SystemBinary, instancectl.SystemBinary
- cmd.Env = append(cmd.Env, "PLAN14_UNIT_CONTROLLER_NODUMP=1", "PLAN14_UNIT_CONTROLLER_RPC=1")
- ready, readyWrite, err := os.Pipe()
- if err != nil {
- t.Fatal(err)
- }
- defer ready.Close()
- defer readyWrite.Close()
- cmd.Stdout = readyWrite
- controllerOutput = ready
- cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}}
- worker, err = startClaimUnitControllerProcess(cmd)
+ cmd.Env = append(cmd.Env, "PLAN14_UNIT_CONTROLLER_PRODUCTION=1")
+ worker, err = startClaimUnitControllerProcess(cmd, claimUnitControllerReady)
if err != nil {
t.Fatal(err)
}
@@ -438,14 +427,6 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string, runtimeUpdate ..
}
t.Cleanup(join)
f.peer = worker.peer
- readyWrite.Close()
- if err := ready.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
- t.Fatal(err)
- }
- signal := []byte{0}
- if _, err := ready.Read(signal); err != nil || signal[0] != 1 {
- t.Fatal("controller startup", err)
- }
if err := worker.Alive(t.Context()); err != nil {
t.Fatal("hardened controller identity", err)
}
@@ -809,7 +790,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string, runtimeUpdate ..
return out, err
}
if worker != nil {
- call = plan14ControllerRootTransport(t, f.root, worker, controllerOutput, lock)
+ call = plan14ControllerRootTransport(t, f.root, host, worker, lock)
for _, attack := range []string{"host", "stamp", "session", "controller", "operation"} {
bad := f.req
reservation := *bad.Reservation
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index 4008e394d..11ad0f81c 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -361,7 +361,7 @@ internal/release/releaseexclusion.go ReleaseExclusion.With authority E2 PROPOSED
internal/release/releaseexclusion.go type ReleaseExclusion authority E2 PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. file:Open@32 a4026f7a3110a647eaaeabecd5f0a255ae91b5e183f431be3b017433ee40eba3
internal/rootparticipation/production.go ActivationReady authority D,G Refuse claims activation without complete census and registered verifier; Part 3 G remains mandatory. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. file:Open@61 0103a8635bfc3a4ab17998aab202eebcc9ed8dc1a9d7c41ac8fabad435ff5c05
internal/rootparticipation/production.go Open authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. file:Open@45,file:Open@5 e7feda4757ec1a808e42729cc91ba57c37a34f48640f72dcdb9de706d8a55a24
-internal/host/claimstop_run.go RunInstanceControl authority C,D,G Refuse premature claim activation and continue serving recovery after per-host startup failures. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. d55919f2c48d02f39bb2d0eda76c7854aaf2c7684b8c782483d8fb62e5f5bff4
+internal/host/claimstop_run.go RunInstanceControl authority C,D,G Refuse premature claim activation and continue serving recovery after per-host startup failures. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending.; PROPOSED E59: root instance-control uid 0; register per-installation controller ownership behind the unchanged 4a activation gate, and recheck its durable scope before dispatch and execution. d4d78681dfa69a815a450abe907c45077985b6cb3178507eb0959c7801d5c1f4
internal/hostfloor/disk.go Disk.WithParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. f06b9c85d99dc0fe071f4b2bf0eced7ec8f118c7df5c1871040df3ea7323b3a3
internal/hostfloor/participation.go CheckParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. d5390731acd74a7ce0ef5e89a1cb1af769d35433c1bf4cf4bd196a2b4da650d2
internal/host/rootlaunchverifier.go rootLaunchVerifier.verify authority E2 PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. ff4241ff35d8dd8f7d08c2d5622ce88040791d34839bcecb22ac0d52436bfafa
@@ -432,7 +432,7 @@ internal/host/claimunit_binding.go claimUnitShape.matches hint E2 PROPOSED E47:
internal/host/claimunit_binding_source.go readClaimUnitShapes authority E2 PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadAll@188 77b2f5b6157118d78a66945e5bfc942d5ef485d2b7a39e52d3b28b8a5b10a0b9
internal/instancectl/claimexec.go ClaimUnits.prepareExecGate authority C,G,J2,J4,E2 root-input;kept=C,G;scope=protected-decision-only PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadFile@995 1b95e2b44b392d930ec8bd921b6d0382b47b3530a3d020646ced26e940e6f523
internal/instancectl/claimunit.go ClaimUnits.cgroupEmpty authority C,G,J2,J4,E2 root-input;kept=C,G;scope=protected-decision-only PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadFile@196 9e874fe959a3a381442e2900e8184f91ef803dd4f47839de8dbd6d3ab395d03e
-internal/host/claimlaunch_authority.go ClaimLaunchRoot.authorize authority D,E2 Both root launch authorizations read root participation and match authenticated peer; TestPlan14ExecutionFinalLaunchUsesRootParticipation. PROPOSED E47: final named-unit launch authorization rechecks the retained root binding and refuses missing, retired, foreign or changed canonical shape. 9e00346c2012343327a230067f2e35f9f960569fbb3b6ee0dc401e7f566cbef6
+internal/host/claimlaunch_authority.go ClaimLaunchRoot.authorize authority D,E2 Both root launch authorizations read root participation and match authenticated peer; TestPlan14ExecutionFinalLaunchUsesRootParticipation. PROPOSED E47: final named-unit launch authorization rechecks the retained root binding and refuses missing, retired, foreign or changed canonical shape.; PROPOSED E59: root instance-control uid 0; register per-installation controller ownership behind the unchanged 4a activation gate, and recheck its durable scope before dispatch and execution. b586860fce43c3c3ae9970872cc291df8d148ca404171dd57c02c45e3ecfc2ec
internal/host/claimlaunch_authority.go ClaimLaunchRoot.launchSpec authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 4703055ca1e26021e448839b43b5e281a020e492aac61dadca12f03675a688a4
internal/host/claimunit_binding.go claimUnitShape.valid authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. PROPOSED E50: Root validates optional exact predecessor definition digest in canonical configuration; no service route may authorize migration. PROPOSED E51: Root canonical TCP endpoints require a numeric port in 1..65535. PROPOSED E58: root instance-control uid 0; permits the explicit console capability only for console.service without unit backing and with a pinned program digest; retained shape equality refuses adding/removing the capability on an established binding. ec595295d005d886a8031753c31757c967a4e40af813c4e1dab4b1c7673edfbf
internal/host/claimunit_dispatch.go claimUnitDispatch.StartReserved authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 02dfbc7e31f9a13ba210d2bed3181da5e1874ae388c2d1060cbdb57c398a04e5
@@ -446,8 +446,8 @@ internal/instancectl/claimunit.go ClaimUnits.start authority E2 PROPOSED E48: ro
# PROPOSED E49
internal/activator/rolelaunch_root_linux.go RoleLaunch.startIsolated authority E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. file:Fstat@2605 a5e700f18823b68fdc17c599e7818b0bc0ffe1dd3d88bd3303693301b325fe78
-internal/host/claimlaunch_root.go ClaimLaunchRoot.Serve authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. whole:Route@665 418d39247b2ebffd1ffad1fe7718b423ada36ed4ce5f25fb367c41fca2246d78
-internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root callback signature carries the reservation host into bound readiness; retained host binding controls readiness independently of startup configuration. 4d7c2ee8ea6710e035cf76df8ba6e5511315c355dc30c818638fe31d1e40b7f9
+internal/host/claimlaunch_root.go ClaimLaunchRoot.Serve authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending.; PROPOSED E59: root instance-control uid 0; register per-installation controller ownership behind the unchanged 4a activation gate, and recheck its durable scope before dispatch and execution. whole:Route@815 d3e1fd713c13b6dbd475f64ecb5c453b93f102578b56b83507d21aee2fa7881e
+internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root callback signature carries the reservation host into bound readiness; retained host binding controls readiness independently of startup configuration. fcacbd3f3a759f07433406fdc8c8efe08362303b9f8a163d7a3698b3fbdcc2bc
internal/host/claimlaunch_transport.go ClaimRoleRootClient.UnitInputs hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. whole:Route@226,whole:Route@234 40a7733f6f7684a849c6cfd1e867cc732f9dc15a2d8cd1fff912adc0069f9229
internal/host/claimowners.go claimServingOwners.Select authority E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. registry:DBPath@1081,registry:DBPath@461,registry:Host@424,whole:Route@555 19654b1aa1b28c4bc0f71601dd176980ab610160f7fcda8f628d5de5835dc131
internal/host/claimproduction.go ProductionClaimOwnerProvider.OpenClaimOwners authority E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root and agent assembly select local root program reads in adopt/instance-control and authenticated typed reads in agent mode; all policy operations include independent shape refusal. 6cb53e9e482884a1de2117fd6fea859cfd6a0c8df0e495228793aa1925381045
@@ -486,8 +486,8 @@ internal/host/claimunit_program.go ClaimLaunchRoot.boundLaunchSpec authority E2
internal/host/claimunit_handover.go claimUnitMaterializer.handover authority E2 PROPOSED E49: root; retained retired predecessor binding and exact tombstone inode authorize only same-shape successor installation. The caller independently resolves the current root installation. PROPOSED E51: Root compares persistent predecessor/tombstone identity before successor replacement; owner and shape checks remain exact. dfd500b03a93044c4a8c9f67a8eeee0408e4ff163184197a8eb8fe40ee8ff351
# PROPOSED E49
-internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root installs retained-placement stop/retirement callbacks, resumes saved retirement before binding, and isolates each configuration/program/materialization failure. Initial binding reads occur outside host exclusion then local owner is checked inside. Readiness looks up the reserved host, not a startup list. PROPOSED E51: Root supplies the kernel boot reader to predecessor capture and stop; startup retirement replay remains ahead of per-unit binding. PROPOSED E52: Root wires peer-verified flat-socket capture and exact retained socket parking into canonical unit migration; no-census path unchanged. PROPOSED E54: root skips named-unit binding/materialization until reserved always-running startup exists; retirement recovery still precedes that gate, and backing=unit retains its existing path. Root witness rejects replacement of live TCP and socket predecessors. file:Read@1275 e4b9166fe33bcbafe91efb09d99d139d1f697df35dd7cff6765c9e483972d4c5
-@coverage internal/host a677b583bf8038d28c61ee49fad2ed734f068a1127f3cbfeb3c7e3f623e9758f
+internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root installs retained-placement stop/retirement callbacks, resumes saved retirement before binding, and isolates each configuration/program/materialization failure. Initial binding reads occur outside host exclusion then local owner is checked inside. Readiness looks up the reserved host, not a startup list. PROPOSED E51: Root supplies the kernel boot reader to predecessor capture and stop; startup retirement replay remains ahead of per-unit binding. PROPOSED E52: Root wires peer-verified flat-socket capture and exact retained socket parking into canonical unit migration; no-census path unchanged. PROPOSED E54: root skips named-unit binding/materialization until reserved always-running startup exists; retirement recovery still precedes that gate, and backing=unit retains its existing path. Root witness rejects replacement of live TCP and socket predecessors.; PROPOSED E59: root instance-control uid 0; register per-installation controller ownership behind the unchanged 4a activation gate, and recheck its durable scope before dispatch and execution. file:Read@1275 b35b9a95c92d467afd59a9b1f5e0fa1d0b83a292ffc34578ee543f2ce9a9078a
+@coverage internal/host fab2c5ffa66d9a4962075eb35cd3ce2d5d8aa9b63c2c0726a171345671a4db06
# PROPOSED E50: root unit migration and R-E50 safety gates
internal/caps/converge.go Converge authority E2 PROPOSED E50: Root capability producer calls CheckHost before any filesystem or systemd effect. 30fc6af73046bfd8a30fd8097573a5e774b3fe987ec1ee49d3b08e20bdc23761
@@ -594,3 +594,29 @@ internal/consolecmd/member.go runMember authority E2 accepted-legacy;rev3=J4;bas
@coverage internal/consolecmd d10e51824ca6f7ca79c9288bfcfc3eadcd8db8391827b412735b1a4e9d3c1ff6
internal/host/claimunit_console.go verifyClaimConsoleEnrollment authority E2 PROPOSED E58: root instance-control uid 0; opens only /etc/carlos/host-actor.json through checked root-owned non-writable ancestry before materialization verification authorizes console execution. 394ef75fd2470ac51123a4315ccc5765784a5bf6f9da31a4c601063ad226226e
internal/host/claimunit_console.go checkClaimConsoleEnrollment authority E2 PROPOSED E58: root instance-control uid 0; bounded decoding rejects foreign box/enrollment, missing instance identity and trailing data; the console independently compares the enrolled instance with live hardware/IMDS inside containment. e116c478a817ecf7bb62856946eba3c91fb581f1b4fa0ada75500c3a32eacd6b
+internal/host/claimunit_controller_other.go newClaimUnitControllers authority E2 PROPOSED E59: non-Linux root entry stub only refuses or returns no controller; grants no process or unit authority. 65002ceefed724b3271a2eb1103490a41d86ff96f098ae522c93362b517af14e
+internal/host/claimunit_controller_other.go runClaimUnitController authority E2 PROPOSED E59: non-Linux root entry stub only refuses or returns no controller; grants no process or unit authority. 0e7de45e867fbf0c3ddfc5ae47186f196c1a818dbc4f63b176a4081179229471
+internal/host/claimunit_controller_owner_linux.go claimUnitControllerLoop authority E2 PROPOSED E59: root-spawned carlos uid controller; forwards only root-pipe launch requests to root, where Unix peer identity and retained installation scope are rechecked; no direct effect authority. 3d05a534867175ffcf0b21af0f201b4539d48314a63133e907e84f2e47819ad9
+internal/host/claimunit_controller_owner_linux.go claimUnitControllerReady authority E2 PROPOSED E59: root parent reads the private child pipe handshake before capturing the dropped-privilege kernel process identity; malformed or missing readiness refuses registration. bd9abfaea137517bd7de814f743433757147344f56fcad868a05bfdf61152057
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.authenticate authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 1b5576a059d2b7e2b9a95a8be009a3d93af219693d42d2b9a15f70a9718adc27
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.authorize authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. registry:Host@190,whole:Route@1041,whole:Route@1083,whole:Route@166,whole:Route@192 ec81de9e69ffe3d74237abec5414f5f0ba63dcc198ff1cc6bc53178cd09a7c92
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.close authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 482f5bb9e9f508574d97b3334f188ee0ed1012657cb636dc6b10e1f187f95bf4
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.read authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. file:Read@224 c6a1b11df8f0e7aec66b38759cc83da439da34edebeb7ba22c94874979aaff62
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.recover authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state.; E59 retry: joined controller map entries are removed only after durable recovery; failed startup also joins its transport before retry. d800a39e27cc6ce589dc643a7b2c0af68240969edb20a4abb4dff6e7c58957a8
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.register authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 5f27cdf1f5d427892fc20e63955ffa1784663acb9b5355026f596f6f7e07b829
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.serveLaunch authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 1c7aa956386c174173dd32d6d9a18ee679b36acd2d3154ccb634b64447d7f58a
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.start authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state.; E59 retry: joined controller map entries are removed only after durable recovery; failed startup also joins its transport before retry. file:Command@420,file:Open@208 d8e2b4955d4d266ef7808f0978b388bd3587c01d2dc69de00f0aadd62a05f88a
+internal/host/claimunit_controller_owner_linux.go claimUnitControllers.write authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 038dab82fb7574daa5bfde56e9de11cae8c1771415d2a0dfd92dd973014509ca
+internal/host/claimunit_controller_owner_linux.go newClaimUnitControllers authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 52cf64e62dc2fbd65d169f0e2efa1c60a2d0a7a705aac45e328bc7a4ba54d7b2
+internal/host/claimunit_controller_owner_linux.go runClaimUnitController authority E2 PROPOSED E59: root helper entry drops supplementary groups and all uid/gid identities to carlos before a no-exec, non-dumpable startup handshake and pipe loop; a service-spawned copy has no root registration. 4c7c5fd6d73817323bfd75077de256e6542e352fcc9146f374aa26a32875ab59
+internal/host/claimunit_controller_process_linux.go claimUnitControllerExited authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 605e27ac6a563664c75c6a2ee282f74d89298d9ad6dcef32a486a15b58c4779c
+internal/host/claimunit_controller_process_linux.go claimUnitControllerHandle.Close authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. c57e69d2d6a6fefd57b5583fb1b5de54e4f7233faad5e94bb8c7a2053c8432b6
+internal/host/claimunit_controller_process_linux.go claimUnitControllerHandle.Stop authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. af21fb7f63fbd09de7408b9772830c2fb285aa4088208eceb3b9e19804769b75
+internal/host/claimunit_controller_process_linux.go claimUnitControllerHandle.alive authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. bfc67cc8e4c55b11f158f27b14e2ad3956df2f570be87ba1b2c35c3ef8e732c2
+internal/host/claimunit_controller_process_linux.go claimUnitControllerProcess.Alive authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 808e3be53a0c182c34bf669355a267fa97e55ec5115dd13e7a1612fad673db7c
+internal/host/claimunit_controller_process_linux.go claimUnitControllerProcess.Close authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 0c283a8f39905630e876baec6be144344653f2fc12b5227a44227d01ae3dcd5f
+internal/host/claimunit_controller_process_linux.go claimUnitControllerProcess.call authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 6d2a9832c37af875dc42836ea67a6dd7862329cbadb6b5748b24c4cbc65f845f
+internal/host/claimunit_controller_process_linux.go observedClaimUnitController authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 5f82bd3ada1f43df46991b6c0fa7fb662986942983954284e0590f8ef7ec9609
+internal/host/claimunit_controller_process_linux.go openClaimUnitControllerProcess authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 8e21eb5201b2e69e90c7deaebea18081cc756d25c65bf473ccdc41370c4c1a56
+internal/host/claimunit_controller_process_linux.go pollClaimUnitController authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. f5e69099b92fead3ab54ca61dbb77389904ecb5ae43bee4a6b84452e96f32a73
+internal/host/claimunit_controller_process_linux.go startClaimUnitControllerProcess authority E2 PROPOSED E59: root instance-control uid 0; exact retained installation and process ownership for named-unit launch, with durable registration, checked kernel identity, per-host request scope and joined predecessor recovery; no census or incomplete 4a gate creates no controller state. 04030311c8dfdd21f7852848a837ecee554b1020b4ac6febb6bd8e641a160ccb
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
index c2df3830a..c29d547b9 100644
--- a/internal/provision/testdata/plan14-root-reads-p3.tsv
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -201,7 +201,7 @@ internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory aut
@coverage internal/activator 0a01bc096c64f1886f810f4856d60f9a2642074a8b2ac0c56cbed77d122e76c8
@coverage internal/console 6f4998c39e6d66cd73ea98500000cbecac251dd21dafd58f51a85c2291e0c533
@coverage internal/edgesfile 7b48368150a427e4620f1c9ded49942b688c9e6c9cd983b7da43d3f2c0810b72
-@coverage internal/host a677b583bf8038d28c61ee49fad2ed734f068a1127f3cbfeb3c7e3f623e9758f
+@coverage internal/host fab2c5ffa66d9a4962075eb35cd3ce2d5d8aa9b63c2c0726a171345671a4db06
@coverage internal/host/units bf6ae516012701e0ca41908167e4efd60dd3c2c24e9af1555dec077e8be44461
@coverage internal/hostbox f877b123ea1c5c361a3046f5962ea5ef8e1a3b8a534a29aa2c15aac507b37081
@coverage internal/hostfloor 696e5ea4fa37b924b68e8e262235b99298ee19342d875213ba8209e5287dd3c6
@@ -307,7 +307,7 @@ internal/host/movesfiles.go movesFiles.Establish authority C,J4 rev3=PROPOSED-p3
internal/hostbox/servinghandover.go ServingHandover.Run authority C,J4 rev3=PROPOSED-p3;run=12;identity=root handover controller uid 0;basis=preserves seed fresh boot-aware directory verification and persists its checked full filesystem identity in successor inventory only; immutable predecessor retained registry:Account@2154,registry:Addr@2373,registry:Addrs@2391,registry:App@2198,registry:Backing@2410,registry:Canary@2467,registry:ClaimOwner@5643,registry:ClaimOwnership@2841,registry:ClaimOwnership@3057,registry:ClaimOwnership@3090,registry:ClaimRowID@3120,registry:ClaimRowID@5714,registry:ClaimRowID@5888,registry:DBPath@2286,registry:Kind@2128,registry:Name@3174,registry:Name@5853,registry:Platform@2451,registry:Replica@2481,registry:Route@2841,registry:Route@3090,registry:Route@5643,registry:Route@5714,registry:Socket@2234,registry:Timers@3039,registry:Timers@3057,registry:Timers@3120,registry:Timers@3174,registry:Timers@5796,registry:Tunnel@2431,registry:Unit@2355,whole:ClaimOwnership@2853,whole:ClaimOwnership@3073,whole:ClaimOwnership@3102,whole:ColdRows@3514,whole:ColdRows@3522,whole:ColdRows@3530,whole:ColdRows@3537,whole:ColdRows@5645,whole:ColdRows@5716,whole:ColdRows@5798,whole:ColdRows@6379,whole:ColdRows@6389,whole:Route@2114,whole:Route@2643,whole:Route@2651,whole:Route@2701,whole:Route@2709,whole:Route@2847,whole:Route@3096,whole:Route@5651,whole:Route@5722,whole:Timer@2656,whole:Timer@2664,whole:Timer@2714,whole:Timer@2752,whole:Timer@3045,whole:Timer@3063,whole:Timer@3126,whole:Timer@3180,whole:Timer@5804 c1367b936e3e0caa9e2bbd0da117a9b89ce3b7b7d4f518069e7d265a48b89a04
internal/host/claimactivation_preflight.go claimActivationVerified authority G rev3=PROPOSED-p3;run=15;identity=agent uid carlos; root migration/adopt/control uid 0;basis=canonical records, installed local units and retained root bindings independently refuse incomplete named-unit activation before claim state 8420c4b5b416bfb653788d69fbdf0f2c3acaad739c85be8bb2c1462f2ad56474
internal/host/claimactivation_preflight.go var openClaimActivationState authority G rev3=PROPOSED-p3;run=13;identity=root migration/adopt/control uid 0;basis=opens retained root unit namespaces read-only without creating claim state file:Open@31 9b4c4aeddcc3756f3c7fbb267096147f5670511fc4fda809d88d98540ad685f6
-internal/host/claimstop_run.go RunInstanceControl authority C,D,G rev3=PROPOSED-p3;run=13;identity=instance-control uid 0;basis=requires retained recovery census before construction and complete launch verification after pending recovery before serving d55919f2c48d02f39bb2d0eda76c7854aaf2c7684b8c782483d8fb62e5f5bff4
+internal/host/claimstop_run.go RunInstanceControl authority C,D,G rev3=PROPOSED-p3;run=13;identity=instance-control uid 0;basis=requires retained recovery census before construction and complete launch verification after pending recovery before serving; PROPOSED E59: root instance-control uid 0; register per-installation controller ownership behind the unchanged 4a activation gate, and recheck its durable scope before dispatch and execution. d4d78681dfa69a815a450abe907c45077985b6cb3178507eb0959c7801d5c1f4
internal/host/claimstop_run.go claimRecoveryReady authority C,G rev3=PROPOSED-p3;run=13;identity=instance-control uid 0;basis=requires valid retained recovery census but defers complete launch inventory until restricted recovery has settled 627ab800a8f5772b81c3a893cf546438d7f76ae30ed29949ae6af52b5c2a9a7b
internal/host/claimunit_binding.go claimUnitBindings.establish authority E2 rev3=PROPOSED-p3;run=13;identity=root unit controller uid 0;basis=production admission refuses binding while named-unit 4a is incomplete, including backing=unit ef56f2db27a3d4a95803407a4b389b402592ed5b450bb6723aeffac42a041cab
internal/host/claimunit_dispatch.go claimUnitDispatch.forStart authority E2 rev3=PROPOSED-p3;run=13;identity=root launch controller uid 0;basis=verifies effective ordinary templates as well as retained named bindings before start and at the reservation permit recheck 3d358cb14cd248cea66750f64a4ea49be91eddb563e6ca389c12c7664f6c6706
@@ -318,7 +318,7 @@ internal/host/claimunit_effective.go claimReservedDropin authority E2 rev3=PROPO
internal/host/claimunit_files_linux.go projectClaimBoundSocket authority E2 rev3=PROPOSED-p3;run=13;identity=root unit materializer uid 0;basis=selects and publishes the root-bound socket symlink and synchronizes its parent; this is publication authority file:Readlinkat@433 4f3c8daab1f68102e7be9511d1ec29d89c584c99aed79641f2b740031e503ab4
internal/host/claimunit_files_other.go projectClaimBoundSocket authority E2 rev3=PROPOSED-p3;run=15;identity=non-Linux root unit materializer;basis=unsupported-platform stub refuses only; performs no locking, opening or publication 3f72016917b749fed665db738fbb54d90b40f59eec09aa3751b6abbb2f14319a
internal/host/claimunit_materialize.go claimUnitMaterializer.install authority E2 rev3=PROPOSED-p3;run=13;identity=root unit controller uid 0;basis=production admission refuses materialization while named-unit 4a is incomplete, including backing=unit ed99fda682170367aee0a3d6270153b7051ae03849aa93dc24ecca60eb7afec4
-internal/host/claimunit_production.go bindProductionClaimUnits authority E2 rev3=PROPOSED-p3;run=13;identity=root control startup uid 0;basis=installs effective verification for ordinary and bound starts; incomplete 4a refuses production binding and materialization for named and backing units file:Read@1275 e4b9166fe33bcbafe91efb09d99d139d1f697df35dd7cff6765c9e483972d4c5
+internal/host/claimunit_production.go bindProductionClaimUnits authority E2 rev3=PROPOSED-p3;run=13;identity=root control startup uid 0;basis=installs effective verification for ordinary and bound starts; incomplete 4a refuses production binding and materialization for named and backing units; PROPOSED E59: root instance-control uid 0; register per-installation controller ownership behind the unchanged 4a activation gate, and recheck its durable scope before dispatch and execution. file:Read@1275 b35b9a95c92d467afd59a9b1f5e0fa1d0b83a292ffc34578ee543f2ce9a9078a
internal/host/proofroot.go configureRootProof authority E1,B3 rev3=PROPOSED-p3;run=13;identity=root adopt uid 0;basis=checks participation before opening history or installing root proof policy; errors deny minting without falling back 322c7da5d034cd1cc870e748c499864796fa8bc11e4bbed6f96f7312d004dbd1
internal/host/proofroot.go proofRoot.mintAndLog authority E1 rev3=PROPOSED-p3;run=13;identity=root adopt uid 0;basis=retains main minting only when no root policy callbacks are installed; partial census policy without a probe refuses, and a complete policy independently checks participation file:Stat@2049,registry:App@1052,registry:App@1190,registry:App@893,registry:Host@1107,registry:Host@810,registry:Host@931,registry:Unit@763,registry:Unit@780,whole:Route@1000,whole:Route@723,whole:Route@756,whole:Route@868,whole:Route@965 faf5c143825d0f2d263c27d0722fba407b387f0a814b0e8a7212a82e813e240a
internal/host/roothistory_creation.go productionCreationHistory authority E1 rev3=PROPOSED-p3;run=13;identity=root controller uid 0;basis=reuses the production release-exclusion token during recovery before recording independently checked installation creation file:Open@297 495a357c0a2a1354d5c5510744f9bf0778034bd6d7b0d68fa1881791e997634f