carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 9739b80205b8143006fa940863ab51bed0c161f9 parent 1bc551a
11 files changed, +799 −7
  • internal/host/claimunit_materialize.go +2 −0
  • internal/host/claimunit_migrate.go +32 −0
  • internal/host/claimunit_migration_reboot_root_linux_test.go +198 −0
  • internal/host/claimunit_production.go +1 −0
  • internal/host/claimunit_socket_migration_linux.go +140 −0
  • internal/host/claimunit_socket_migration_other.go +15 −0
  • internal/host/claimunit_socket_migration_test.go +220 −0
  • internal/host/plan14_console_root_linux_test.go +121 −0
  • internal/host/plan14_generation_registry_root_linux_test.go +4 −0
  • internal/host/plan14_installed_launch_root_linux_test.go +55 −3
  • internal/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