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>
17 files changed,
+1264
−124
internal/host/claimlaunch_authority.go+5 −0internal/host/claimlaunch_root.go+6 −0internal/host/claimstop_run.go+3 −0internal/host/claimunit_controller.go+15 −0internal/host/claimunit_controller_durability_root_linux_test.go+124 −0internal/host/claimunit_controller_linux_test.go+40 −29internal/host/claimunit_controller_other.go+12 −0internal/host/claimunit_controller_owner_linux.go+523 −0internal/host/claimunit_controller_owner_linux_test.go+244 −0internal/host/{claimunit_controller_process_linux_test.go => claimunit_controller_process_linux.go}+97 −10internal/host/claimunit_controller_reboot_root_linux_test.go+90 −0internal/host/claimunit_production.go+22 −2internal/host/claimunit_startup_test.go+12 −1internal/host/plan14_controller_transport_root_linux_test.go+33 −51internal/host/plan14_installed_launch_root_linux_test.go+3 −22internal/provision/testdata/plan14-root-reads-e.tsv+32 −6internal/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 |