WIP claim Plan 14 track E checkpoint 54 (not for merge)
Named-unit replacement deferred until always-running startup works; real console and woodstar services pass under bound-unit containment (reserved restart, persistent state, sibling network refusal, edge restart, exact retirement).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+312
−15
internal/host/claimunit_production.go+6 −0internal/host/claimunit_startup_gate_root_linux_test.go+73 −0internal/host/plan14_console_binary_root_linux_test.go+12 −0internal/host/plan14_console_root_linux_test.go+15 −0internal/host/plan14_installed_launch_root_linux_test.go+74 −13internal/host/plan14_woodstar_binary_root_linux_test.go+10 −0internal/host/plan14_woodstar_root_linux_test.go+76 −0internal/provision/testdata/plan14-root-reads-e.tsv+4 −2scripts/plan14-console-root-runner.sh+42 −0
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 287.019s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.710s
ok github.com/carlosframework/platform/cmd/moveprobe 93.100s
ok github.com/carlosframework/platform/cmd/zddprobe 0.712s
ok github.com/carlosframework/platform/internal/activator 249.619s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.458s
ok github.com/carlosframework/platform/internal/apperr 0.074s
ok github.com/carlosframework/platform/internal/applog 0.127s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.522s
? 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.003s
ok github.com/carlosframework/platform/internal/cgroup 0.710s
ok github.com/carlosframework/platform/internal/changelog 0.302s
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.271s
? 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 6.552s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.022s
ok github.com/carlosframework/platform/internal/dbverify 0.008s
ok github.com/carlosframework/platform/internal/dnsrec 0.006s
ok github.com/carlosframework/platform/internal/docsite 0.011s
ok github.com/carlosframework/platform/internal/domains 2.420s
ok github.com/carlosframework/platform/internal/doorkey 0.011s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 26.670s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.016s
ok github.com/carlosframework/platform/internal/fleetapi 0.139s
ok github.com/carlosframework/platform/internal/gate 1.665s
ok github.com/carlosframework/platform/internal/genselect 0.027s
ok github.com/carlosframework/platform/internal/geo 0.030s
ok github.com/carlosframework/platform/internal/geoip 0.045s
ok github.com/carlosframework/platform/internal/historyreadback 0.163s
ok github.com/carlosframework/platform/internal/host 143.665s
ok github.com/carlosframework/platform/internal/hostactors 0.273s
ok github.com/carlosframework/platform/internal/hostbox 2.240s
ok github.com/carlosframework/platform/internal/hostclaim 0.493s
ok github.com/carlosframework/platform/internal/hostevidence 0.012s
ok github.com/carlosframework/platform/internal/hostexec 4.911s
ok github.com/carlosframework/platform/internal/hostfence 0.035s
ok github.com/carlosframework/platform/internal/hostfiles 10.517s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.990s
ok github.com/carlosframework/platform/internal/hostops 1.563s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.013s
ok github.com/carlosframework/platform/internal/hostquarantine 0.012s
ok github.com/carlosframework/platform/internal/hostrelocate 0.446s
ok github.com/carlosframework/platform/internal/hostresolve 0.127s
ok github.com/carlosframework/platform/internal/hostwrite 0.012s
ok github.com/carlosframework/platform/internal/infraview 0.012s
ok github.com/carlosframework/platform/internal/instancectl 5.253s
ok github.com/carlosframework/platform/internal/instanceexec 2.736s
ok github.com/carlosframework/platform/internal/instancefs 0.017s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.020s
ok github.com/carlosframework/platform/internal/layout 1.515s
ok github.com/carlosframework/platform/internal/lease 9.803s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.021s
ok github.com/carlosframework/platform/internal/ledger 0.009s
ok github.com/carlosframework/platform/internal/mail 0.149s
ok github.com/carlosframework/platform/internal/maintenance 0.086s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.507s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.062s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 20.993s
ok github.com/carlosframework/platform/internal/moves/archive 0.753s
ok github.com/carlosframework/platform/internal/objstore 0.172s
ok github.com/carlosframework/platform/internal/ops 0.167s
ok github.com/carlosframework/platform/internal/park 0.202s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 17.037s
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.467s
ok github.com/carlosframework/platform/internal/relay 7.238s
ok github.com/carlosframework/platform/internal/release 22.321s
ok github.com/carlosframework/platform/internal/rollout 3.751s
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.010s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.565s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.514s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.584s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.030s
ok github.com/carlosframework/platform/internal/status 0.039s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.192s
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.003s
ok github.com/carlosframework/platform/internal/vet 1.540s
=== 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.021s
=== RUN TestT4StorageProbeMinio
2026/10/16 00:44:24 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEDBE1CFCE6CAB, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go| index 28be4f429..ae5bc833e 100644 |
| --- a/internal/host/claimunit_production.go |
| +++ b/internal/host/claimunit_production.go |
| @@ -179,6 +179,12 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl |
| sort.Strings(hosts) |
| for _, host := range hosts { |
| shape := shapes[host] |
| + // Named services must keep their operator definition until a reserved |
| + // boot/adoption controller can keep them running independently of edge. |
| + if shape.Backing == "" { |
| + log.Print(hostbox.ErrUnsupported) |
| + continue |
| + } |
| if !claimHexDigest(shape.Program) { |
| log.Print(os.ErrPermission) |
| continue |
diff --git a/internal/host/claimunit_startup_gate_root_linux_test.go b/internal/host/claimunit_startup_gate_root_linux_test.go| new file mode 100644 |
| index 000000000..ff7f9f5b8 |
| --- /dev/null |
| +++ b/internal/host/claimunit_startup_gate_root_linux_test.go |
| @@ -0,0 +1,73 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func plan14ProductionStartupDefersReplacement(t *testing.T, original *ClaimLaunchRoot, material *claimUnitMaterializer, binding claimUnitBinding, ctl func(context.Context, ...string) (string, error)) { |
| + t.Helper() |
| + launch := *original |
| + before, id, err := material.read(binding.Shape.Unit) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + invocation, err := ctl(t.Context(), "show", binding.Shape.Unit, "-p", "InvocationID", "--value") |
| + if err != nil || invocation == "" { |
| + t.Fatal("missing live predecessor", err) |
| + } |
| + oldOpen, oldMaterials, oldShapes := openClaimUnitBindings, openClaimUnitMaterializations, productionClaimUnitShapes |
| + defer func() { |
| + openClaimUnitBindings, openClaimUnitMaterializations, productionClaimUnitShapes = oldOpen, oldMaterials, oldShapes |
| + }() |
| + openClaimUnitBindings = func() (rootstate.Store, error) { return launch.unitBindings.state, nil } |
| + openClaimUnitMaterializations = func() (rootstate.Store, error) { return material.state, nil } |
| + productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { |
| + return map[string]claimUnitShape{binding.Shape.Host: binding.Shape}, nil |
| + } |
| + base := launch.Units.(instancectl.ClaimUnits) |
| + base.BoundHost, base.BoundUnit = "", "" |
| + for _, retained := range []bool{true, false} { |
| + if !retained { |
| + if err := launch.unitBindings.state.Remove(binding.Shape.Host + ".json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + launch.Units = base |
| + wrapped := ClaimRootWithLaunch{Launch: &launch} |
| + wrapped.Root.Stop.Coordinator.Kernel = claimUnitStopKernel{Units: base} |
| + assembly := &ClaimAssembly{} |
| + if err := bindProductionClaimUnits(t.Context(), &wrapped, assembly); err != nil { |
| + t.Fatal("named unit blocked control startup", err) |
| + } |
| + if binding.Shape.Backing == "unit" { |
| + if err := material.verify(binding); err != nil { |
| + t.Fatal("named startup gate blocked backing-unit materialization", err) |
| + } |
| + return |
| + } |
| + after, afterID, err := material.read(binding.Shape.Unit) |
| + if err != nil || !bytes.Equal(before, after) || !id.Same(afterID) { |
| + t.Fatal("startup replaced named unit before reserved startup works", err) |
| + } |
| + afterInvocation, err := ctl(t.Context(), "show", binding.Shape.Unit, "-p", "InvocationID", "--value") |
| + if err != nil || afterInvocation != invocation { |
| + t.Fatal("startup stopped live predecessor", err) |
| + } |
| + if _, err := material.load(binding.Shape.Host); !os.IsNotExist(err) { |
| + t.Fatal("startup retained named replacement intent", err) |
| + } |
| + if !retained { |
| + if _, err := launch.unitBindings.read(binding.Shape.Host); !os.IsNotExist(err) { |
| + t.Fatal("startup established named authority before startup works", err) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan14_console_binary_root_linux_test.go b/internal/host/plan14_console_binary_root_linux_test.go| new file mode 100644 |
| index 000000000..c2a8679eb |
| --- /dev/null |
| +++ b/internal/host/plan14_console_binary_root_linux_test.go |
| @@ -0,0 +1,12 @@ |
| +//go:build linux && rootrunner && realconsole |
| + |
| +package host |
| + |
| +import _ "embed" |
| + |
| +// Built from cmd/console by scripts/plan14-console-root-runner.sh. |
| +// |
| +//go:embed plan14-console-runner.bin |
| +var plan14EmbeddedConsole []byte |
| + |
| +func init() { plan14ConsoleExecutable = plan14EmbeddedConsole } |
diff --git a/internal/host/plan14_console_root_linux_test.go b/internal/host/plan14_console_root_linux_test.go| index 99396de7a..a8ceb2394 100644 |
| --- a/internal/host/plan14_console_root_linux_test.go |
| +++ b/internal/host/plan14_console_root_linux_test.go |
| @@ -3,6 +3,8 @@ |
| package host |
| import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| "encoding/json" |
| "fmt" |
| "io" |
| @@ -10,6 +12,7 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + "path/filepath" |
| "strings" |
| "sync/atomic" |
| "testing" |
| @@ -119,3 +122,15 @@ func plan14ConsoleProgram(t *testing.T, host, endpoint string) (claimUnitProgram |
| } |
| } |
| } |
| + |
| +func plan14ConsoleBinaryProgram(t *testing.T, p claimUnitProgram) claimUnitProgram { |
| + t.Helper() |
| + pub, _, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p.Args = []string{"serve"} |
| + p.Env = p.Env[1:] |
| + p.Env = append(p.Env, "CONSOLE_ORIGIN=http://tenant", "CARLOS_RELEASE_PUBKEY="+base64.StdEncoding.EncodeToString(pub), "CARLOS_DEPLOYMENT_DIR="+filepath.Join(provision.InstanceDir("/data", p.Host), "deployment"), "AWS_EC2_METADATA_DISABLED=true") |
| + return p |
| +} |
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go| index 2310be902..69ecf2df3 100644 |
| --- a/internal/host/plan14_installed_launch_root_linux_test.go |
| +++ b/internal/host/plan14_installed_launch_root_linux_test.go |
| @@ -107,11 +107,39 @@ func TestPlan14BoundNetworkEdgeRootRunner(t *testing.T) { |
| } |
| } |
| -func TestPlan14RealConsoleRootRunner(t *testing.T) { |
| +func TestPlan14ConsoleLibraryRootRunner(t *testing.T) { |
| plan14InstalledClaimLaunch(t, "console:materialized:tcp") |
| } |
| +var plan14ConsoleExecutable, plan14WoodstarExecutable []byte |
| + |
| +func TestPlan14RealConsoleRootRunner(t *testing.T) { |
| + if len(plan14ConsoleExecutable) == 0 { |
| + t.Fatal("build the real executable with scripts/plan14-console-root-runner.sh") |
| + } |
| + plan14InstalledClaimLaunch(t, "binary:console:materialized:tcp") |
| +} |
| + |
| +func TestPlan14RealWoodstarRootRunner(t *testing.T) { |
| + if len(plan14WoodstarExecutable) == 0 { |
| + t.Fatal("set PLAN14_WOODSTAR_SOURCE when running scripts/plan14-console-root-runner.sh") |
| + } |
| + plan14InstalledClaimLaunch(t, "woodstar:materialized:tcp") |
| +} |
| + |
| +func TestPlan14NamedStartupDefersReplacementRootRunner(t *testing.T) { |
| + for _, mode := range []string{"tcp", "socket-program", "backing-program"} { |
| + t.Run(mode, func(t *testing.T) { plan14InstalledClaimLaunch(t, "defer:migrated:materialized:"+mode) }) |
| + } |
| +} |
| + |
| func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| + woodstar := strings.HasPrefix(boundUnit, "woodstar:") |
| + boundUnit = strings.TrimPrefix(boundUnit, "woodstar:") |
| + deferReplacement := strings.HasPrefix(boundUnit, "defer:") |
| + boundUnit = strings.TrimPrefix(boundUnit, "defer:") |
| + consoleBinary := strings.HasPrefix(boundUnit, "binary:") |
| + boundUnit = strings.TrimPrefix(boundUnit, "binary:") |
| realConsole := strings.HasPrefix(boundUnit, "console:") |
| boundUnit = strings.TrimPrefix(boundUnit, "console:") |
| isolation := strings.HasPrefix(boundUnit, "isolation:") |
| @@ -191,9 +219,25 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if tcpMode { |
| program.Env = append(program.Env, "PLAN14_TENANT_ADDR="+endpoint) |
| } |
| - var checkConsole func(*http.Client) |
| + var checkService func(*http.Client) |
| if realConsole { |
| - program, checkConsole = plan14ConsoleProgram(t, host, endpoint) |
| + program, checkService = plan14ConsoleProgram(t, host, endpoint) |
| + if consoleBinary { |
| + if len(plan14ConsoleExecutable) == 0 { |
| + t.Fatal("real console executable missing") |
| + } |
| + program = plan14ConsoleBinaryProgram(t, program) |
| + } |
| + } |
| + if woodstar { |
| + if len(plan14WoodstarExecutable) == 0 { |
| + t.Fatal("real Woodstar executable missing") |
| + } |
| + program, checkService = plan14WoodstarProgram(t, host, endpoint) |
| + } |
| + healthURL := "http://tenant/" |
| + if woodstar { |
| + healthURL += "healthz" |
| } |
| programRaw, _ := json.Marshal(program) |
| programSum := sha256.Sum256(programRaw) |
| @@ -438,6 +482,10 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| } |
| } |
| } |
| + if deferReplacement { |
| + plan14ProductionStartupDefersReplacement(t, f.root, material, binding, ctl) |
| + return |
| + } |
| if err := material.install(t.Context(), binding); err != nil { |
| debug, _ := ctl(t.Context(), "show", unit, "-p", "ActiveState,InvocationID,ControlGroup,FragmentPath,DropInPaths") |
| t.Fatalf("migration/materialization: %v; unit: %s", err, debug) |
| @@ -497,6 +545,13 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + runnerBody := body |
| + if consoleBinary { |
| + body = plan14ConsoleExecutable |
| + } |
| + if woodstar { |
| + body = plan14WoodstarExecutable |
| + } |
| for _, v := range []string{"v1", "v3"} { |
| if err := release.StageArtifact("/opt/carlos", rec.Account, v, rec.App, body); err != nil { |
| t.Fatal(err) |
| @@ -552,7 +607,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| lock := &release.ReleaseExclusion{Open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "release"), true, false) }} |
| checkSibling := func() {} |
| if programMode { |
| - checkSibling = plan14BoundSibling(t, body, ctl, uid, gid, lock) |
| + checkSibling = plan14BoundSibling(t, runnerBody, ctl, uid, gid, lock) |
| checkSibling() |
| } |
| if err := f.req.Validate(); err != nil { |
| @@ -583,7 +638,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| }} |
| defer transport.CloseIdleConnections() |
| client := &http.Client{Transport: transport, Timeout: 3 * time.Second} |
| - resp, err := client.Get("http://tenant/") |
| + resp, err := client.Get(healthURL) |
| if err != nil { |
| t.Fatal("installed tenant did not serve", err) |
| } |
| @@ -592,10 +647,10 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if resp.StatusCode != 200 { |
| t.Fatal(resp.Status) |
| } |
| - if realConsole { |
| - checkConsole(client) |
| + if checkService != nil { |
| + checkService(client) |
| } |
| - if isolation || realConsole { |
| + if isolation || realConsole || woodstar { |
| 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")) |
| @@ -612,7 +667,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| } |
| } |
| for _, name := range func() []string { |
| - if realConsole { |
| + if realConsole || woodstar { |
| return nil |
| } |
| return []string{"console", "sibling"} |
| @@ -649,7 +704,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if err != nil || before == "" || after != before { |
| t.Fatal("edge restart changed bound invocation", before, after, err) |
| } |
| - response, err := client.Get("http://tenant/") |
| + response, err := client.Get(healthURL) |
| if err != nil { |
| t.Fatal("edge restart stopped bound service", err) |
| } |
| @@ -683,6 +738,12 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| t.Fatal(records, err) |
| } |
| v1 := records[0] |
| + if consoleBinary || woodstar { |
| + running, err := os.ReadFile(fmt.Sprintf("/proc/%d/exe", v1.Process.TenantPID)) |
| + if err != nil || sha256.Sum256(running) != sha256.Sum256(body) { |
| + t.Fatal("running service is not the built executable", err) |
| + } |
| + } |
| if v1.Process.PID != response.Grant.PID || v1.Process.Containment != 1 || v1.Process.TenantPID == 0 { |
| t.Fatal("missing production process evidence", v1) |
| } |
| @@ -752,7 +813,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if restarted.Grant.UnitInvocationID == response.Grant.UnitInvocationID { |
| t.Fatal("restart reused invocation") |
| } |
| - resp, err := client.Get("http://tenant/") |
| + resp, err := client.Get(healthURL) |
| if err != nil { |
| t.Fatal("restarted endpoint", err) |
| } |
| @@ -761,8 +822,8 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) { |
| if resp.StatusCode != 200 { |
| t.Fatal(resp.Status) |
| } |
| - if realConsole { |
| - checkConsole(client) |
| + if checkService != nil { |
| + checkService(client) |
| } |
| stop.Reservation = &next |
| if _, err := f.root.Serve(t.Context(), f.peer, stop); err != nil { |
diff --git a/internal/host/plan14_woodstar_binary_root_linux_test.go b/internal/host/plan14_woodstar_binary_root_linux_test.go| new file mode 100644 |
| index 000000000..cd996873f |
| --- /dev/null |
| +++ b/internal/host/plan14_woodstar_binary_root_linux_test.go |
| @@ -0,0 +1,10 @@ |
| +//go:build linux && rootrunner && realwoodstar |
| + |
| +package host |
| + |
| +import _ "embed" |
| + |
| +//go:embed plan14-woodstar-runner.bin |
| +var plan14EmbeddedWoodstar []byte |
| + |
| +func init() { plan14WoodstarExecutable = plan14EmbeddedWoodstar } |
diff --git a/internal/host/plan14_woodstar_root_linux_test.go b/internal/host/plan14_woodstar_root_linux_test.go| new file mode 100644 |
| index 000000000..7131e7279 |
| --- /dev/null |
| +++ b/internal/host/plan14_woodstar_root_linux_test.go |
| @@ -0,0 +1,76 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "database/sql" |
| + "encoding/json" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func plan14WoodstarProgram(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) |
| + io.WriteString(w, `<html><head><title>runner upstream</title></head></html>`) |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + upstream := "http://" + ln.Addr().String() |
| + dbPath := provision.NestedDBPath("/data", host) |
| + p := claimUnitProgram{Schema: 1, Host: host, Args: []string{"-addr", endpoint, "-db", dbPath, "-allow-private"}} |
| + var savedKey string |
| + var savedFetch int64 |
| + return p, func(c *http.Client) { |
| + t.Helper() |
| + resp, err := c.Get("http://tenant/unfurl?url=" + url.QueryEscape(upstream)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var card map[string]any |
| + err = json.NewDecoder(resp.Body).Decode(&card) |
| + resp.Body.Close() |
| + if err != nil || resp.StatusCode != http.StatusOK || card["ok"] != false || contacted.Load() != 0 { |
| + t.Fatal("Woodstar containment allowed sibling fetch", resp.Status, card, contacted.Load(), err) |
| + } |
| + db, err := sql.Open("sqlite", dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Close() |
| + var key string |
| + if err := db.QueryRow("SELECT value FROM config WHERE key = 'img_sign_key'").Scan(&key); err != nil || len(key) != 64 { |
| + t.Fatal("Woodstar did not persist its signing key", err) |
| + } |
| + var fetched int64 |
| + if err := db.QueryRow("SELECT fetched_at FROM cards WHERE url = ? AND ok = 0", upstream).Scan(&fetched); err != nil || fetched <= 0 { |
| + t.Fatal("Woodstar did not persist its cache", err, fetched) |
| + } |
| + if savedKey != "" && (savedKey != key || savedFetch != fetched) { |
| + t.Fatal("Woodstar restart lost persistent state") |
| + } |
| + savedKey, savedFetch = key, fetched |
| + control := &http.Client{Timeout: time.Second} |
| + resp, err = control.Get(upstream) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if contacted.Swap(0) != 1 { |
| + t.Fatal("upstream control did not connect") |
| + } |
| + } |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index 573333c68..55af99dd2 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. 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 5036bd72c90bf1e8416ef729277fee1ac422f205d95c6e6e965ab9508a67bddc |
| +internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. PROPOSED E50: Root installs retained-placement stop/retirement callbacks, resumes saved retirement before binding, and isolates each configuration/program/materialization failure. Initial binding reads occur outside host exclusion then local owner is checked inside. Readiness looks up the reserved host, not a startup list. PROPOSED E51: Root supplies the kernel boot reader to predecessor capture and stop; startup retirement replay remains ahead of per-unit binding. PROPOSED E52: Root wires peer-verified flat-socket capture and exact retained socket parking into canonical unit migration; no-census path unchanged. PROPOSED E54: root skips named-unit binding/materialization until reserved always-running startup exists; retirement recovery still precedes that gate, and backing=unit retains its existing path. Root witness rejects replacement of live TCP and socket predecessors. file:Read@1150 f8cc464409153f09a65a429fabb17dc52d58df5ae170a2eab41e58f81ea04204 |
| +@coverage internal/host 2c955ee613a768c0643e0dfacb0af22bc91b2463bf3cf9eabfc47fa6470cf71b |
| # 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 |
| @@ -565,3 +565,5 @@ internal/hostbox/inventory_identity_linux.go VerifyInventoryIdentity authority E |
| internal/hostbox/journalservice.go journalRequestHash authority E2,J2,J4 PROPOSED E53: Executing identity root (state capture also runs as carlos); boot-local device and mount numbers are checked through retained filesystem, handle, inode, owner and topology evidence; no old-boot recapture grants. Tests: TestPlan14InventoryIdentityBootBoundary, TestRootRebootInventoryIdentityPhase1/2. 99703d244e91b55863a034eb7b8b7ecae50c1ca092ef9dba9594fdaff088b6e3 |
| internal/hostbox/typedpark_linux.go sameArtifactAt authority E2,J2,J4 PROPOSED E53: Executing identity root (state capture also runs as carlos); boot-local device and mount numbers are checked through retained filesystem, handle, inode, owner and topology evidence; no old-boot recapture grants. Tests: TestPlan14InventoryIdentityBootBoundary, TestRootRebootInventoryIdentityPhase1/2. a0e3b899df85823cc4549d49f6577a7eff1549b7a0e4f1b53f4a23573ee4b948 |
| internal/rootstate/authoritydir_linux.go OpenAuthorityDirectory authority E2,J2,J4 PROPOSED E53: Executing identity root; generation pins retain checked root ancestry and persistent filesystem/handle/inode/owner/topology evidence across boot; legacy device-only pins refuse a different boot. Tests: TestPlan14GenerationLegacyPinBootBoundary, TestRootRebootGenerationPinIdentityPhase1/2. file:Open@167,file:Openat@524 52a957fff0ec22967acc9627456cf2f5289a0563ffa94bf92f4ad59961073d7a |
| +@coverage scripts a620f2df4560d164dae133f136a84f61457cd4e4ac65f0e04bb27c1d9324fd21 |
| +scripts/plan14-console-root-runner.sh external diagnostic E2 PROPOSED E54: builder identity; packages locally built console and optional archived Woodstar executable into rootrunner-only tests, invokes the separately authorized dev runner and removes its own artifacts. Not installed or called by production. file:external@0 d4b0da2675717679695cbe051b4395047cf0f0e31999cec825351f951a721878 |
diff --git a/scripts/plan14-console-root-runner.sh b/scripts/plan14-console-root-runner.sh| new file mode 100644 |
| index 000000000..80ac60234 |
| --- /dev/null |
| +++ b/scripts/plan14-console-root-runner.sh |
| @@ -0,0 +1,42 @@ |
| +#!/usr/bin/env bash |
| +set -euo pipefail |
| +test "$#" -ge 1 |
| +runner=$(realpath "$1") |
| +shift |
| +cd "$(dirname "$0")/.." |
| +worktree=$(pwd) |
| +artifact=internal/host/plan14-console-runner.bin |
| +woodstar_artifact=internal/host/plan14-woodstar-runner.bin |
| +test ! -e "$artifact" |
| +test ! -e "$woodstar_artifact" |
| +scratch=$(mktemp -d) |
| +owned_artifacts=() |
| +trap 'rm -f -- "${owned_artifacts[@]}"; rm -rf -- "$scratch"' EXIT |
| +stage_artifact() { |
| + python3 - "$1" "$2" <<'PYTHON' |
| +import os, shutil, sys |
| +with open(sys.argv[1], "rb") as source, open(sys.argv[2], "xb") as target: |
| + try: |
| + shutil.copyfileobj(source, target) |
| + except BaseException: |
| + os.unlink(sys.argv[2]) |
| + raise |
| +PYTHON |
| + owned_artifacts+=("$2") |
| +} |
| +export GOFLAGS=-buildvcs=false |
| +unset GOCACHE |
| +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 nice -n 10 go build -o "$scratch/console" ./cmd/console |
| +stage_artifact "$scratch/console" "$artifact" |
| +tags=rootrunner,realconsole |
| +sha256sum "$artifact" |
| +if [ -n "${PLAN14_WOODSTAR_SOURCE:-}" ]; then |
| + mkdir "$scratch/woodstar" |
| + git -C "$PLAN14_WOODSTAR_SOURCE" rev-parse HEAD |
| + git -C "$PLAN14_WOODSTAR_SOURCE" archive HEAD go.mod go.sum cmd internal | tar -x -C "$scratch/woodstar" |
| + (cd "$scratch/woodstar" && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 nice -n 10 go build -mod=readonly -o "$scratch/unfurl" ./cmd/unfurl) |
| + stage_artifact "$scratch/unfurl" "$woodstar_artifact" |
| + sha256sum "$woodstar_artifact" |
| + tags=$tags,realwoodstar |
| +fi |
| +nice -n 10 python3 -u "$runner" --worktree "$worktree" --pkg ./internal/host --tags "$tags" "$@" |