WIP claim Plan 14 track E checkpoint 52 (not for merge)
4a: flat-socket operator migration and reboot recovery; console composition under the bound unit (persistent store, reserved restart, edge-restart survival, sibling isolation).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+799
−7
internal/host/claimunit_materialize.go+2 −0internal/host/claimunit_migrate.go+32 −0internal/host/claimunit_migration_reboot_root_linux_test.go+198 −0internal/host/claimunit_production.go+1 −0internal/host/claimunit_socket_migration_linux.go+140 −0internal/host/claimunit_socket_migration_other.go+15 −0internal/host/claimunit_socket_migration_test.go+220 −0internal/host/plan14_console_root_linux_test.go+121 −0internal/host/plan14_generation_registry_root_linux_test.go+4 −0internal/host/plan14_installed_launch_root_linux_test.go+55 −3internal/provision/testdata/plan14-root-reads-e.tsv+11 −4
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 292.387s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.895s
ok github.com/carlosframework/platform/cmd/moveprobe 92.204s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 251.206s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.466s
ok github.com/carlosframework/platform/internal/apperr 0.070s
ok github.com/carlosframework/platform/internal/applog 0.121s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.870s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.700s
ok github.com/carlosframework/platform/internal/changelog 0.301s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 388.205s
? 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.960s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.394s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.544s
? 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.131s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.014s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/historyreadback 0.152s
ok github.com/carlosframework/platform/internal/host 144.372s
ok github.com/carlosframework/platform/internal/hostactors 0.400s
ok github.com/carlosframework/platform/internal/hostbox 2.357s
ok github.com/carlosframework/platform/internal/hostclaim 0.510s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 4.899s
ok github.com/carlosframework/platform/internal/hostfence 0.040s
ok github.com/carlosframework/platform/internal/hostfiles 10.550s
ok github.com/carlosframework/platform/internal/hostfloor 0.013s
ok github.com/carlosframework/platform/internal/hostguard 1.024s
ok github.com/carlosframework/platform/internal/hostops 1.560s
? 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.447s
ok github.com/carlosframework/platform/internal/hostresolve 0.141s
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.254s
ok github.com/carlosframework/platform/internal/instanceexec 2.747s
ok github.com/carlosframework/platform/internal/instancefs 0.024s
ok github.com/carlosframework/platform/internal/instancekey 0.023s
ok github.com/carlosframework/platform/internal/instanceupdates 0.029s
ok github.com/carlosframework/platform/internal/layout 1.542s
ok github.com/carlosframework/platform/internal/lease 9.828s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.163s
ok github.com/carlosframework/platform/internal/maintenance 0.083s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.548s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.069s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 20.986s
ok github.com/carlosframework/platform/internal/moves/archive 0.785s
ok github.com/carlosframework/platform/internal/objstore 0.186s
ok github.com/carlosframework/platform/internal/ops 0.170s
ok github.com/carlosframework/platform/internal/park 0.209s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 17.082s
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.443s
ok github.com/carlosframework/platform/internal/relay 7.230s
ok github.com/carlosframework/platform/internal/release 22.222s
ok github.com/carlosframework/platform/internal/rollout 3.774s
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.577s
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.494s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.591s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.706s
ok github.com/carlosframework/platform/internal/status 0.040s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.145s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
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.001s
ok github.com/carlosframework/platform/internal/vet 1.533s
=== 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: 47382690 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.022s
=== RUN TestT4StorageProbeMinio
2026/10/15 22:46:22 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DED570E7FB5EE4, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/host/claimunit_materialize.go b/internal/host/claimunit_materialize.go| index a0aee374f..713521fb6 100644 |
| --- a/internal/host/claimunit_materialize.go |
| +++ b/internal/host/claimunit_materialize.go |
| @@ -24,6 +24,8 @@ type claimUnitMaterialization struct { |
| } |
| type claimUnitMaterializer struct { |
| + socketCapture func(context.Context, claimUnitShape, claimUnitPredecessor) (claimUnitSocketIdentity, error) |
| + socketPark func(claimUnitBinding, claimUnitSocketIdentity) error |
| predecessor func(context.Context, claimUnitBinding) (claimUnitPredecessor, error) |
| stopPredecessor func(context.Context, claimUnitPredecessor) error |
| idle func(string) error |
diff --git a/internal/host/claimunit_migrate.go b/internal/host/claimunit_migrate.go| index fd44f23c9..bc4d206dd 100644 |
| --- a/internal/host/claimunit_migrate.go |
| +++ b/internal/host/claimunit_migrate.go |
| @@ -21,6 +21,13 @@ type claimUnitMigration struct { |
| Definition rootstate.FileIdentity |
| Digest string |
| Predecessor claimUnitPredecessor |
| + Socket claimUnitSocketIdentity `json:",omitempty"` |
| +} |
| + |
| +type claimUnitSocketIdentity struct { |
| + Boot string |
| + Device, Inode uint64 |
| + UID, GID, Mode uint32 |
| } |
| func (p claimUnitPredecessor) valid(unit string) bool { |
| @@ -42,6 +49,15 @@ func (m claimUnitMaterializer) migration(ctx context.Context, b claimUnitBinding |
| return nil, err |
| } |
| r := &claimUnitMigration{Definition: id, Digest: claimUnitDigest(raw), Predecessor: p} |
| + if claimFlatSocket(b.Shape) { |
| + if m.socketCapture == nil || m.socketPark == nil { |
| + return nil, os.ErrPermission |
| + } |
| + r.Socket, err = m.socketCapture(ctx, b.Shape, p) |
| + if err != nil { |
| + return nil, err |
| + } |
| + } |
| if !r.valid(b) { |
| return nil, os.ErrPermission |
| } |
| @@ -63,6 +79,9 @@ func (m claimUnitMaterializer) finishMigration(ctx context.Context, r claimUnitM |
| if err = m.idle(r.Binding.Shape.Host); err != nil { |
| return err |
| } |
| + if err = m.finishSocketMigration(r); err != nil { |
| + return err |
| + } |
| if err = m.replace(r.Binding.Shape.Unit, id, want); err != nil { |
| return err |
| } |
| @@ -84,10 +103,23 @@ func (m claimUnitMaterializer) finishMigration(ctx context.Context, r claimUnitM |
| if err = m.idle(r.Binding.Shape.Host); err != nil { |
| return err |
| } |
| + if err = m.finishSocketMigration(r); err != nil { |
| + return err |
| + } |
| r.Identity = id |
| return m.save(r) |
| } |
| +func (m claimUnitMaterializer) finishSocketMigration(r claimUnitMaterialization) error { |
| + if !claimFlatSocket(r.Binding.Shape) { |
| + return nil |
| + } |
| + if r.Migration == nil || r.Migration.Socket.Boot == "" || m.socketPark == nil { |
| + return os.ErrPermission |
| + } |
| + return m.socketPark(r.Binding, r.Migration.Socket) |
| +} |
| + |
| func claimPredecessorPlacement(unit, group string) bool { |
| if !instancectl.ValidBoundClaimUnit(unit) { |
| return false |
diff --git a/internal/host/claimunit_migration_reboot_root_linux_test.go b/internal/host/claimunit_migration_reboot_root_linux_test.go| new file mode 100644 |
| index 000000000..b35b45e73 |
| --- /dev/null |
| +++ b/internal/host/claimunit_migration_reboot_root_linux_test.go |
| @@ -0,0 +1,198 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +const migrationRebootDir = "/var/lib/carlos-e52-migration" |
| +const migrationRebootUnit = "plan14-e52-migration.service" |
| +const migrationRebootSocket = "/run/carlos/plan14-e52.sock" |
| + |
| +func migrationRebootMaterial(t *testing.T) claimUnitMaterializer { |
| + t.Helper() |
| + store, err := rootstate.Open(filepath.Join(migrationRebootDir, "state"), true, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { store.Close() }) |
| + files := claimUnitFiles{dir: "/etc/systemd/system"} |
| + k := claimPredecessorKernel{ctl: rebootPredecessorCtl, group: claimPredecessorGroup, boot: claimUnitBoot} |
| + return claimUnitMaterializer{state: store, read: files.read, create: files.create, replace: files.replace, predecessor: k.capture, stopPredecessor: k.stop, |
| + socketCapture: captureProductionClaimUnitSocket, socketPark: parkProductionClaimUnitSocket, idle: func(string) error { return nil }} |
| +} |
| + |
| +func TestRootRebootClaimUnitMigrationPhase1(t *testing.T) { |
| + for _, dir := range []string{migrationRebootDir, "/run/carlos", "/run/carlos-control"} { |
| + if err := os.Mkdir(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := os.ReadFile(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(migrationRebootDir, "payload"), body, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy := []byte("[Service]\nType=simple\nEnvironment=PLAN14_TENANT_SOCKET=" + migrationRebootSocket + "\nExecStart=" + migrationRebootDir + "/payload plan14-tenant\n") |
| + path := "/etc/systemd/system/" + migrationRebootUnit |
| + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = f.Write(legacy) |
| + if err = errors.Join(err, f.Close()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out, err := rebootPredecessorCtl(t.Context(), "daemon-reload"); err != nil { |
| + t.Fatal(out, err) |
| + } |
| + if out, err := rebootPredecessorCtl(t.Context(), "start", migrationRebootUnit); err != nil { |
| + t.Fatal(out, err) |
| + } |
| + for until := time.Now().Add(5 * time.Second); ; { |
| + conn, err := net.DialTimeout("unix", migrationRebootSocket, time.Second) |
| + if err == nil { |
| + conn.Close() |
| + break |
| + } |
| + if time.Now().After(until) { |
| + t.Fatal(err) |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + _, in, s := plan14UnitBindingFixture(t, migrationRebootUnit, "", migrationRebootSocket) |
| + s.Program, s.PredecessorDigest = strings.Repeat("a", 64), claimUnitDigest(legacy) |
| + b := claimUnitBinding{Schema: 1, Owner: unitBindingOwner(in), Shape: s} |
| + m := migrationRebootMaterial(t) |
| + m.stopPredecessor = func(context.Context, claimUnitPredecessor) error { return os.ErrClosed } |
| + if err := m.install(t.Context(), b); !errors.Is(err, os.ErrClosed) { |
| + t.Fatal("migration intent boundary", err) |
| + } |
| + r, err := m.load(b.Owner.Host) |
| + if err != nil || r.Migration == nil || r.Migration.Socket.Boot == "" || r.Identity != (rootstate.FileIdentity{}) { |
| + t.Fatal("lost durable migration", r, err) |
| + } |
| + raw, _ := json.Marshal(b) |
| + if err := os.WriteFile(filepath.Join(migrationRebootDir, "binding.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + old := claimEdgeMaskOwner |
| + defer func() { claimEdgeMaskOwner = old }() |
| + claimEdgeMaskOwner = filepath.Join(migrationRebootDir, "edge-mask") |
| + if _, err := createClaimEdgeMask(t.Context(), r.Migration.Socket.Boot); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Logf("migration saved boot=%s device=%d mount=%d socket=%d:%d", r.Migration.Definition.Boot, r.Migration.Definition.Device, r.Migration.Definition.Mount, r.Migration.Socket.Device, r.Migration.Socket.Inode) |
| +} |
| + |
| +func TestRootRebootClaimUnitMigrationPhase2(t *testing.T) { |
| + t.Cleanup(func() { |
| + rebootPredecessorCtl(context.Background(), "stop", migrationRebootUnit) |
| + os.Remove("/etc/systemd/system/" + migrationRebootUnit) |
| + os.Remove("/run/systemd/system/carlos-edge.service") |
| + rebootPredecessorCtl(context.Background(), "daemon-reload") |
| + for _, dir := range []string{migrationRebootDir, "/run/carlos", "/run/carlos-control"} { |
| + os.RemoveAll(dir) |
| + } |
| + }) |
| + for _, dir := range []string{"/run/carlos", "/run/carlos-control"} { |
| + if err := os.Mkdir(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var b claimUnitBinding |
| + raw, err := os.ReadFile(filepath.Join(migrationRebootDir, "binding.json")) |
| + if err != nil || json.Unmarshal(raw, &b) != nil { |
| + t.Fatal(err) |
| + } |
| + m := migrationRebootMaterial(t) |
| + r, err := m.load(b.Owner.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + boot, err := claimUnitBoot() |
| + if err != nil || boot == r.Migration.Definition.Boot { |
| + t.Fatal("actual reboot required", err) |
| + } |
| + oldMaskOwner := claimEdgeMaskOwner |
| + defer func() { claimEdgeMaskOwner = oldMaskOwner }() |
| + claimEdgeMaskOwner = filepath.Join(migrationRebootDir, "edge-mask") |
| + if err := os.Symlink("/dev/null", "/run/systemd/system/carlos-edge.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out, err := rebootPredecessorCtl(t.Context(), "daemon-reload"); err != nil { |
| + t.Fatal(out, err) |
| + } |
| + if err := recoverLegacyClaimEdgeMask(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if target, err := os.Readlink("/run/systemd/system/carlos-edge.service"); err != nil || target != "/dev/null" { |
| + t.Fatal("old boot removed replacement mask", err) |
| + } |
| + if _, err := os.Lstat(claimEdgeMaskOwner); !os.IsNotExist(err) { |
| + t.Fatal("old mask record retained", err) |
| + } |
| + path := "/etc/systemd/system/" + migrationRebootUnit |
| + if err := unix.Mount(path, path, "", unix.MS_BIND, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + refused := m.install(t.Context(), b) |
| + if err := unix.Unmount(path, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if refused == nil { |
| + t.Fatal("substituted definition mount accepted") |
| + } |
| + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: migrationRebootSocket, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = m.install(t.Context(), b); err == nil { |
| + t.Fatal("post-boot replacement socket removed") |
| + } |
| + if _, err := os.Lstat(migrationRebootSocket); err != nil { |
| + t.Fatal("replacement socket lost", err) |
| + } |
| + listener.Close() |
| + if err := m.install(t.Context(), b); err != nil { |
| + t.Fatal("resume migration after reboot", err) |
| + } |
| + if err := m.verify(b); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := projectClaimBoundSocket(b.Shape); err != nil { |
| + t.Fatal(err) |
| + } |
| + if target, err := os.Readlink(migrationRebootSocket); err != nil || target != claimSocketTarget(b.Shape) { |
| + t.Fatal("socket projection", target, err) |
| + } |
| + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + if _, err := rebootPredecessorCtl(t.Context(), "start", migrationRebootUnit); err == nil { |
| + time.Sleep(100 * time.Millisecond) |
| + pid, _ := rebootPredecessorCtl(t.Context(), "show", "-p", "MainPID", "--value", migrationRebootUnit) |
| + if pid != "0" { |
| + t.Fatal("migration started without reservation", pid) |
| + } |
| + } |
| +} |
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go| index daf1f0597..28be4f429 100644 |
| --- a/internal/host/claimunit_production.go |
| +++ b/internal/host/claimunit_production.go |
| @@ -100,6 +100,7 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl |
| }, state: materialState, read: files.read, create: files.create, replace: files.replace} |
| predecessor := claimPredecessorKernel{ctl: base.Systemctl, group: claimPredecessorGroup, boot: claimUnitBoot} |
| material.predecessor, material.stopPredecessor = predecessor.capture, predecessor.stop |
| + material.socketCapture, material.socketPark = captureProductionClaimUnitSocket, parkProductionClaimUnitSocket |
| b.handover = material.handover |
| dispatch := claimUnitDispatch{base: base, bindings: b, shapes: productionClaimUnitShapes, effective: func(ctx context.Context, shape claimUnitShape) error { |
| return claimEffectiveUnit(ctx, shape, base.Systemctl) |
diff --git a/internal/host/claimunit_socket_migration_linux.go b/internal/host/claimunit_socket_migration_linux.go| new file mode 100644 |
| index 000000000..a1fe099bb |
| --- /dev/null |
| +++ b/internal/host/claimunit_socket_migration_linux.go |
| @@ -0,0 +1,140 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "github.com/carlosframework/platform/internal/safefile" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func captureClaimUnitSocket(path, boot string) (claimUnitSocketIdentity, error) { |
| + parent, err := safefile.OpenDir(filepath.Dir(path)) |
| + if err != nil { |
| + return claimUnitSocketIdentity{}, err |
| + } |
| + defer parent.Close() |
| + return claimUnitSocketAt(int(parent.Fd()), filepath.Base(path), boot) |
| +} |
| + |
| +func claimUnitSocketAt(parent int, name, boot string) (claimUnitSocketIdentity, error) { |
| + var st unix.Stat_t |
| + if err := unix.Fstatat(parent, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + return claimUnitSocketIdentity{}, err |
| + } |
| + if boot == "" || st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Nlink != 1 { |
| + return claimUnitSocketIdentity{}, os.ErrPermission |
| + } |
| + return claimUnitSocketIdentity{Boot: boot, Device: uint64(st.Dev), Inode: st.Ino, UID: st.Uid, GID: st.Gid, Mode: st.Mode}, nil |
| +} |
| + |
| +func parkClaimUnitSocket(source, archive, boot string, want claimUnitSocketIdentity) error { |
| + return parkClaimUnitSocketWith(source, archive, boot, want, unix.Renameat2) |
| +} |
| + |
| +func parkClaimUnitSocketWith(source, archive, boot string, want claimUnitSocketIdentity, rename func(int, string, int, string, uint) error) error { |
| + if want.Boot == "" || want.Device == 0 || want.Inode == 0 || want.Mode&unix.S_IFMT != unix.S_IFSOCK || boot == "" { |
| + return os.ErrPermission |
| + } |
| + src, err := safefile.OpenDir(filepath.Dir(source)) |
| + if err != nil { |
| + return err |
| + } |
| + defer src.Close() |
| + dst, err := safefile.OpenDir(filepath.Dir(archive)) |
| + if err != nil { |
| + return err |
| + } |
| + defer dst.Close() |
| + sfd, dfd := int(src.Fd()), int(dst.Fd()) |
| + sname, dname := filepath.Base(source), filepath.Base(archive) |
| + old, sourceErr := claimUnitSocketAt(sfd, sname, boot) |
| + parked, archiveErr := claimUnitSocketAt(dfd, dname, boot) |
| + if errors.Is(sourceErr, os.ErrNotExist) { |
| + if errors.Is(archiveErr, os.ErrNotExist) { |
| + return errors.Join(src.Sync(), dst.Sync()) |
| + } |
| + if archiveErr != nil || parked != want { |
| + return os.ErrPermission |
| + } |
| + return errors.Join(src.Sync(), dst.Sync()) |
| + } |
| + if sourceErr != nil || old != want || !errors.Is(archiveErr, os.ErrNotExist) { |
| + return os.ErrPermission |
| + } |
| + if err = rename(sfd, sname, dfd, dname, unix.RENAME_NOREPLACE); err != nil { |
| + return err |
| + } |
| + parked, err = claimUnitSocketAt(dfd, dname, boot) |
| + if err != nil || parked != want { |
| + // Preserve a raced replacement; never overwrite a new source name. |
| + restore := unix.Renameat2(dfd, dname, sfd, sname, unix.RENAME_NOREPLACE) |
| + return errors.Join(os.ErrPermission, err, restore, src.Sync(), dst.Sync()) |
| + } |
| + return errors.Join(src.Sync(), dst.Sync()) |
| +} |
| + |
| +func captureProductionClaimUnitSocket(ctx context.Context, s claimUnitShape, p claimUnitPredecessor) (claimUnitSocketIdentity, error) { |
| + if !claimFlatSocket(s) { |
| + return claimUnitSocketIdentity{}, os.ErrPermission |
| + } |
| + boot, err := claimUnitBoot() |
| + if err != nil { |
| + return claimUnitSocketIdentity{}, err |
| + } |
| + before, err := captureClaimUnitSocket(s.Socket, boot) |
| + if err != nil { |
| + return before, err |
| + } |
| + out, err := exec.CommandContext(ctx, "systemctl", "show", "-p", "MainPID", "--value", p.Unit).Output() |
| + if err != nil { |
| + return before, err |
| + } |
| + pid, err := strconv.Atoi(strings.TrimSpace(string(out))) |
| + if err != nil || pid <= 1 { |
| + return before, os.ErrPermission |
| + } |
| + conn, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, "unix", s.Socket) |
| + if err != nil { |
| + return before, err |
| + } |
| + defer conn.Close() |
| + if err = claimProbePeer(conn, pid, "0::/"+p.Group); err != nil { |
| + return before, err |
| + } |
| + after, err := captureClaimUnitSocket(s.Socket, boot) |
| + if err != nil || after != before { |
| + return before, os.ErrPermission |
| + } |
| + return before, nil |
| +} |
| + |
| +func parkProductionClaimUnitSocket(b claimUnitBinding, id claimUnitSocketIdentity) error { |
| + if !b.valid() || !claimFlatSocket(b.Shape) { |
| + return os.ErrPermission |
| + } |
| + boot, err := claimUnitBoot() |
| + if err != nil { |
| + return err |
| + } |
| + const dir = "/run/carlos-control/unit-migrations" |
| + store, err := rootstate.Open(dir, true, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer store.Close() |
| + raw, err := json.Marshal(b) |
| + if err != nil { |
| + return err |
| + } |
| + return parkClaimUnitSocket(b.Shape.Socket, filepath.Join(dir, claimUnitDigest(raw)+".sock"), boot, id) |
| +} |
diff --git a/internal/host/claimunit_socket_migration_other.go b/internal/host/claimunit_socket_migration_other.go| new file mode 100644 |
| index 000000000..8eba79199 |
| --- /dev/null |
| +++ b/internal/host/claimunit_socket_migration_other.go |
| @@ -0,0 +1,15 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| +) |
| + |
| +func captureProductionClaimUnitSocket(context.Context, claimUnitShape, claimUnitPredecessor) (claimUnitSocketIdentity, error) { |
| + return claimUnitSocketIdentity{}, os.ErrPermission |
| +} |
| +func parkProductionClaimUnitSocket(claimUnitBinding, claimUnitSocketIdentity) error { |
| + return os.ErrPermission |
| +} |
diff --git a/internal/host/claimunit_socket_migration_test.go b/internal/host/claimunit_socket_migration_test.go| new file mode 100644 |
| index 000000000..65580fd5f |
| --- /dev/null |
| +++ b/internal/host/claimunit_socket_migration_test.go |
| @@ -0,0 +1,220 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestPlan14FlatSocketMigration(t *testing.T) { |
| + for _, attack := range []string{"none", "replacement", "boot", "lost-rename", "archive-replacement", "erased"} { |
| + t.Run(attack, func(t *testing.T) { |
| + dir := t.TempDir() |
| + source, archive := filepath.Join(dir, "old.sock"), filepath.Join(dir, "retained.sock") |
| + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + listener.SetUnlinkOnClose(false) |
| + defer listener.Close() |
| + id, err := captureClaimUnitSocket(source, "boot-one") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + listener.Close() |
| + boot := "boot-one" |
| + switch attack { |
| + case "erased": |
| + if err := os.Remove(source); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "boot": |
| + boot = "boot-two" |
| + case "replacement": |
| + if err := os.Rename(source, source+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(source, []byte("replacement"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "lost-rename", "archive-replacement": |
| + if err := os.Rename(source, archive); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "archive-replacement" { |
| + if err := os.Rename(archive, archive+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(archive, []byte("replacement"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + renames := 0 |
| + err = parkClaimUnitSocketWith(source, archive, boot, id, func(sfd int, src string, dfd int, dst string, flags uint) error { |
| + renames++ |
| + return unix.Renameat2(sfd, src, dfd, dst, flags) |
| + }) |
| + if attack == "none" || attack == "lost-rename" || attack == "erased" { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = parkClaimUnitSocket(source, archive, boot, id); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + if _, err := os.Lstat(source); !os.IsNotExist(err) { |
| + t.Fatal("old socket still occupies routing name", err) |
| + } |
| + } else { |
| + if err == nil { |
| + t.Fatal("unowned socket accepted") |
| + } |
| + if renames != 0 { |
| + t.Fatal("unowned socket renamed", renames) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14OperatorSocketMigrationDurableRetry(t *testing.T) { |
| + for _, loss := range []string{"stop", "park", "replace"} { |
| + t.Run(loss, func(t *testing.T) { |
| + b, in, shape := plan14UnitBindingFixture(t, "woodstar.service", "", "/run/carlos/woodstar.sock") |
| + old := []byte("[Service]\nExecStart=/old/program\n") |
| + shape.Program, shape.PredecessorDigest = strings.Repeat("a", 64), claimUnitDigest(old) |
| + b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| + binding, err := b.establish(t.Context(), in.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := t.TempDir() |
| + source, archive := filepath.Join(dir, "source.sock"), filepath.Join(dir, "archive.sock") |
| + ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ln.SetUnlinkOnClose(false) |
| + defer ln.Close() |
| + want, err := captureClaimUnitSocket(source, "boot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + files := &plan14UnitFiles{body: old, id: rootstate.FileIdentity{Device: 1, Inode: 2}} |
| + state := &rollbackMemory{files: map[string][]byte{}} |
| + p := claimUnitPredecessor{Unit: shape.Unit, Invocation: "old", Group: "system.slice/woodstar.service", Boot: "boot", GroupIdentity: rootstate.FileIdentity{Device: 3, Inode: 4}} |
| + stopped, parked := false, false |
| + m := claimUnitMaterializer{state: state, read: files.read, create: files.create, idle: func(string) error { |
| + if !stopped { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + }} |
| + m.predecessor = func(context.Context, claimUnitBinding) (claimUnitPredecessor, error) { return p, nil } |
| + m.socketCapture = func(context.Context, claimUnitShape, claimUnitPredecessor) (claimUnitSocketIdentity, error) { |
| + return want, nil |
| + } |
| + m.stopPredecessor = func(context.Context, claimUnitPredecessor) error { |
| + r, err := m.load(in.Host) |
| + if err != nil || r.Migration == nil || r.Migration.Socket != want { |
| + t.Fatal("stop without durable socket identity", err) |
| + } |
| + stopped = true |
| + if loss == "stop" { |
| + return os.ErrClosed |
| + } |
| + return nil |
| + } |
| + m.socketPark = func(_ claimUnitBinding, id claimUnitSocketIdentity) error { |
| + if !stopped || id != want { |
| + t.Fatal("socket park before exact predecessor stop") |
| + } |
| + if err := parkClaimUnitSocket(source, archive, "boot", id); err != nil { |
| + return err |
| + } |
| + parked = true |
| + if loss == "park" { |
| + return os.ErrClosed |
| + } |
| + return nil |
| + } |
| + m.replace = func(unit string, id rootstate.FileIdentity, raw []byte) error { |
| + if !parked { |
| + t.Fatal("unit replaced before socket migration") |
| + } |
| + if err := files.replace(unit, id, raw); err != nil { |
| + return err |
| + } |
| + if loss == "replace" { |
| + return os.ErrClosed |
| + } |
| + return nil |
| + } |
| + if err := m.install(t.Context(), binding); err == nil { |
| + t.Fatal("lost reply accepted") |
| + } |
| + loss = "" |
| + if err := m.install(t.Context(), binding); err != nil { |
| + t.Fatal("durable retry", err) |
| + } |
| + if err := m.verify(binding); err != nil || files.retires != 1 { |
| + t.Fatal("migration did not finish exactly once", err, files.retires) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14FlatSocketMigrationRenameRace(t *testing.T) { |
| + for _, occupied := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "restore", true: "occupied"}[occupied], func(t *testing.T) { |
| + dir := t.TempDir() |
| + source, archive := filepath.Join(dir, "source.sock"), filepath.Join(dir, "archive.sock") |
| + ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ln.SetUnlinkOnClose(false) |
| + defer ln.Close() |
| + id, err := captureClaimUnitSocket(source, "boot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = parkClaimUnitSocketWith(source, archive, "boot", id, func(sfd int, src string, dfd int, dst string, flags uint) error { |
| + if err := os.Rename(source, source+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(source, []byte("raced"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + err := unix.Renameat2(sfd, src, dfd, dst, flags) |
| + if occupied && err == nil { |
| + err = os.WriteFile(source, []byte("newest"), 0600) |
| + } |
| + return err |
| + }) |
| + if err == nil { |
| + t.Fatal("raced replacement accepted") |
| + } |
| + path, want := source, "raced" |
| + if occupied { |
| + path, want = archive, "raced" |
| + got, e := os.ReadFile(source) |
| + if e != nil || string(got) != "newest" { |
| + t.Fatal("new source overwritten", e) |
| + } |
| + } |
| + got, err := os.ReadFile(path) |
| + if err != nil || string(got) != want { |
| + t.Fatal("raced file lost", string(got), err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan14_console_root_linux_test.go b/internal/host/plan14_console_root_linux_test.go| new file mode 100644 |
| index 000000000..99396de7a |
| --- /dev/null |
| +++ b/internal/host/plan14_console_root_linux_test.go |
| @@ -0,0 +1,121 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func plan14ConsolePayload() { |
| + st, err := console.Open(os.Getenv("CONSOLE_DB")) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + defer st.Close() |
| + srv, err := console.NewServer(console.Config{Origin: "http://tenant", Keymail: os.Getenv("CONSOLE_KEYMAIL")}, st) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + ln, err := net.Listen("tcp", os.Getenv("CONSOLE_ADDR")) |
| + if err == nil { |
| + err = http.Serve(ln, srv) |
| + } |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| +} |
| + |
| +func plan14ConsoleProgram(t *testing.T, host, endpoint string) (claimUnitProgram, func(*http.Client)) { |
| + t.Helper() |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var contacted atomic.Int64 |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + contacted.Add(1) |
| + w.WriteHeader(http.StatusBadRequest) |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + keymail := "http://" + ln.Addr().String() |
| + p := claimUnitProgram{Schema: 1, Host: host, Args: []string{"plan14-console"}, Env: []string{ |
| + "PLAN14_CONSOLE=1", "CONSOLE_ADDR=" + endpoint, "CONSOLE_DB=" + provision.NestedDBPath("/data", host), "CONSOLE_KEYMAIL=" + keymail, |
| + }} |
| + device := "" |
| + return p, func(c *http.Client) { |
| + t.Helper() |
| + check := func(path, body string) map[string]any { |
| + t.Helper() |
| + resp, err := c.Post("http://tenant"+path, "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var result map[string]any |
| + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || resp.StatusCode != http.StatusOK { |
| + t.Fatal(path, resp.Status, result, err) |
| + } |
| + return result |
| + } |
| + if device == "" { |
| + started := check("/auth/cli/start", `{"label":"reserved console"}`) |
| + device, _ = started["device_code"].(string) |
| + if device == "" { |
| + t.Fatal("console did not write login request", started) |
| + } |
| + } |
| + body, _ := json.Marshal(map[string]string{"device_code": device}) |
| + result := check("/auth/cli/poll", string(body)) |
| + if result["status"] != "pending" { |
| + t.Fatal("console lost persistent login request", result) |
| + } |
| + client := *c |
| + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } |
| + resp, err := client.Get("http://tenant/auth/signin") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + location, err := url.Parse(resp.Header.Get("Location")) |
| + if err != nil || resp.StatusCode != http.StatusSeeOther || !strings.HasPrefix(location.String(), keymail+"/") { |
| + t.Fatal("console sign-in did not select the test upstream", err, resp.Status) |
| + } |
| + req, _ := http.NewRequest(http.MethodGet, "http://tenant/auth/callback?code=runner&state="+url.QueryEscape(location.Query().Get("state")), nil) |
| + for _, cookie := range resp.Cookies() { |
| + req.AddCookie(cookie) |
| + } |
| + resp, err = client.Do(req) |
| + if err != nil { |
| + t.Fatal("console exchange", err) |
| + } |
| + io.Copy(io.Discard, resp.Body) |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusBadGateway || contacted.Load() != 0 { |
| + t.Fatal("console reached a sibling TCP upstream", resp.Status, contacted.Load()) |
| + } |
| + control := &http.Client{Timeout: time.Second} |
| + resp, err = control.Get(keymail) |
| + if err != nil { |
| + t.Fatal("upstream control", err) |
| + } |
| + resp.Body.Close() |
| + if contacted.Swap(0) != 1 { |
| + t.Fatal("control did not reach upstream") |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan14_generation_registry_root_linux_test.go b/internal/host/plan14_generation_registry_root_linux_test.go| index ba422fc13..bf839a3f8 100644 |
| --- a/internal/host/plan14_generation_registry_root_linux_test.go |
| +++ b/internal/host/plan14_generation_registry_root_linux_test.go |
| @@ -14,6 +14,10 @@ import ( |
| // The test binary must take the same private registry entrypoint as carlos. |
| func TestMain(m *testing.M) { |
| + if os.Getenv("PLAN14_CONSOLE") == "1" { |
| + plan14ConsolePayload() |
| + return |
| + } |
| if os.Getenv("PLAN14_TENANT_SOCKET") != "" { |
| plan14InstalledTenant() |
| return |
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go| index 4e7edc329..2310be902 100644 |
| --- a/internal/host/plan14_installed_launch_root_linux_test.go |
| +++ b/internal/host/plan14_installed_launch_root_linux_test.go |
| @@ -107,7 +107,13 @@ func TestPlan14BoundNetworkEdgeRootRunner(t *testing.T) { |
| } |
| } |
| +func TestPlan14RealConsoleRootRunner(t *testing.T) { |
| + plan14InstalledClaimLaunch(t, "console:materialized:tcp") |
| +} |
| + |
| func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| + realConsole := strings.HasPrefix(boundUnit, "console:") |
| + boundUnit = strings.TrimPrefix(boundUnit, "console:") |
| isolation := strings.HasPrefix(boundUnit, "isolation:") |
| boundUnit = strings.TrimPrefix(boundUnit, "isolation:") |
| startup := strings.HasPrefix(boundUnit, "startup:") |
| @@ -185,6 +191,10 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if tcpMode { |
| program.Env = append(program.Env, "PLAN14_TENANT_ADDR="+endpoint) |
| } |
| + var checkConsole func(*http.Client) |
| + if realConsole { |
| + program, checkConsole = plan14ConsoleProgram(t, host, endpoint) |
| + } |
| programRaw, _ := json.Marshal(program) |
| programSum := sha256.Sum256(programRaw) |
| if boundUnit == "backing" { |
| @@ -239,6 +249,12 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| unitPath = filepath.Join("/etc/systemd/system", unit) |
| } |
| legacyDefinition := []byte("[Service]\nType=simple\nUser=carlos\nKillMode=control-group\nExecStart=/bin/sh -c 'sleep infinity & wait'\n") |
| + if migrated && !tcpMode { |
| + if err := os.Chown("/run/carlos", uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacyDefinition = []byte("[Service]\nType=simple\nUser=carlos\nKillMode=control-group\nEnvironment=PLAN14_TENANT_SOCKET=" + socketEndpoint + "\nExecStart=/opt/carlos/carlos plan14-tenant\n") |
| + } |
| if migrated { |
| if err := os.WriteFile(unitPath, legacyDefinition, 0644); err != nil { |
| t.Fatal(err) |
| @@ -270,6 +286,20 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if out, err := ctl(t.Context(), "start", unit); err != nil { |
| t.Fatal(out, err) |
| } |
| + if !tcpMode { |
| + deadline := time.Now().Add(5 * time.Second) |
| + for { |
| + conn, err := net.DialTimeout("unix", socketEndpoint, time.Second) |
| + if err == nil { |
| + conn.Close() |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("legacy socket did not serve", err) |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + } |
| } |
| // Use a real controller lifetime, which must be joined before exact release. |
| controller := exec.Command("/bin/sleep", "240") |
| @@ -394,8 +424,19 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| t.Fatal(err) |
| } |
| if migrated { |
| - kernel := claimPredecessorKernel{ctl: ctl, group: claimPredecessorGroup} |
| + kernel := claimPredecessorKernel{ctl: ctl, group: claimPredecessorGroup, boot: claimUnitBoot} |
| material.predecessor, material.stopPredecessor = kernel.capture, kernel.stop |
| + material.socketCapture, material.socketPark = captureProductionClaimUnitSocket, parkProductionClaimUnitSocket |
| + if !tcpMode { |
| + predecessor, err := kernel.capture(t.Context(), binding) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + predecessor.Group = "system.slice/unrelated.service" |
| + if _, err := captureProductionClaimUnitSocket(t.Context(), binding.Shape, predecessor); err == nil { |
| + t.Fatal("socket accepted a foreign predecessor cgroup") |
| + } |
| + } |
| } |
| if err := material.install(t.Context(), binding); err != nil { |
| debug, _ := ctl(t.Context(), "show", unit, "-p", "ActiveState,InvocationID,ControlGroup,FragmentPath,DropInPaths") |
| @@ -551,7 +592,10 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if resp.StatusCode != 200 { |
| t.Fatal(resp.Status) |
| } |
| - if isolation { |
| + if realConsole { |
| + checkConsole(client) |
| + } |
| + if isolation || realConsole { |
| if tcpMode { |
| siblingTransport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| return (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(provision.InstanceRunDir("sibling.example"), "app.sock")) |
| @@ -567,7 +611,12 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| t.Fatal("sibling reached bound TCP upstream", response.StatusCode) |
| } |
| } |
| - for _, name := range []string{"console", "sibling"} { |
| + for _, name := range func() []string { |
| + if realConsole { |
| + return nil |
| + } |
| + return []string{"console", "sibling"} |
| + }() { |
| listener, err := net.Listen("tcp", "127.0.0.1:0") |
| if err != nil { |
| t.Fatal(err) |
| @@ -712,6 +761,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if resp.StatusCode != 200 { |
| t.Fatal(resp.Status) |
| } |
| + if realConsole { |
| + checkConsole(client) |
| + } |
| stop.Reservation = &next |
| if _, err := f.root.Serve(t.Context(), f.peer, stop); err != nil { |
| t.Fatal("restarted stop", err) |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index 99e09d4ca..cba8e575e 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-e.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-e.tsv |
| @@ -486,8 +486,8 @@ internal/host/claimunit_program.go ClaimLaunchRoot.boundLaunchSpec authority E2 |
| internal/host/claimunit_handover.go claimUnitMaterializer.handover authority E2 PROPOSED E49: root; retained retired predecessor binding and exact tombstone inode authorize only same-shape successor installation. The caller independently resolves the current root installation. PROPOSED E51: Root compares persistent predecessor/tombstone identity before successor replacement; owner and shape checks remain exact. dfd500b03a93044c4a8c9f67a8eeee0408e4ff163184197a8eb8fe40ee8ff351 |
| # PROPOSED E49 |
| -internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root installs retained-placement stop/retirement callbacks, resumes saved retirement before binding, and isolates each configuration/program/materialization failure. Initial binding reads occur outside host exclusion then local owner is checked inside. Readiness looks up the reserved host, not a startup list. PROPOSED E51: Root supplies the kernel boot reader to predecessor capture and stop; startup retirement replay remains ahead of per-unit binding. file:Read@1150 428a2b2b685954f9060c750f7408258bf1c0f9768955b4b740129ec9b20408ef |
| -@coverage internal/host a41f3316fd51ce72142b6a3927415fbcd4c1b7e02976097e97fcbf60d6b3687f |
| +internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root installs retained-placement stop/retirement callbacks, resumes saved retirement before binding, and isolates each configuration/program/materialization failure. Initial binding reads occur outside host exclusion then local owner is checked inside. Readiness looks up the reserved host, not a startup list. PROPOSED E51: Root supplies the kernel boot reader to predecessor capture and stop; startup retirement replay remains ahead of per-unit binding. PROPOSED E52: Root wires peer-verified flat-socket capture and exact retained socket parking into canonical unit migration; no-census path unchanged. file:Read@1150 ffaf6904dece68748905b0ddba9ee94a12f50050402f6bef48f882a88c7ad934 |
| +@coverage internal/host bc0c5ed13d503fc6167b6821eae3aafe9dc965c59960031fdf5a01fe080879a1 |
| # PROPOSED E50: root unit migration and R-E50 safety gates |
| internal/caps/converge.go Converge authority E2 PROPOSED E50: Root capability producer calls CheckHost before any filesystem or systemd effect. 7231c4e38b0093565412265a540af518b60fa8b0f0263502ce8845b2d7c2967d |
| @@ -499,8 +499,8 @@ internal/host/claimunit_effective.go claimEffectiveUnit authority E2 PROPOSED E5 |
| internal/host/claimunit_effective.go claimIPSet authority E2 PROPOSED E50: Root normalizes systemd IP prefixes for exact network-policy comparison; malformed prefixes refuse. afa75b50fae4f7d0a785fc2fcfc03f27b68b4accbc8bcf6b1404984a7df282fc |
| internal/host/claimunit_effective.go claimReservedDropin authority E2 PROPOSED E50: Root constructs exact expected reservation drop-in bytes from the retained host identity and fixed private paths; a caller cannot choose bind destinations. data-literal@253 759da720bdcb3a0e3f0b6b80325653d6704a56b3187871c5e763b1a2040f670e |
| internal/host/claimunit_migrate.go claimPredecessorPlacement authority E2 PROPOSED E50: Root accepts only the exact system.slice service path or systemd default instantiated-service slice for the canonical unit. 7c2248d3c2a76d2c46d813ab960dbc80095db5348a21bd1fc709633810a4be4a |
| -internal/host/claimunit_migrate.go claimUnitMaterializer.finishMigration authority E2 PROPOSED E50: Root resumes durable migration; exact definition identity gates replacement and the complete predecessor cgroup must be empty before materialization completes. PROPOSED E51: Root compares persistent operator-definition identity before stopping the predecessor and replacing its bytes. 3eca31ebde16e7beb00dd66aed088debd6ce0fbc25fada1511c0c4db01efd1c9 |
| -internal/host/claimunit_migrate.go claimUnitMaterializer.migration authority E2 PROPOSED E50: Root accepts an existing operator definition only with canonical digest consent and independently captured systemd invocation plus cgroup inode. f40694cd913fb9db331ec82e81209f120c2cd115ecbd4e1c0947f2a6c1973500 |
| +internal/host/claimunit_migrate.go claimUnitMaterializer.finishMigration authority E2 PROPOSED E50: Root resumes durable migration; exact definition identity gates replacement and the complete predecessor cgroup must be empty before materialization completes. PROPOSED E51: Root compares persistent operator-definition identity before stopping the predecessor and replacing its bytes. PROPOSED E52: Root independently stops the retained predecessor before parking its socket and replacing its definition; retry repeats the socket identity gate before recording completion. 925389d58a0c061f6999cacc5a28b9e80ab32b2713768fbdfcb5674ac5fdfa38 |
| +internal/host/claimunit_migrate.go claimUnitMaterializer.migration authority E2 PROPOSED E50: Root accepts an existing operator definition only with canonical digest consent and independently captured systemd invocation plus cgroup inode. PROPOSED E52: Root captures the operator socket peer and boot-local inode identity before saving migration intent; missing capture/park callbacks refuse. a4769ec9098d9d8a549e803dd025704d7b2fea1b1b8fa748eb620e32380ef0f7 |
| internal/host/claimunit_migrate.go claimUnitMigration.valid authority E2 PROPOSED E50: Root decoded migration must match its binding digest, definition identity and independently retained predecessor placement. e94154551a8a463be04b2f0591c01336cf9a2c18c6ac2211d1a38701844029e7 |
| internal/host/claimunit_migrate.go claimUnitPredecessor.valid authority E2 PROPOSED E50: Root validates retained predecessor unit, invocation and cgroup identity before a stop can be attempted. 6216b37e15df78ff7ec405158c9956013cf0db7318440dba500bc5f7f0025094 |
| internal/host/claimunit_migrate_kernel.go claimPredecessorKernel.capture authority E2 PROPOSED E50: Root brackets the complete predecessor cgroup identity/population read with matching systemd observations; foreign fragment or drop-in refuses capture. PROPOSED E51: Root records the kernel boot beside exact systemd invocation and cgroup identity. 5b7b188fa99b61b1034589cfea3871188f6c571cc40f10669a37c291b2763667 |
| @@ -538,3 +538,10 @@ internal/rootstate/persistent_linux.go PersistentIdentity authority E2 PROPOSED |
| internal/rootstate/state.go FileIdentity.Same authority E2 PROPOSED E51: Persistent equality requires filesystem, handle, inode, ownership, mode and mount topology; device/mount numbers compare only on the same boot. PROPOSED E51: Persistent equality requires filesystem, handle, inode, ownership, mode and mount topology; device/mount numbers compare only on the same boot. PROPOSED E51 clarification: within one boot devices remain exact; mount IDs compare within one mount namespace. A root namespace clone still requires identical filesystem, handle, inode, ownership and mount topology. d32f66f3ca64f96d9ad85c2d4b67ee6b69fd113100552f83df75c4ece8d8881e |
| @coverage internal/host/units d5cb4f1dbfb14212e7d032e47b2c078f2e34cb5edfad2d41afa5e00618a4e8e8 |
| internal/host/claimunit_template.go const claimBoundTemplate authority E2 PROPOSED E51: Root-only bound-unit template is held inside the claim renderer; ordinary bootstrap never enumerates or installs it. data-literal@27 9ff0f84071165eb2d642596f1c4b28241f872fea99f7bf8e3d77f51785ad270a |
| +internal/host/claimunit_migrate.go claimUnitMaterializer.finishSocketMigration authority E2 PROPOSED E52: Root requires a durable socket boot identity and the parking callback for flat sockets; other canonical shapes retain their existing path. 14ca575327ecd59c2015b8262da89913ea41518615dd31533268a754cc8a5e75 |
| +internal/host/claimunit_socket_migration_linux.go captureProductionClaimUnitSocket authority E2 PROPOSED E52: Root reads systemd MainPID and verifies the connected Unix peer against that process/descendants and the retained predecessor cgroup, with socket identity rechecked after connect. file:CommandContext@417 5d3534b2766ae2aeb2fddc7037647cfa3444de80d0e4dfff594d176fb73aeea0 |
| +internal/host/claimunit_socket_migration_linux.go claimUnitSocketAt authority E2 PROPOSED E52: Root reads nofollow socket metadata relative to the open directory; requires a single-link socket and retains boot, device, inode, owner and mode. file:Fstatat@120 05c0b8454a39eeed80bb3524f9eade9fa22fb4f6284a840eecffaffbf1a792ee |
| +internal/host/claimunit_socket_migration_linux.go parkProductionClaimUnitSocket authority E2 PROPOSED E52: Root opens its checked private runtime archive and parks only the canonical bound socket under a digest of the retained binding. file:Open@295 a479871ede2db7f5d5813192e3fe4a43ff8f4c4731266230a94dbd35c9762907 |
| +internal/host/claimunit_socket_migration_linux.go captureClaimUnitSocket authority E2 PROPOSED E52: Root opens the socket parent without following symlinks, then captures its nofollow identity. 01d268a02f01e3553f82e66be078a8c6f9dffa56272f2649766002aab69aaa2b |
| +internal/host/claimunit_socket_migration_linux.go parkClaimUnitSocket authority E2 PROPOSED E52: Root delegates socket parking to no-replace rename with identity verification. 3bc751b4b76d46fff1e02e07540688c4c1bc018213b0ab284564fbcdc6b07729 |
| +internal/host/claimunit_socket_migration_linux.go parkClaimUnitSocketWith authority E2 PROPOSED E52: Root requires exact same-boot socket identity before and after no-replace rename; on races preserves the replacement without overwriting a new source. Absence after independently verified predecessor stop permits replay; a replacement socket or archive refuses. c024aca802ed8a0009c68d992838cb636a03660c86cd58dab23e3f7b1e1b4ffa |