WIP claim Plan 14 track E checkpoint 48 (not for merge)
4a: production unit binding and program checks; systemd startup identity race fixed; TCP, socket and backing=unit lifecycles beside an unaffected claimed sibling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+1129
−26
internal/host/claimlaunch_authority.go+3 −0internal/host/claimlaunch_production.go+3 −0internal/host/claimlaunch_root.go+1 −0internal/host/claimlaunch_root_test.go+4 −1internal/host/claimunit_binding.go+30 −2internal/host/claimunit_definition.go+51 −0internal/host/claimunit_definition_test.go+43 −0internal/host/claimunit_dispatch.go+123 −0internal/host/claimunit_dispatch_test.go+89 −0internal/host/claimunit_production.go+120 −0internal/host/claimunit_program.go+99 −0internal/host/claimunit_program_test.go+88 −0internal/host/plan14_bound_sibling_root_linux_test.go+158 −0internal/host/plan14_installed_launch_root_linux_test.go+169 −10internal/host/plan14_root_generations_test.go+15 −1internal/instancectl/claimexec.go+39 −4internal/instancectl/claimexec_linux.go+5 −2internal/instancectl/claimexec_startup_linux_test.go+68 −0internal/instancectl/claimunit.go+1 −1internal/provision/testdata/plan14-root-reads-e.tsv+20 −5
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 298.463s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.867s
ok github.com/carlosframework/platform/cmd/moveprobe 93.330s
ok github.com/carlosframework/platform/cmd/zddprobe 0.722s
ok github.com/carlosframework/platform/internal/activator 250.030s
ok github.com/carlosframework/platform/internal/agentsock 0.021s
ok github.com/carlosframework/platform/internal/alerting 0.007s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.565s
ok github.com/carlosframework/platform/internal/apperr 0.099s
ok github.com/carlosframework/platform/internal/applog 0.169s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.848s
? 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.016s
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.736s
ok github.com/carlosframework/platform/internal/changelog 0.345s
ok github.com/carlosframework/platform/internal/clamd 0.057s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 391.672s
? 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.973s
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.004s
ok github.com/carlosframework/platform/internal/docsite 0.007s
ok github.com/carlosframework/platform/internal/domains 2.393s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 26.947s
? 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.133s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.020s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.178s
ok github.com/carlosframework/platform/internal/host 145.429s
ok github.com/carlosframework/platform/internal/hostactors 0.523s
ok github.com/carlosframework/platform/internal/hostbox 2.381s
ok github.com/carlosframework/platform/internal/hostclaim 0.503s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 4.996s
ok github.com/carlosframework/platform/internal/hostfence 0.052s
ok github.com/carlosframework/platform/internal/hostfiles 10.437s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 1.024s
ok github.com/carlosframework/platform/internal/hostops 1.579s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.469s
ok github.com/carlosframework/platform/internal/hostresolve 0.132s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.235s
ok github.com/carlosframework/platform/internal/instanceexec 2.749s
ok github.com/carlosframework/platform/internal/instancefs 0.008s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.504s
ok github.com/carlosframework/platform/internal/lease 9.792s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.160s
ok github.com/carlosframework/platform/internal/maintenance 0.082s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.502s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.068s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.058s
ok github.com/carlosframework/platform/internal/moves/archive 0.779s
ok github.com/carlosframework/platform/internal/objstore 0.173s
ok github.com/carlosframework/platform/internal/ops 0.177s
ok github.com/carlosframework/platform/internal/park 0.204s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 17.584s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.490s
ok github.com/carlosframework/platform/internal/relay 7.240s
ok github.com/carlosframework/platform/internal/release 22.194s
ok github.com/carlosframework/platform/internal/rollout 3.796s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.575s
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.530s
ok github.com/carlosframework/platform/internal/seeds 0.012s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.810s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.005s
ok github.com/carlosframework/platform/internal/status 0.039s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.189s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
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.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.537s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 47186082 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/15 17:31:01 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC43B9153F953, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.021s
minio contract: PASS
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go| index 0df2243ff..31bc1eafe 100644 |
| --- a/internal/host/claimlaunch_authority.go |
| +++ b/internal/host/claimlaunch_authority.go |
| @@ -48,6 +48,9 @@ func (h *ClaimLaunchRoot) launchSpec(q hostbox.RoleLaunchRequest) (instancectl.L |
| return instancectl.Launch{}, hostbox.ErrRoleLaunchShape |
| } |
| t := q.Reservation.Target |
| + if launch, bound, err := h.boundLaunchSpec(q); bound || err != nil { |
| + return launch, err |
| + } |
| paths, err := claimIsolatedPaths(map[string]string{"data": h.DataRoot}, t.Host, ClaimLaunchInputs{DBPath: provision.NestedDBPath(h.DataRoot, t.Host), Limit: cgroup.Limit{MemoryMax: q.MemoryMax}}) |
| if err != nil { |
| return instancectl.Launch{}, err |
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index 2ef8eee94..e4b297b91 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -76,6 +76,9 @@ func WithProductionClaimLaunch(ctx context.Context, handler http.Handler) (http. |
| wrapped.Launch.history = root.history |
| wrapped.Launch.generations = productionRootGenerations(a.Claims, a.ManagedRoots["opt"]) |
| wrapped.Launch.installedProgram = wrapped.Launch.rootInstalledProgram |
| + if err := bindProductionClaimUnits(ctx, &wrapped, a); err != nil { |
| + return nil, err |
| + } |
| return wrapped, nil |
| } |
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go| index 086cc4b39..1848f1760 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 { |
| + unitProgram func(claimUnitShape) (claimUnitProgram, error) |
| unitBindings *claimUnitBindings |
| history rootHistoryAccess |
| generations *rootGenerationSource |
diff --git a/internal/host/claimlaunch_root_test.go b/internal/host/claimlaunch_root_test.go| index 95bbcd0f0..5e468eff0 100644 |
| --- a/internal/host/claimlaunch_root_test.go |
| +++ b/internal/host/claimlaunch_root_test.go |
| @@ -132,8 +132,11 @@ type launchFixture struct { |
| } |
| func newLaunchFixture(t *testing.T, op hostclaim.Op) *launchFixture { |
| + return newLaunchFixtureHost(t, op, "shop.example") |
| +} |
| + |
| +func newLaunchFixtureHost(t *testing.T, op hostclaim.Op, host string) *launchFixture { |
| t.Helper() |
| - host := "shop.example" |
| boot, err := execlease.BootID() |
| if err != nil { |
| t.Skip("root launch requires Linux") |
diff --git a/internal/host/claimunit_binding.go b/internal/host/claimunit_binding.go| index 90979cac6..8221dc55b 100644 |
| --- a/internal/host/claimunit_binding.go |
| +++ b/internal/host/claimunit_binding.go |
| @@ -21,6 +21,7 @@ import ( |
| type claimUnitShape struct { |
| Host, Account, App, Kind, Unit, Backing, Socket, Addr, DBPath string |
| + Program string `json:",omitempty"` |
| } |
| type claimUnitBinding struct { |
| @@ -37,6 +38,9 @@ type claimUnitBindings struct { |
| } |
| func (s claimUnitShape) valid() bool { |
| + if s.Program != "" && !claimHexDigest(s.Program) { |
| + return false |
| + } |
| if !provision.ValidHostShape(s.Host) || !release.ValidAccount(s.Account) || !release.ValidApp(s.App) || s.Kind != "instance" || !instancectl.ValidBoundClaimUnit(s.Unit) { |
| return false |
| } |
| @@ -61,11 +65,15 @@ func (s claimUnitShape) valid() bool { |
| } |
| func (s claimUnitShape) route() registry.Route { |
| - return registry.Route{Host: s.Host, Account: s.Account, App: s.App, Kind: s.Kind, Unit: s.Unit, Backing: s.Backing, Socket: s.Socket, Addr: s.Addr, DBPath: s.DBPath} |
| + unit := s.Unit |
| + if s.Backing == registry.BackingUnit { |
| + unit = "" |
| + } |
| + return registry.Route{Host: s.Host, Account: s.Account, App: s.App, Kind: s.Kind, Unit: unit, Backing: s.Backing, Socket: s.Socket, Addr: s.Addr, DBPath: s.DBPath} |
| } |
| func (s claimUnitShape) matches(r registry.Route) bool { |
| - return s.Host == r.Host && s.Account == r.Account && s.App == r.App && s.Kind == r.Kind && s.Unit == r.Unit && s.Backing == r.Backing && s.Socket == r.Socket && s.Addr == r.Addr && s.DBPath == r.DBPath && r.Addrs == "" && r.Tunnel == "" && r.Seed == "" && !r.Replica |
| + return s.Host == r.Host && s.Account == r.Account && s.App == r.App && s.Kind == r.Kind && s.route().Unit == r.Unit && s.Backing == r.Backing && s.Socket == r.Socket && s.Addr == r.Addr && s.DBPath == r.DBPath && r.Addrs == "" && r.Tunnel == "" && r.Seed == "" && !r.Replica |
| } |
| func unitBindingOwner(in hostguard.RootInstallation) hostguard.RootInstallation { |
| @@ -142,6 +150,26 @@ func (s *claimUnitBindings) establish(ctx context.Context, host string) (claimUn |
| if err != nil { |
| return next, err |
| } |
| + names, err := s.state.Names() |
| + if err != nil { |
| + return claimUnitBinding{}, err |
| + } |
| + for _, name := range names { |
| + if name == "bindings" || name == host+".json" { |
| + continue |
| + } |
| + otherHost, ok := strings.CutSuffix(name, ".json") |
| + if !ok { |
| + return claimUnitBinding{}, os.ErrPermission |
| + } |
| + other, err := s.read(otherHost) |
| + if err != nil { |
| + return claimUnitBinding{}, err |
| + } |
| + if other.Shape.Unit == next.Shape.Unit { |
| + return claimUnitBinding{}, os.ErrPermission |
| + } |
| + } |
| old, err := s.read(host) |
| if err == nil { |
| if old != next { |
diff --git a/internal/host/claimunit_definition.go b/internal/host/claimunit_definition.go| new file mode 100644 |
| index 000000000..9c72d987b |
| --- /dev/null |
| +++ b/internal/host/claimunit_definition.go |
| @@ -0,0 +1,51 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func claimBoundUnitDefinition(s claimUnitShape) ([]byte, error) { |
| + if !s.valid() || !claimHexDigest(s.Program) { |
| + return nil, os.ErrPermission |
| + } |
| + template, err := unitsFS.ReadFile("units/carlos-instance@.service") |
| + if err != nil { |
| + return nil, err |
| + } |
| + body := strings.ReplaceAll(strings.ReplaceAll(string(template), "%i", instancectl.Escape(provision.InstanceID(s.Host))), "%I", provision.InstanceID(s.Host)) |
| + // With no live root gate, even a direct systemctl restart must execute no |
| + // tenant code. The temporary reservation drop-in replaces this command. |
| + body = strings.Replace(body, "ExecStart="+instancectl.SystemBinary+" instance-exec run "+provision.InstanceID(s.Host), "ExecStart=/usr/bin/false", 1) |
| + if s.Addr != "" { |
| + body += "\n[Service]\nIPAddressAllow=localhost\n" |
| + } |
| + return []byte(body), nil |
| +} |
| + |
| +func verifyClaimBoundUnitDefinition(s claimUnitShape) error { |
| + want, err := claimBoundUnitDefinition(s) |
| + if err != nil { |
| + return err |
| + } |
| + f, err := rootstate.OpenAuthorityFile(filepath.Join("/run/systemd/system", s.Unit)) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + got, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1)) |
| + if err != nil { |
| + return err |
| + } |
| + if !bytes.Equal(got, want) { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/claimunit_definition_test.go b/internal/host/claimunit_definition_test.go| new file mode 100644 |
| index 000000000..bffe259b6 |
| --- /dev/null |
| +++ b/internal/host/claimunit_definition_test.go |
| @@ -0,0 +1,43 @@ |
| +package host |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestPlan14BoundUnitDefinition(t *testing.T) { |
| + _, _, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| + shape.Program = strings.Repeat("a", 64) |
| + for _, tcp := range []bool{true, false} { |
| + if !tcp { |
| + shape.Addr, shape.Socket = "", provision.NestedSocket(shape.Host) |
| + } |
| + raw, err := claimBoundUnitDefinition(shape) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body := string(raw) |
| + if !strings.Contains(body, "ExecStart=/usr/bin/false\n") || strings.Contains(body, "ExecStart=/opt/carlos/carlos instance-exec") || !strings.Contains(body, "Restart=no\n") || !strings.Contains(body, "Slice="+instancectl.SliceName(shape.Host)+"\n") || strings.Contains(body, "%I") || strings.Contains(body, "%i") { |
| + t.Fatal("definition permits unreserved execution or foreign placement") |
| + } |
| + if strings.Contains(body, "IPAddressAllow=localhost") != tcp { |
| + t.Fatal("loopback permission escaped named TCP scope") |
| + } |
| + for _, setting := range []string{"ProtectControlGroups=yes", "PrivatePIDs=yes", "NoNewPrivileges=yes", "CapabilityBoundingSet=", "TemporaryFileSystem=/opt/carlos:ro"} { |
| + if !strings.Contains(body, setting+"\n") { |
| + t.Fatal("missing containment", setting) |
| + } |
| + } |
| + } |
| + shape.Host = "woodstar-media.example" |
| + raw, err := claimBoundUnitDefinition(shape) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(string(raw), "Slice="+instancectl.SliceName(shape.Host)+"\n") { |
| + t.Fatal("named unit used unescaped slice") |
| + } |
| +} |
diff --git a/internal/host/claimunit_dispatch.go b/internal/host/claimunit_dispatch.go| new file mode 100644 |
| index 000000000..133940340 |
| --- /dev/null |
| +++ b/internal/host/claimunit_dispatch.go |
| @@ -0,0 +1,123 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| +) |
| + |
| +// Placement comes from retained root state. Configuration may deny a new |
| +// start, but removing it cannot retarget cleanup to the ordinary run unit. |
| +type claimUnitDispatch struct { |
| + base instancectl.ClaimUnits |
| + bindings *claimUnitBindings |
| + shapes func() (map[string]claimUnitShape, error) |
| + definition func(claimUnitShape) error |
| +} |
| + |
| +func (d claimUnitDispatch) selected(host string) (instancectl.ClaimUnits, error) { |
| + u := d.base |
| + if d.bindings == nil || d.shapes == nil { |
| + return u, os.ErrPermission |
| + } |
| + b, err := d.bindings.read(host) |
| + if err == nil { |
| + u.BoundHost, u.BoundUnit = host, b.Shape.Unit |
| + return u, nil |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return u, err |
| + } |
| + shapes, err := d.shapes() |
| + if err != nil { |
| + return u, err |
| + } |
| + if _, configured := shapes[host]; configured { |
| + return u, os.ErrPermission |
| + } |
| + return u, nil |
| +} |
| + |
| +func (d claimUnitDispatch) forStart(ctx context.Context, host string) (instancectl.ClaimUnits, error) { |
| + u, err := d.selected(host) |
| + if err != nil { |
| + return u, err |
| + } |
| + if u.BoundUnit != "" { |
| + shapes, err := d.shapes() |
| + if err != nil { |
| + return u, err |
| + } |
| + b, err := d.bindings.check(ctx, host, nil) |
| + if err != nil { |
| + return u, err |
| + } |
| + if shapes[host] != b.Shape { |
| + return u, os.ErrPermission |
| + } |
| + if d.definition == nil { |
| + return u, os.ErrPermission |
| + } |
| + if err := d.definition(b.Shape); err != nil { |
| + return u, err |
| + } |
| + } |
| + return u, nil |
| +} |
| + |
| +func (d claimUnitDispatch) Placement(host string) (string, string, error) { |
| + u, err := d.selected(host) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return u.Placement(host) |
| +} |
| + |
| +func (d claimUnitDispatch) StartReserved(ctx context.Context, in instancectl.ClaimUnit, l instancectl.Launch, p instancectl.ClaimExecPermit, check func() error) (instancectl.ClaimUnitStarted, error) { |
| + u, err := d.forStart(ctx, in.Host) |
| + if err != nil { |
| + return instancectl.ClaimUnitStarted{}, err |
| + } |
| + if check == nil { |
| + return instancectl.ClaimUnitStarted{}, os.ErrPermission |
| + } |
| + return u.StartReserved(ctx, in, l, p, func() error { |
| + again, err := d.forStart(ctx, in.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if again.BoundHost != u.BoundHost || again.BoundUnit != u.BoundUnit { |
| + return os.ErrPermission |
| + } |
| + return check() |
| + }) |
| +} |
| + |
| +func (d claimUnitDispatch) Same(ctx context.Context, started instancectl.ClaimUnitStarted) error { |
| + // The caller retained the selected placement in the launch grant. Same |
| + // checks that exact invocation without taking a new route from the registry. |
| + return d.base.Same(ctx, started) |
| +} |
| + |
| +func (d claimUnitDispatch) Stop(context.Context, string) error { |
| + // Dispatch cannot guess which invocation a bare host means. |
| + return os.ErrPermission |
| +} |
| + |
| +func (d claimUnitDispatch) StopInvocation(ctx context.Context, host, invocation string) error { |
| + u, err := d.selected(host) |
| + if err != nil { |
| + return err |
| + } |
| + return u.StopInvocation(ctx, host, invocation) |
| +} |
| + |
| +func (d claimUnitDispatch) Gone(ctx context.Context, host, invocation string) error { |
| + u, err := d.selected(host) |
| + if err != nil { |
| + return err |
| + } |
| + return u.Gone(ctx, host, invocation) |
| +} |
diff --git a/internal/host/claimunit_dispatch_test.go b/internal/host/claimunit_dispatch_test.go| new file mode 100644 |
| index 000000000..6daa8ace6 |
| --- /dev/null |
| +++ b/internal/host/claimunit_dispatch_test.go |
| @@ -0,0 +1,89 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| +) |
| + |
| +func TestPlan14UnitDispatch(t *testing.T) { |
| + b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| + configured := map[string]claimUnitShape{in.Host: shape} |
| + d := claimUnitDispatch{bindings: b, shapes: func() (map[string]claimUnitShape, error) { return configured, nil }} |
| + if _, _, err := d.Placement(in.Host); err == nil { |
| + t.Fatal("configured unit without retained binding fell through") |
| + } |
| + if _, err := b.establish(t.Context(), in.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + unit, group, err := d.Placement(in.Host) |
| + if err != nil || unit != shape.Unit || group != instancectl.BoundClaimUnitControlGroup(in.Host, shape.Unit) { |
| + t.Fatal(unit, group, err) |
| + } |
| + unit, group, err = d.Placement("sibling.example") |
| + if err != nil || unit != instancectl.UnitName(instancectl.RoleRun, "sibling.example") || group != instancectl.ClaimUnitControlGroup("sibling.example") { |
| + t.Fatal(unit, group, err) |
| + } |
| + delete(configured, in.Host) |
| + if _, err := d.forStart(t.Context(), in.Host); err == nil { |
| + t.Fatal("removed configuration allowed start") |
| + } |
| + unit, _, err = d.Placement(in.Host) |
| + if err != nil || unit != shape.Unit { |
| + t.Fatal("cleanup lost retained placement", unit, err) |
| + } |
| + configured[in.Host] = shape |
| + changed := in |
| + changed.Stamp++ |
| + b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return changed, nil } |
| + if _, err := d.forStart(t.Context(), in.Host); err == nil { |
| + t.Fatal("successor inherited placement") |
| + } |
| + b.state.Write(in.Host+".json", []byte("broken")) |
| + if _, _, err := d.Placement(in.Host); err == nil { |
| + t.Fatal("corruption fell through") |
| + } |
| + d.shapes = func() (map[string]claimUnitShape, error) { return nil, os.ErrPermission } |
| + if _, _, err := d.Placement("sibling.example"); err == nil { |
| + t.Fatal("unreadable configuration inferred absence") |
| + } |
| +} |
| + |
| +func TestPlan14BackingUnitRegistryShape(t *testing.T) { |
| + b, in, s := plan14UnitBindingFixture(t, "carlos-app@shop.example.service", "unit", "") |
| + if _, err := b.establish(t.Context(), in.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := s.route() |
| + if r.Unit != "" { |
| + t.Fatal("canonical backing route invents a named unit", r.Unit) |
| + } |
| + if _, err := b.check(t.Context(), in.Host, &r); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Unit = s.Unit |
| + if _, err := b.check(t.Context(), in.Host, &r); err == nil { |
| + t.Fatal("accepted backing-to-named shape change") |
| + } |
| +} |
| + |
| +func TestPlan14UnitBindingRetainedOwnership(t *testing.T) { |
| + b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| + if _, err := b.establish(t.Context(), in.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + other := in |
| + other.Host, other.Record = "sibling.example", "apps/bab/shop/instances/sibling.example.json" |
| + shape.Host = other.Host |
| + b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return other, nil } |
| + b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| + if _, err := b.establish(t.Context(), other.Host); err == nil { |
| + t.Fatal("root config reassigned a retained unit to a sibling") |
| + } |
| + if _, err := b.read(other.Host); !os.IsNotExist(err) { |
| + t.Fatal("created conflicting owner", err) |
| + } |
| +} |
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go| new file mode 100644 |
| index 000000000..8dc7ac57d |
| --- /dev/null |
| +++ b/internal/host/claimunit_production.go |
| @@ -0,0 +1,120 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +var openClaimUnitBindings = func() (rootstate.Store, error) { |
| + return rootstate.Open("/var/lib/carlos-claims/unit-bindings", true, false) |
| +} |
| + |
| +var productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { |
| + shapes, err := readClaimUnitShapes("/etc/carlos/claim-units.json") |
| + if errors.Is(err, os.ErrNotExist) { |
| + return map[string]claimUnitShape{}, nil |
| + } |
| + return shapes, err |
| +} |
| + |
| +// Called only in the census-gated root control startup. The root configuration |
| +// chooses unit placement; the existing root installation chooses its owner. |
| +func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *ClaimAssembly) error { |
| + guard := h.Launch.Guard |
| + if guard.Root == nil || guard.Claims == nil { |
| + return os.ErrPermission |
| + } |
| + base, ok := h.Launch.Units.(instancectl.ClaimUnits) |
| + if !ok { |
| + return os.ErrPermission |
| + } |
| + shapes, err := productionClaimUnitShapes() |
| + if err != nil { |
| + return err |
| + } |
| + for _, shape := range shapes { |
| + if !claimHexDigest(shape.Program) { |
| + return os.ErrPermission |
| + } |
| + if _, err := readClaimUnitProgram(shape); err != nil { |
| + return err |
| + } |
| + } |
| + state, err := openClaimUnitBindings() |
| + if err != nil { |
| + return err |
| + } |
| + b := &claimUnitBindings{state: state} |
| + b.canonical = func(ctx context.Context, host string) (claimUnitShape, error) { |
| + current, err := productionClaimUnitShapes() |
| + if err != nil { |
| + return claimUnitShape{}, err |
| + } |
| + shape, ok := current[host] |
| + if !ok { |
| + return claimUnitShape{}, os.ErrPermission |
| + } |
| + return shape, nil |
| + } |
| + b.installation = func(ctx context.Context, host string) (hostguard.RootInstallation, error) { |
| + captured, err := guard.Capture(host) |
| + if err != nil { |
| + return hostguard.RootInstallation{}, err |
| + } |
| + in, err := guard.Root.Installation(ctx, host, captured) |
| + if err != nil { |
| + return in, err |
| + } |
| + observed, err := guard.Claims.Read(ctx, host) |
| + if err != nil { |
| + return in, err |
| + } |
| + if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil { |
| + return in, os.ErrPermission |
| + } |
| + holder := observed.Claim.Holder |
| + if holder.Incarnation != in.Incarnation || holder.Stamp != in.Stamp || holder.Account != in.Account || holder.App != in.App || holder.Key != in.Record { |
| + return in, os.ErrPermission |
| + } |
| + return in, nil |
| + } |
| + hosts := make([]string, 0, len(shapes)) |
| + for host := range shapes { |
| + hosts = append(hosts, host) |
| + } |
| + sort.Strings(hosts) |
| + for _, host := range hosts { |
| + if err := guard.WithLocal(ctx, host, func() error { _, err := b.establish(ctx, host); return err }); err != nil { |
| + state.Close() |
| + return err |
| + } |
| + } |
| + dispatch := claimUnitDispatch{base: base, bindings: b, shapes: productionClaimUnitShapes, definition: verifyClaimBoundUnitDefinition} |
| + h.Launch.Units, h.Launch.unitBindings = dispatch, b |
| + h.Launch.unitProgram = readClaimUnitProgram |
| + kernel, ok := h.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel) |
| + if !ok { |
| + state.Close() |
| + return os.ErrPermission |
| + } |
| + kernel.Units = dispatch |
| + h.Root.Stop.Coordinator.Kernel = kernel |
| + if h.Root.executionRuntime != nil { |
| + h.Root.executionRuntime.root = &h.Root |
| + } |
| + closePrevious := a.Close |
| + a.Close = func(ctx context.Context) error { |
| + var err error |
| + if closePrevious != nil { |
| + err = closePrevious(ctx) |
| + } |
| + return errors.Join(err, state.Close()) |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/claimunit_program.go b/internal/host/claimunit_program.go| new file mode 100644 |
| index 000000000..cac2be0d8 |
| --- /dev/null |
| +++ b/internal/host/claimunit_program.go |
| @@ -0,0 +1,99 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type claimUnitProgram struct { |
| + Schema int |
| + Host string |
| + Args, Env []string |
| +} |
| + |
| +func readClaimUnitProgram(shape claimUnitShape) (claimUnitProgram, error) { |
| + if !provision.ValidHostShape(shape.Host) { |
| + return claimUnitProgram{}, os.ErrPermission |
| + } |
| + f, err := rootstate.OpenAuthorityFile(filepath.Join("/etc/carlos/claim-units", shape.Host+".json")) |
| + if err != nil { |
| + return claimUnitProgram{}, err |
| + } |
| + defer f.Close() |
| + raw, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1)) |
| + if err != nil { |
| + return claimUnitProgram{}, err |
| + } |
| + return decodeClaimUnitProgram(raw, shape) |
| +} |
| + |
| +func decodeClaimUnitProgram(raw []byte, shape claimUnitShape) (claimUnitProgram, error) { |
| + var p claimUnitProgram |
| + sum := sha256.Sum256(raw) |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + dec.DisallowUnknownFields() |
| + if len(raw) > rootstate.MaxRecord || hex.EncodeToString(sum[:]) != shape.Program || dec.Decode(&p) != nil || dec.Decode(new(any)) != io.EOF || p.Schema != 1 || p.Host != shape.Host || len(p.Args) > 256 || len(p.Env) > 1024 { |
| + return p, os.ErrPermission |
| + } |
| + for _, arg := range p.Args { |
| + if strings.ContainsRune(arg, 0) { |
| + return p, os.ErrPermission |
| + } |
| + } |
| + seen := map[string]bool{} |
| + for _, env := range p.Env { |
| + key, _, ok := strings.Cut(env, "=") |
| + if !ok || key == "" || strings.ContainsRune(env, 0) || seen[key] { |
| + return p, os.ErrPermission |
| + } |
| + seen[key] = true |
| + } |
| + return p, nil |
| +} |
| + |
| +func (h *ClaimLaunchRoot) boundLaunchSpec(q hostbox.RoleLaunchRequest) (instancectl.Launch, bool, error) { |
| + var launch instancectl.Launch |
| + host := q.Reservation.Target.Host |
| + unit, _, err := claimLaunchPlacement(h.Units, host) |
| + if err != nil { |
| + return launch, false, err |
| + } |
| + if unit == instancectl.UnitName(instancectl.RoleRun, host) { |
| + return launch, false, nil |
| + } |
| + if h.unitBindings == nil { |
| + return launch, true, os.ErrPermission |
| + } |
| + b, err := h.unitBindings.read(host) |
| + if err != nil { |
| + return launch, true, err |
| + } |
| + // Older bindings only describe placement and retain the ordinary isolated |
| + // launch contract. Production configuration requires a program digest. |
| + if b.Shape.Program == "" { |
| + return launch, false, nil |
| + } |
| + if b.Retiring || b.Retired || b.Shape.Unit != unit || h.unitProgram == nil { |
| + return launch, true, os.ErrPermission |
| + } |
| + p, err := h.unitProgram(b.Shape) |
| + if err != nil { |
| + return launch, true, err |
| + } |
| + if !slices.Equal(p.Args, q.Spec.Args) || !slices.Equal(p.Env, q.Spec.Env) { |
| + return launch, true, os.ErrPermission |
| + } |
| + return instancectl.Launch{Schema: instancectl.LaunchSchema, Host: host, Argv: append([]string{filepath.Join(instancectl.LiveDir, host)}, p.Args...), Env: append([]string(nil), p.Env...)}, true, nil |
| +} |
diff --git a/internal/host/claimunit_program_test.go b/internal/host/claimunit_program_test.go| new file mode 100644 |
| index 000000000..e93ddae6b |
| --- /dev/null |
| +++ b/internal/host/claimunit_program_test.go |
| @@ -0,0 +1,88 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +func TestPlan14BoundProgram(t *testing.T) { |
| + f, _, _ := generationLaunchFixture(t) |
| + b, _, _ := plan14UnitBindingFixture(t, "console.service", "", "") |
| + in, err := f.root.Guard.Root.Installation(t.Context(), f.target.Host, hostguard.Captured{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p := claimUnitProgram{Schema: 1, Host: in.Host, Args: []string{"console", "-listen", "127.0.0.1:8090"}, Env: []string{"CONSOLE_ORIGIN=https://shop.example"}} |
| + raw, _ := json.Marshal(p) |
| + sum := sha256.Sum256(raw) |
| + s := claimUnitShape{Host: in.Host, Account: in.Account, App: in.App, Kind: "instance", Unit: "console.service", Addr: "127.0.0.1:8090", Program: hex.EncodeToString(sum[:])} |
| + b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return in, nil } |
| + b.canonical = func(context.Context, string) (claimUnitShape, error) { return s, nil } |
| + if _, err := b.establish(t.Context(), in.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.root.unitBindings = b |
| + f.root.unitProgram = func(shape claimUnitShape) (claimUnitProgram, error) { return decodeClaimUnitProgram(raw, shape) } |
| + f.root.Units = plan14NamedLaunchUnits{f.units, in.Host, s.Unit} |
| + f.req.Spec.Args, f.req.Spec.Env = p.Args, p.Env |
| + launch, err := f.root.launchSpec(f.req) |
| + if err != nil || len(launch.Argv) != 4 || launch.Argv[1] != "console" { |
| + t.Fatal(launch, err) |
| + } |
| + f.units.started.Unit, f.units.started.ControlGroup, _ = f.root.Units.(plan14NamedLaunchUnits).Placement(in.Host) |
| + f.req.Reservation.Group.RelativePath = f.units.started.ControlGroup |
| + inputs, err := f.req.Spec.Hash() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.req.Reservation.Inputs = inputs |
| + f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = inputs }) |
| + f.units.beforeCheck = func() { raw = append(raw, ' ') } |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil { |
| + t.Fatal("changed program at exec accepted") |
| + } |
| + if n, _ := f.units.counts(); n != 0 { |
| + t.Fatal("changed program executed") |
| + } |
| + raw, _ = json.Marshal(p) |
| + f.req.Spec.Env = []string{"CONSOLE_ORIGIN=https://forged.example"} |
| + if _, err := f.root.launchSpec(f.req); err == nil { |
| + t.Fatal("service-selected environment accepted") |
| + } |
| + f.req.Spec.Env = p.Env |
| + f.req.Spec.Args = []string{"shell"} |
| + if _, err := f.root.launchSpec(f.req); err == nil { |
| + t.Fatal("service-selected command accepted") |
| + } |
| + f.req.Spec.Args = p.Args |
| + raw = append(raw, ' ') |
| + if _, err := f.root.launchSpec(f.req); err == nil { |
| + t.Fatal("changed canonical bytes accepted") |
| + } |
| + f.root.unitProgram = nil |
| + if _, err := f.root.launchSpec(f.req); err == nil { |
| + t.Fatal("missing program reader fell through") |
| + } |
| +} |
| + |
| +func TestPlan14BoundProgramDecode(t *testing.T) { |
| + for _, p := range []claimUnitProgram{ |
| + {Schema: 2, Host: "shop.example"}, |
| + {Schema: 1, Host: "sibling.example"}, |
| + {Schema: 1, Host: "shop.example", Args: []string{"bad\x00arg"}}, |
| + {Schema: 1, Host: "shop.example", Env: []string{"A=1", "A=2"}}, |
| + {Schema: 1, Host: "shop.example", Env: []string{"INVALID"}}, |
| + } { |
| + raw, _ := json.Marshal(p) |
| + sum := sha256.Sum256(raw) |
| + if _, err := decodeClaimUnitProgram(raw, claimUnitShape{Host: "shop.example", Program: hex.EncodeToString(sum[:])}); err == nil { |
| + t.Fatal("invalid program accepted", p) |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan14_bound_sibling_root_linux_test.go b/internal/host/plan14_bound_sibling_root_linux_test.go| new file mode 100644 |
| index 000000000..3b95485eb |
| --- /dev/null |
| +++ b/internal/host/plan14_bound_sibling_root_linux_test.go |
| @@ -0,0 +1,158 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func plan14BoundSibling(t *testing.T, body []byte, ctl func(context.Context, ...string) (string, error), uid, gid int, lock *release.ReleaseExclusion) func() { |
| + t.Helper() |
| + f, disk, rec := generationLaunchFixtureHost(t, "sibling.example") |
| + host := rec.Host |
| + for _, dir := range []string{provision.InstanceDir("/data", host), provision.InstanceRunDir(host)} { |
| + if err := os.MkdirAll(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(dir, uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + template, err := unitsFS.ReadFile("units/carlos-instance@.service") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Match the named test's dependency and output adapters. |
| + lines := plan14RunnerUnitLines(template) |
| + path := "/run/systemd/system/carlos-instance@.service" |
| + if err := os.WriteFile(path, []byte(lines), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + ctl(context.Background(), "stop", instancectl.UnitName(instancectl.RoleRun, host)) |
| + ctl(context.Background(), "stop", instancectl.SliceName(host)) |
| + os.Remove(path) |
| + os.RemoveAll(filepath.Join("/run/systemd/system", instancectl.UnitName(instancectl.RoleRun, host)+".d")) |
| + os.RemoveAll(filepath.Join("/run/systemd/system", instancectl.SliceName(host)+".d")) |
| + ctl(context.Background(), "daemon-reload") |
| + }) |
| + if _, err := ctl(t.Context(), "daemon-reload"); err != nil { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + rec.Schema = 1 |
| + plan14PutRecord(t, store, rec) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.StageArtifact("/opt/carlos", rec.Account, "sibling", rec.App, body); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := release.Manifest{App: rec.App, Version: "sibling", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| + if err := release.Ship(t.Context(), store, rec.Account, m, map[string][]byte{release.AdoptPlatform(): body}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(t.Context(), store, priv, rec.Account, rec.App, "canary/console", "sibling"); err != nil { |
| + t.Fatal(err) |
| + } |
| + exe := release.VersionPath("/opt/carlos", rec.Account, "sibling", rec.App) |
| + file, err := rootstate.OpenTrustedFile(exe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := rootstate.Identity(file) |
| + file.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(body) |
| + f.req.Executable, f.req.Spec.Executable, f.req.Spec.Digest = exe, exe, hex.EncodeToString(sum[:]) |
| + f.req.Reservation.Executable = hostbox.LaunchFile{Device: id.Device, Inode: id.Inode} |
| + socket := filepath.Join(provision.InstanceRunDir(host), "app.sock") |
| + f.req.Spec.Args = []string{"--socket", socket, "--db", provision.NestedDBPath("/data", host)} |
| + f.req.Spec.Env = []string{"CARLOS_ADMIN_TOKEN=test", "PLAN14_TENANT_SOCKET=" + socket} |
| + inputs, err := f.req.Spec.Hash() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.req.Reservation.Inputs = inputs |
| + f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = inputs }) |
| + f.root.Clock, f.root.DataRoot = execlease.SystemClock{}, "/data" |
| + f.root.ReadyBudget = 10 * time.Second |
| + f.root.Units = instancectl.ClaimUnits{Systemctl: ctl, CgroupRoot: "/sys/fs/cgroup", ExeRoot: "/run/carlos-control/exe", UnitRoot: "/run/systemd/system", DataRoot: "/data"} |
| + f.root.Kernel = linuxClaimLaunchKernel{OptRoot: "/opt/carlos", CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"} |
| + f.root.generations = &rootGenerationSource{store: store, optDir: "/opt/carlos", pub: func() (ed25519.PublicKey, error) { return pub, nil }, open: func() (rootstate.Store, error) { return disk, nil }, openFile: rootstate.OpenTrustedFile} |
| + var started hostbox.RoleLaunchResponse |
| + if err := lock.With(t.Context(), func(ctx context.Context) error { |
| + var err error |
| + started, err = f.root.Serve(ctx, f.peer, f.req) |
| + return err |
| + }); err != nil { |
| + t.Fatal("sibling launch", err) |
| + } |
| + pin := readGenerationLaunch(t, disk) |
| + t.Cleanup(func() { |
| + q := f.req |
| + q.Op = hostbox.RoleLaunchStop |
| + q.Spec, q.Executable, q.MemoryMax = nil, "", 0 |
| + if _, err := f.root.Serve(context.Background(), f.peer, q); err != nil { |
| + t.Error("sibling stop", err) |
| + } |
| + }) |
| + return func() { |
| + t.Helper() |
| + if got := readGenerationLaunch(t, disk); got != pin { |
| + t.Fatal("sibling pin changed") |
| + } |
| + g := started.Grant |
| + if err := f.root.Units.Same(t.Context(), instancectl.ClaimUnitStarted{Unit: g.Unit, InvocationID: g.UnitInvocationID, MainPID: g.PID, ControlGroup: g.Group.RelativePath}); err != nil { |
| + t.Fatal("sibling invocation changed", err) |
| + } |
| + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + }} |
| + defer transport.CloseIdleConnections() |
| + resp, err := (&http.Client{Transport: transport}).Get("http://sibling/") |
| + if err != nil { |
| + t.Fatal("sibling stopped serving", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != 200 { |
| + t.Fatal(resp.Status) |
| + } |
| + } |
| +} |
| + |
| +func plan14RunnerUnitLines(template []byte) string { |
| + var lines []string |
| + for _, line := range strings.Split(string(template), "\n") { |
| + key, _, _ := strings.Cut(line, "=") |
| + switch key { |
| + case "BindsTo", "Requires", "After": |
| + continue |
| + case "StandardOutput": |
| + line = "StandardOutput=journal" |
| + } |
| + lines = append(lines, line) |
| + } |
| + return strings.Join(lines, "\n") |
| +} |
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go| index a10a33daf..5ae46f185 100644 |
| --- a/internal/host/plan14_installed_launch_root_linux_test.go |
| +++ b/internal/host/plan14_installed_launch_root_linux_test.go |
| @@ -33,7 +33,22 @@ func plan14InstalledTenant() { |
| if socket == "" { |
| os.Exit(2) |
| } |
| - ln, err := net.Listen("unix", socket) |
| + network := "unix" |
| + if addr := os.Getenv("PLAN14_TENANT_ADDR"); addr != "" { |
| + network, socket = "tcp", addr |
| + } |
| + if network == "unix" { |
| + // The payload owns its socket, like an ordinary Unix HTTP server. A |
| + // process killed at exit may leave that socket pathname behind. |
| + if st, err := os.Lstat(socket); err == nil { |
| + if st.Mode()&os.ModeSocket == 0 || os.Remove(socket) != nil { |
| + os.Exit(1) |
| + } |
| + } else if !os.IsNotExist(err) { |
| + os.Exit(1) |
| + } |
| + } |
| + ln, err := net.Listen(network, socket) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| os.Exit(1) |
| @@ -53,7 +68,26 @@ func TestPlan14NamedClaimLaunchRootRunner(t *testing.T) { |
| } |
| } |
| +// This witness permits loopback HTTP between the runner and its named TCP |
| +// payloads. The payloads make no remote network requests. |
| +func TestPlan14BoundRouteProgramRootRunner(t *testing.T) { |
| + for _, unit := range []string{"tcp", "socket-program", "backing-program"} { |
| + t.Run(unit, func(t *testing.T) { plan14InstalledClaimLaunch(t, unit) }) |
| + } |
| +} |
| + |
| func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| + programMode := boundUnit == "tcp" || boundUnit == "socket-program" || boundUnit == "backing-program" |
| + tcpMode := boundUnit == "tcp" || boundUnit == "backing-program" |
| + if boundUnit == "tcp" { |
| + boundUnit = "plan14-tcp.service" |
| + } |
| + if boundUnit == "socket-program" { |
| + boundUnit = "plan14-socket.service" |
| + } |
| + if boundUnit == "backing-program" { |
| + boundUnit = "backing" |
| + } |
| if dir := os.Getenv("PLAN14_INSTALLED_RETIRE_CHILD"); dir != "" { |
| plan14InstalledRetire(t, dir) |
| return |
| @@ -93,6 +127,21 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| t.Setenv("CARLOS_DATA_DIR", "/data") |
| t.Setenv("CARLOS_OPT_DIR", "/opt/carlos") |
| host := f.target.Host |
| + var endpoint string |
| + if tcpMode { |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + endpoint = ln.Addr().String() |
| + ln.Close() |
| + } |
| + program := claimUnitProgram{Schema: 1, Host: host, Args: []string{"plan14-tenant"}, Env: []string{"PLAN14_TENANT_SOCKET=" + filepath.Join(provision.InstanceRunDir(host), "app.sock")}} |
| + if tcpMode { |
| + program.Env = append(program.Env, "PLAN14_TENANT_ADDR="+endpoint) |
| + } |
| + programRaw, _ := json.Marshal(program) |
| + programSum := sha256.Sum256(programRaw) |
| if boundUnit == "backing" { |
| boundUnit = "carlos-app@" + host + ".service" |
| } |
| @@ -118,9 +167,16 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| continue |
| case "StandardOutput": |
| line = "StandardOutput=journal" |
| + case "ExecStart": |
| + if programMode { |
| + line = "ExecStart=/usr/bin/false" |
| + } |
| } |
| lines = append(lines, line) |
| } |
| + if tcpMode { |
| + lines = append(lines, "[Service]", "IPAddressAllow=localhost") |
| + } |
| unit := instancectl.UnitName(instancectl.RoleRun, host) |
| unitPath := "/run/systemd/system/carlos-instance@.service" |
| if boundUnit != "" { |
| @@ -216,6 +272,29 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if strings.HasPrefix(boundUnit, "carlos-app@") { |
| shape.Backing = "unit" |
| } |
| + if programMode { |
| + shape.Program = hex.EncodeToString(programSum[:]) |
| + if tcpMode { |
| + shape.Addr, shape.Socket = endpoint, "" |
| + } |
| + if err := os.MkdirAll("/etc/carlos/claim-units", 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join("/etc/carlos/claim-units", host+".json"), programRaw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + programPath := filepath.Join("/etc/carlos/claim-units", host+".json") |
| + if err := os.Chmod(programPath, 0666); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := readClaimUnitProgram(shape); err == nil { |
| + t.Fatal("service-writable program accepted") |
| + } |
| + if err := os.Chmod(programPath, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.root.unitProgram = readClaimUnitProgram |
| + } |
| f.root.unitBindings = &claimUnitBindings{state: bindings, installation: func(context.Context, string) (hostguard.RootInstallation, error) { return old, nil }, canonical: func(context.Context, string) (claimUnitShape, error) { return shape, nil }} |
| if _, err := f.root.unitBindings.establish(t.Context(), host); err != nil { |
| t.Fatal(err) |
| @@ -231,6 +310,26 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| } |
| return "" |
| }(), Systemctl: ctl, CgroupRoot: "/sys/fs/cgroup", ExeRoot: "/run/carlos-control/exe", UnitRoot: "/run/systemd/system", DataRoot: "/data", WaitBudget: 20 * time.Second} |
| + if programMode { |
| + base := f.root.Units.(instancectl.ClaimUnits) |
| + base.BoundHost, base.BoundUnit = "", "" |
| + binding, err := f.root.unitBindings.read(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.root.Units = claimUnitDispatch{base: base, bindings: f.root.unitBindings, shapes: func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{host: binding.Shape}, nil }, definition: func(s claimUnitShape) error { |
| + // Dependencies and output are runner adapters; all isolation settings |
| + // and the permanent no-reservation refusal remain in the unit. |
| + raw, err := os.ReadFile(unitPath) |
| + if err != nil { |
| + return err |
| + } |
| + if string(raw) != strings.Join(lines, "\n") { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + }} |
| + } |
| f.root.Kernel = linuxClaimLaunchKernel{OptRoot: "/opt/carlos", CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"} |
| store := release.NewDirStore(filepath.Join(dir, "deployment")) |
| rec.Schema = 1 |
| @@ -268,6 +367,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| f.req.Spec.Digest = hex.EncodeToString(digest[:]) |
| f.req.Spec.Args = []string{"--socket", filepath.Join(provision.InstanceRunDir(host), "app.sock"), "--db", provision.NestedDBPath("/data", host)} |
| f.req.Spec.Env = []string{"CARLOS_ADMIN_TOKEN=test", "PLAN14_TENANT_SOCKET=" + filepath.Join(provision.InstanceRunDir(host), "app.sock")} |
| + if programMode { |
| + f.req.Spec.Args, f.req.Spec.Env = program.Args, program.Env |
| + } |
| inputs, err := f.req.Spec.Hash() |
| if err != nil { |
| t.Fatal(err) |
| @@ -276,6 +378,11 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = inputs }) |
| f.root.generations = &rootGenerationSource{store: store, optDir: "/opt/carlos", pub: func() (ed25519.PublicKey, error) { return pub, nil }, open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "generations"), true, false) }, openFile: rootstate.OpenTrustedFile} |
| lock := &release.ReleaseExclusion{Open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "release"), true, false) }} |
| + checkSibling := func() {} |
| + if programMode { |
| + checkSibling = plan14BoundSibling(t, body, ctl, uid, gid, lock) |
| + checkSibling() |
| + } |
| if err := f.req.Validate(); err != nil { |
| t.Fatal("request", err) |
| } |
| @@ -296,6 +403,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| t.Fatalf("production launch: %v\n%s", err, logs) |
| } |
| transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + if tcpMode { |
| + return (&net.Dialer{}).DialContext(ctx, "tcp", endpoint) |
| + } |
| return (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(provision.InstanceRunDir(host), "app.sock")) |
| }} |
| defer transport.CloseIdleConnections() |
| @@ -309,6 +419,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if resp.StatusCode != 200 { |
| t.Fatal(resp.Status) |
| } |
| + checkSibling() |
| generations, err := rootstate.Open(filepath.Join(dir, "generations"), false, false) |
| if err != nil { |
| t.Fatal(err) |
| @@ -360,6 +471,50 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if _, err := f.root.Serve(t.Context(), f.peer, stop); err != nil { |
| t.Fatal("production stop", err) |
| } |
| + if programMode { |
| + ctl(t.Context(), "start", unit) |
| + time.Sleep(100 * time.Millisecond) |
| + state, err := ctl(t.Context(), "show", "-p", "MainPID", "--value", unit) |
| + if err != nil || state != "0" { |
| + t.Fatal("unreserved restart executed", state, err) |
| + } |
| + ctl(t.Context(), "stop", unit) |
| + next := *f.req.Reservation |
| + next.ID, next.InvocationID = "attempt-2:main", "inv-2" |
| + f.req.Reservation = &next |
| + f.claims.set(func(c *hostclaim.Claim) { |
| + c.Holder.WakeRetry.AttemptsReserved++ |
| + c.Holder.WakeRetry.Attempt.ID = "attempt-2" |
| + c.Holder.WakeRetry.Attempt.InvocationID = "inv-2" |
| + }) |
| + var restarted hostbox.RoleLaunchResponse |
| + if err := lock.With(t.Context(), func(ctx context.Context) error { |
| + var err error |
| + restarted, err = f.root.Serve(ctx, f.peer, f.req) |
| + return err |
| + }); err != nil { |
| + logs, _ := exec.Command("journalctl", "-u", unit, "--no-pager", "-n", "100").CombinedOutput() |
| + state, _ := ctl(t.Context(), "show", "-p", "ActiveState,SubState,InvocationID,ControlGroup,ExecStart,Result", unit) |
| + t.Fatalf("reserved restart: %v\n%s\n%s", err, state, logs) |
| + } |
| + if restarted.Grant.UnitInvocationID == response.Grant.UnitInvocationID { |
| + t.Fatal("restart reused invocation") |
| + } |
| + resp, err := client.Get("http://tenant/") |
| + if err != nil { |
| + t.Fatal("restarted endpoint", err) |
| + } |
| + resp.Body.Close() |
| + transport.CloseIdleConnections() |
| + if resp.StatusCode != 200 { |
| + t.Fatal(resp.Status) |
| + } |
| + stop.Reservation = &next |
| + if _, err := f.root.Serve(t.Context(), f.peer, stop); err != nil { |
| + t.Fatal("restarted stop", err) |
| + } |
| + } |
| + checkSibling() |
| if err := journal.CloseWriters(t.Context(), f.target); err != nil { |
| t.Fatal(err) |
| } |
| @@ -381,6 +536,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| } else { |
| t.Logf("%s", output) |
| } |
| + checkSibling() |
| } |
| func plan14InstalledRetire(t *testing.T, dir string) { |
| @@ -401,13 +557,14 @@ func plan14InstalledRetire(t *testing.T, dir string) { |
| defer generations.Close() |
| ledger := &release.RootGenerations{Store: generations} |
| records, err := ledger.Reservations(t.Context()) |
| - if err != nil || len(records) != 2 { |
| + if err != nil || len(records) < 2 { |
| t.Fatal(records, err) |
| } |
| - var v1, v3 release.GenerationReservation |
| + var v1s []release.GenerationReservation |
| + var v3 release.GenerationReservation |
| for _, r := range records { |
| if r.Binding.Version == "v1" { |
| - v1 = r |
| + v1s = append(v1s, r) |
| } else { |
| v3 = r |
| } |
| @@ -417,12 +574,14 @@ func plan14InstalledRetire(t *testing.T, dir string) { |
| } |
| verifier := generationRetirement{guard: hostguard.Guard{DataDir: "/data", LockDir: filepath.Join(dir, "retire-locks"), Root: hostguard.NewRootAuthority(state)}, journal: journal, kernel: productionGenerationAbsence} |
| lock := &release.ReleaseExclusion{Open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "release"), false, false) }} |
| - if err := lock.With(t.Context(), func(ctx context.Context) error { return verifier.retire(ctx, ledger, v1) }); err != nil { |
| - t.Fatal(err) |
| - } |
| - released, err := ledger.Read(v1.Binding) |
| - if err != nil || released.Retirement == "" { |
| - t.Fatal("exact pin not released", released, err) |
| + for _, v1 := range v1s { |
| + if err := lock.With(t.Context(), func(ctx context.Context) error { return verifier.retire(ctx, ledger, v1) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + released, err := ledger.Read(v1.Binding) |
| + if err != nil || released.Retirement == "" { |
| + t.Fatal("exact pin not released", released, err) |
| + } |
| } |
| pending, err := ledger.Read(v3.Binding) |
| if err != nil || pending != v3 { |
diff --git a/internal/host/plan14_root_generations_test.go b/internal/host/plan14_root_generations_test.go| index ff1827930..15d24415c 100644 |
| --- a/internal/host/plan14_root_generations_test.go |
| +++ b/internal/host/plan14_root_generations_test.go |
| @@ -42,8 +42,12 @@ func (s *generationDisk) Write(k string, b []byte) error { |
| } |
| func generationLaunchFixture(t *testing.T) (*launchFixture, *generationDisk, provision.Record) { |
| + return generationLaunchFixtureHost(t, "shop.example") |
| +} |
| + |
| +func generationLaunchFixtureHost(t *testing.T, host string) (*launchFixture, *generationDisk, provision.Record) { |
| t.Helper() |
| - f := newLaunchFixture(t, servingOp()) |
| + f := newLaunchFixtureHost(t, servingOp(), host) |
| store := release.NewDirStore(t.TempDir()) |
| rec := provision.Record{Host: f.target.Host, Account: "bab", App: "shop", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)} |
| plan14PutRecord(t, store, rec) |
| @@ -306,6 +310,10 @@ func TestPlan14GenerationReservationProcessCrash(t *testing.T) { |
| } |
| func TestPlan14GenerationProductionWiring(t *testing.T) { |
| + oldOpen, oldShapes := openClaimUnitBindings, productionClaimUnitShapes |
| + openClaimUnitBindings = func() (rootstate.Store, error) { return &rollbackMemory{files: map[string][]byte{}}, nil } |
| + productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{}, nil } |
| + t.Cleanup(func() { openClaimUnitBindings, productionClaimUnitShapes = oldOpen, oldShapes }) |
| f, _, _ := generationLaunchFixture(t) |
| root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }, Coordinator: ClaimStopCoordinator{Kernel: stopKernelFunc(nil), Guard: f.root.Guard}}} |
| assembly := &ClaimAssembly{Claims: f.root.generations.store, ManagedRoots: map[string]string{"opt": f.root.generations.optDir, "data": "/data"}, Local: f.root.Local} |
| @@ -315,6 +323,12 @@ func TestPlan14GenerationProductionWiring(t *testing.T) { |
| t.Fatal(err) |
| } |
| launch := handler.(ClaimRootWithLaunch).Launch |
| + if _, ok := launch.Units.(claimUnitDispatch); !ok || launch.unitBindings == nil { |
| + t.Fatal("production unit dispatch missing") |
| + } |
| + if err := assembly.Close(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| if launch.generations == nil || launch.generations.open == nil || launch.generations.openFile == nil || launch.generations.pub == nil || launch.generations.store != assembly.Claims || launch.history == nil { |
| t.Fatal("production launch lacks a mandatory root reservation") |
| } |
diff --git a/internal/instancectl/claimexec.go b/internal/instancectl/claimexec.go| index 6ff0c6992..f7009dcd6 100644 |
| --- a/internal/instancectl/claimexec.go |
| +++ b/internal/instancectl/claimexec.go |
| @@ -8,6 +8,7 @@ import ( |
| "net" |
| "os" |
| "path/filepath" |
| + "strconv" |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -90,7 +91,7 @@ func (u ClaimUnits) prepareExecGate(ctx context.Context, host string, p ClaimExe |
| return gate, nil |
| } |
| -func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnitStarted, check func() error) error { |
| +func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s *ClaimUnitStarted, check func() error) error { |
| deadline := time.Now().Add(u.budget()) |
| if d, ok := ctx.Deadline(); ok && d.Before(deadline) { |
| deadline = d |
| @@ -110,10 +111,10 @@ func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnit |
| if err = conn.SetDeadline(deadline); err != nil { |
| return err |
| } |
| - if err = claimExecPeer(conn, s.MainPID); err != nil { |
| + if err = u.pinExecPeer(ctx, conn, s); err != nil { |
| return err |
| } |
| - if err = u.Same(ctx, s); err != nil { |
| + if err = u.Same(ctx, *s); err != nil { |
| return err |
| } |
| var asked ClaimExecPermit |
| @@ -127,9 +128,43 @@ func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnit |
| if err = check(); err != nil { |
| return err |
| } |
| - if err = u.Same(ctx, s); err != nil { |
| + if err = u.Same(ctx, *s); err != nil { |
| return err |
| } |
| // One connection, one permit. Closing/restarting root cannot replay it. |
| return json.NewEncoder(conn).Encode(g.permit) |
| } |
| + |
| +// PrivatePIDs may replace systemd's setup PID with the namespace init PID. |
| +// Only this pre-exec handshake may pin that transition. The invocation and |
| +// cgroup must stay fixed, and systemd must identify the socket peer as MainPID. |
| +func (u ClaimUnits) pinExecPeer(ctx context.Context, conn net.Conn, s *ClaimUnitStarted) error { |
| + for { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if err := claimExecPeer(conn, s.MainPID); err == nil { |
| + return nil |
| + } |
| + state, err := u.show(ctx, s.Unit, "InvocationID", "MainPID", "ControlGroup", "ActiveState") |
| + if err != nil { |
| + return err |
| + } |
| + if state["InvocationID"] != s.InvocationID || state["ControlGroup"] != "/"+s.ControlGroup || state["ActiveState"] != "active" { |
| + return ErrClaimUnit |
| + } |
| + pid, err := strconv.Atoi(state["MainPID"]) |
| + if err != nil || pid <= 0 { |
| + return ErrClaimUnit |
| + } |
| + if err := claimExecPeer(conn, pid); err == nil { |
| + s.MainPID = pid |
| + return nil |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-time.After(u.poll()): |
| + } |
| + } |
| +} |
diff --git a/internal/instancectl/claimexec_linux.go b/internal/instancectl/claimexec_linux.go| index dad2541e0..00688bf08 100644 |
| --- a/internal/instancectl/claimexec_linux.go |
| +++ b/internal/instancectl/claimexec_linux.go |
| @@ -2,7 +2,10 @@ |
| package instancectl |
| -import "net" |
| +import ( |
| + "fmt" |
| + "net" |
| +) |
| func claimExecPeer(c net.Conn, pid int) error { |
| cred, err := peerCred(c) |
| @@ -10,7 +13,7 @@ func claimExecPeer(c net.Conn, pid int) error { |
| return err |
| } |
| if int(cred.Pid) != pid { |
| - return ErrClaimUnit |
| + return fmt.Errorf("%w: executable gate peer %d differs from unit MainPID %d", ErrClaimUnit, cred.Pid, pid) |
| } |
| return nil |
| } |
diff --git a/internal/instancectl/claimexec_startup_linux_test.go b/internal/instancectl/claimexec_startup_linux_test.go| new file mode 100644 |
| index 000000000..629752715 |
| --- /dev/null |
| +++ b/internal/instancectl/claimexec_startup_linux_test.go |
| @@ -0,0 +1,68 @@ |
| +//go:build linux |
| + |
| +package instancectl |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPlan14ExecPeerNamespaceTransition(t *testing.T) { |
| + for _, attack := range []string{"none", "invocation", "group", "peer", "state"} { |
| + t.Run(attack, func(t *testing.T) { |
| + dir, err := os.MkdirTemp("", "ep") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(dir) |
| + ln, err := net.Listen("unix", filepath.Join(dir, "s")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer ln.Close() |
| + client, err := net.Dial("unix", filepath.Join(dir, "s")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer client.Close() |
| + server, err := ln.Accept() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer server.Close() |
| + s := ClaimUnitStarted{Unit: "console.service", InvocationID: "inv", ControlGroup: "group", MainPID: os.Getpid() + 10000} |
| + calls := 0 |
| + u := ClaimUnits{Poll: time.Millisecond, Systemctl: func(context.Context, ...string) (string, error) { |
| + calls++ |
| + pid, inv, group, state := os.Getpid(), "inv", "/group", "active" |
| + if calls == 1 || attack == "peer" { |
| + pid += 10000 |
| + } |
| + switch attack { |
| + case "invocation": |
| + inv = "replacement" |
| + case "group": |
| + group = "/sibling" |
| + case "state": |
| + state = "failed" |
| + } |
| + return fmt.Sprintf("MainPID=%d\nInvocationID=%s\nControlGroup=%s\nActiveState=%s\n", pid, inv, group, state), nil |
| + }} |
| + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) |
| + defer cancel() |
| + err = u.pinExecPeer(ctx, server, &s) |
| + if attack == "none" { |
| + if err != nil || s.MainPID != os.Getpid() || calls < 2 { |
| + t.Fatal(s, calls, err) |
| + } |
| + } else if err == nil { |
| + t.Fatal("accepted changed identity", attack) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/instancectl/claimunit.go b/internal/instancectl/claimunit.go| index 9e0ec697e..b4a278e4f 100644 |
| --- a/internal/instancectl/claimunit.go |
| +++ b/internal/instancectl/claimunit.go |
| @@ -217,7 +217,7 @@ func (u ClaimUnits) start(ctx context.Context, in ClaimUnit, launch *Launch, che |
| } |
| started := ClaimUnitStarted{Unit: unit, InvocationID: id, ControlGroup: group, MainPID: pid} |
| if gate != nil { |
| - if err := gate.authorize(ready, u, started, check); err != nil { |
| + if err := gate.authorize(ready, u, &started, check); err != nil { |
| cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), u.budget()) |
| defer cancel() |
| if sameErr := u.Same(cleanup, started); sameErr != nil { |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index ec24b90d7..283b2bfee 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-e.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-e.tsv |
| @@ -54,7 +54,7 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S |
| internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a |
| internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab |
| internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7 |
| -@coverage internal/host f7295d9eaa466a1b8ca6daf9ec5176b604538815739fec2b12d0fe20b87b4f8b |
| +@coverage internal/host 20d945384e2acd968e65c69ad218b960dc1428993a3b281092aac9390a4b90a7 |
| @coverage internal/release 329e742eddf1a5da3cd8f4a6b76a2eda2e38e5e6f0132d66e7b3830cfa95e77f |
| @coverage internal/rootstate a773d6dbc0e4150e0054f7a94d0e1493c88d0d57f4f4530138b3427e2c217bc5 |
| internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154 |
| @@ -184,11 +184,11 @@ proof_cli.go runProof authority E1 E17: operator saves exact signed authorizatio |
| internal/historyreadback/readback.go RequestStatus authority E1 E17: operator saves exact signed authorization and challenge before delivery, resumes without renewal, and verifies enrolled-root exact completion. Existing proof branches are unchanged. Tests: TestPlan14HistoryOperatorWorkflow, TestPlan14HistoryOperatorConflicts, TestPlan14HistoryOperatorExactCompletion, TestPlan14HistoryWorkflowProcessCrash, TestPlan14HistoryCommandResume, TestPlan14HistorySavedFiles, TestPlan14HistoryCLIDispatch. d89052b0cdd12c8c8dcbc353e9ae995005caeb88d8e4c70974cb9e6f4bfd46d7 |
| @coverage . dcc873ca955a35bf69ae09641d50576790123ec17aaf01637f0cfbbcf85530ce |
| @coverage internal/historyreadback eaca4d4caacf499db8a4c4e8044d565eec5bb42e0fc765b66f6c7559fbb04842 |
| -internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,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. 183f783e1998ffbe49fec813501dfcb9238ffae3b8a09dc545cf88d1b1609dd0 |
| +internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,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 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. d207171fd9e9ac2b056f246623da6e35ae80296590ebd018852415e4cf7a7133 |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.launchLocked authority E1,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. file:Read@2348,file:Read@2507,file:Read@707 8857198d935629758593ec447afac75c9b28ecaf09743bb8f304f93ea868c66e |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1,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. 78796fbb8f2516f96d1de84cfdd97f294cbcbd21ee63e8b47aa4e2fc2bad3980 |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1,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. file:Read@2865 f0bc2975c60b0476090e265227b6428b3e7ab0bb41c6a0feb9a9a2244642fbc9 |
| -internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,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. bf0bd36504cca10325b1f10a6f1ed094bbaccfc1a0bf4391e006af53ae2a9f94 |
| +internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,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. f1a0ee3e297b8f85b63f0f5361d065d3cd21448552f1574ec54f6256409fe3f3 |
| internal/host/rootgenerations_launch.go generationLaunchPermit.check 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. 788e600dd37fec60c5b98dc42b23c182c337df064088a041e2e20b68573942b5 |
| internal/host/rootgenerations_launch.go generationLaunchPermit.started 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 E45: root uses retained reservation identities and live kernel containment checks; unrecognized processes still refuse. Current positive root acceptance requires rerun after proc-mount tightening. a7635d02d9cb682be64a0df3f8932336f5e874260898df9013ff7ffd7e3d7462 |
| internal/host/rootgenerations_launch.go productionRootGenerations 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. file:Open@346 a1132403ace7213de9e12720d5a393a3bb85045d1d2c53f0c37ea2ae8e933b04 |
| @@ -391,7 +391,7 @@ internal/host/rootgeneration_containment_linux.go generationKernel.containedTask |
| internal/host/claimstop_run.go claimActivationReady authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. 82d893dc252c02813cd492f44dab8adc75b0e6fe5eaf4b7314c17759fdd5c83e |
| internal/host/rootreleaselock.go withClaimReleaseCensus authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. 35bcc5e18e89f9755cf1f749bbaa4cbf493380b75500183687476abd71c946b6 |
| internal/rootparticipation/ledger.go IsAcquisitionRecovery authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. d71ec87152a9ba780aaf6e08fd54288bd6da40d61ad96495e7c3b29a0be200cd |
| -@coverage internal/instancectl e2a238c3bd1267e3084705ccb5b2e0bff4b3feed82accbb48de8bd4dd68b9e29 |
| +@coverage internal/instancectl 9425fb28a2251848755a36fd8e68921c1ab0f72db21320493a15e6701a76180b |
| @coverage internal/instanceexec 1a4b9c7a5c57c99ecb39b56c724f08f7e0226dc4809f31000e49e4b38cb4d059 |
| @fields A,Account,Accounts,AddedAt,Addr,Addrs,Alias,Aliases,Alive,AllowAbsent,App,Archives,Args,At,Attempts,B,Backing,Bucket,BuildState,Callback,CallbackError,CallbackFailed,Canary,Caps,CertExpiresAt,Changed,Channel,CheckedAt,ClaimIncarnation,ClaimOwner,ClaimOwnership,ClaimRowID,ClaimStamp,Class,Contents,CreatedAt,DBPath,DNSCheckedAt,DNSStatus,Deadline,Demand,Digest,DoneBefore,Due,DueAt,Env,EnvBefore,Environment,Error,ErrorKind,ExcludeHosts,ExpectGood,ExpectOp,Expected,F,Failed,FenceGen,Fleet,FleetBox,FleetBoxes,Fleets,From,FromDB,FromOp,FromSock,Gate,GoodDemand,GoodDigest,GoodVersion,Hash,Health,Held,Hibernate,HoldOp,Host,Hosts,ID,Id,IdValue,Idle,IdleSeconds,ImportGate,Journal,Keep,Kind,Label,LastAt,LastError,LastMs,LastStatus,Limit,MaxRun,MaxRunSeconds,Method,Ms,Name,Next,NextAt,NextDue,NextGood,NextValue,Now,Number,Number64,Observed,ObservedValue,Old,On,Op,Original,Owner,Ownership,P,Path,Paths,Platform,Pool,Prefix,Prev,PrevVersion,Provisioned,RecordKey,Region,Registration,Replica,Replicas,Replication,Retirement,Route,Routes,Row,Rows,Seed,SeedID,SeedSource,ServingVersion,Slug,Socket,Spec,Sqid,State,Status,StatusNamed,Steering,StringMap,T,TValue,Tags,Target,Targets,Text,Timer,Timers,Times,To,ToDB,ToOp,ToSock,TokenHash,Tunnel,Unit,UpdateSettings,Ver,Version,W,WakeIncarnation,Wildcards,closed,conn,connect,ctx,d,dec,done,enc,full,input,mu,output,path,remote,sql,wait |
| internal/host/agent.go runAgent authority B,C,D,F rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with wireSeedPlacement called in the activator start's Configure step, before armMovesPass, and the startup sweep's result stored for the import rollback in Recover; read sites unchanged, offsets moved; main's B,C,D,F authority retained PROPOSED E46: captured claim selection and root journal mutation; root RPC outside service host exclusion; SQL/route data never grants root authority. Executing identity: service controller; root for inventory/retirement callbacks. file:Exec@13802,file:Open@731,registry:App@10065,whole:Route@10057,whole:Route@23109 098dab554d79d132385030a61a266947c39d91b9c2670a3be1141e7eacfbab72 |
| @@ -434,8 +434,23 @@ internal/host/claimlaunch_root.go ClaimLaunchRoot.stop authority C,G,J4,E2 root- |
| internal/host/claimlaunch_root.go claimUnitStopKernel.settleUnits authority C,G,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:Read@219 82c5a4ea6295e61d08f7a77a26e42040af6fff953265d8055d2454e28dff5ef1 |
| internal/host/claimunit_binding.go claimUnitBindings.check 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. whole:Route@342,whole:Route@384 ade4c1bc0458015b450959ac11e28696c19491e24a76f334a5fb4f9c3ce8e767 |
| internal/host/claimunit_binding.go claimUnitBindings.read 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:Read@211 3ce1ba6c82c41f1ad4ac005066746fcefa8a679ff587d07fa8794ca743a77685 |
| -internal/host/claimunit_binding.go claimUnitShape.matches hint 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. registry:Account@98,registry:Addr@229,registry:Addrs@263,registry:App@120,registry:Backing@182,registry:DBPath@251,registry:Host@75,registry:Kind@139,registry:Replica@315,registry:Seed@298,registry:Socket@207,registry:Tunnel@280,registry:Unit@159 3846a08b4ea9297cb75bb7ebd25e07c9a63b8ada6a53ec8b49f2dce4e323ad6b |
| +internal/host/claimunit_binding.go claimUnitShape.matches hint 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. 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. registry:Account@98,registry:Addr@237,registry:Addrs@271,registry:App@120,registry:Backing@190,registry:DBPath@259,registry:Host@75,registry:Kind@139,registry:Replica@323,registry:Seed@306,registry:Socket@215,registry:Tunnel@288,registry:Unit@149,registry:Unit@167 e0fd04d11a508d0d01830e7e646cc33d3673b5d1f9f25756981de70fcb61d46f |
| 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.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 claimUnitBindings.establish 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. 8001dce0f3c38b1c08845b8a536870e8d644114401670b8ca4950562b3c3517e |
| +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. 725731eb019d6a4e612a7a94bcebc5619139405081053d725a4c67f763d536d4 |
| +internal/host/claimunit_definition.go claimBoundUnitDefinition 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. file:ReadFile@165 2ef162905f3ca438ac3c186c92cd04059ba4deb07da29badd9d31a02d352ff00 |
| +internal/host/claimunit_definition.go verifyClaimBoundUnitDefinition 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. file:ReadAll@285 107db8628ffc00a68a079040c507484372c36815e20de082f4964caac958597e |
| +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 |
| +internal/host/claimunit_dispatch.go claimUnitDispatch.forStart 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. ca62826e85ad57e88e68739cf35bee81c4caa0e541f1883d4440ebbdaefc4890 |
| +internal/host/claimunit_dispatch.go claimUnitDispatch.selected 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. d167dd0c3f8f2b32995fd4d330e9fd3d15054e6abcbc77ce9408a0bf3b2c8196 |
| +internal/host/claimunit_production.go bindProductionClaimUnits 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. file:Read@1270 bf9467dfc3f9819e7157fad6af0ae86c3d0bdb05f56fcd55fb68438e73699712 |
| +internal/host/claimunit_production.go var openClaimUnitBindings 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. file:Open@70 f332e5eeef4116b6a9242f7f5c6c47bef78639a042ef0db3993a54dfae71963c |
| +internal/host/claimunit_program.go ClaimLaunchRoot.boundLaunchSpec 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. e80de9bd3222f06b6a7999ab9aa160a7a5edbb07f58eb7ba6ecba2ef77e66b7e |
| +internal/host/claimunit_program.go readClaimUnitProgram 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. file:ReadAll@353 28ee039bab9f2461f10797082d8cfafe751f094271f377d04565e410127808ec |
| +internal/instancectl/claimexec.go ClaimUnits.pinExecPeer 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. 6966c6e7ca873e312051e94c473f59331b33a1bf6b1017a2a83cd05c66ec01c4 |
| +internal/instancectl/claimexec.go claimExecGate.authorize 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. 96964cb1eb39b1dd8ecbd47e9e6139e92a61bc72a83ebacfe9cf5886b6d08712 |
| +internal/instancectl/claimunit.go ClaimUnits.start 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. 415efaf2a2a3b304cc9b623c19f0f772e941939322f5752eb8c74ed49e93b1dd |