WIP files seed checkpoint 27 (not for merge)
Second isolated launch PID race fixed; two-host isolation across retirement and reboot; whole-executor kill and rerun; Task 7 runbook strings drafted for review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 files changed,
+1182
−82
internal/host/claimfilesseed_cli_rootrunner_linux_test.go+315 −0internal/host/claimfilesseed_crash_rootrunner_linux_test.go+3 −0internal/host/claimfilesseed_integration_linux_test.go+5 −2internal/host/claimfilesseed_isolated_rootrunner_linux_test.go+92 −12internal/host/claimfilesseed_lifetime_rootrunner_linux_test.go+181 −0internal/host/claimfilesseed_live_rootrunner_linux_test.go+10 −1internal/host/claimfilesseed_reboot_rootrunner_linux_test.go+294 −0internal/host/claimfilesseed_restore_linux_test.go+58 −45internal/host/claimfilesseed_twohost_rootrunner_linux_test.go+157 −0internal/host/claimretryrecovery_test.go+18 −6internal/host/claimservinglegacyroot_linux_test.go+6 −2internal/host/claimservingpeer_rootrunner_linux_test.go+8 −0internal/host/claimservingretire_linux_test.go+3 −3internal/host/plan12_restore_linux_test.go+1 −1internal/instancectl/claimexec.go+16 −3internal/instancectl/claimexec_rev3_linux_test.go+10 −5internal/instancectl/claimunit.go+1 −1internal/provision/testdata/plan14-root-reads-seed.tsv+4 −1
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 282.185s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.544s
ok github.com/carlosframework/platform/cmd/moveprobe 93.157s
ok github.com/carlosframework/platform/cmd/zddprobe 0.702s
ok github.com/carlosframework/platform/internal/activator 250.773s
ok github.com/carlosframework/platform/internal/agentsock 0.014s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.020s
ok github.com/carlosframework/platform/internal/appconfig 2.438s
ok github.com/carlosframework/platform/internal/apperr 0.069s
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.992s
? 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.015s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.691s
ok github.com/carlosframework/platform/internal/changelog 0.300s
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 385.576s
? 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.977s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.312s
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.591s
? 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.129s
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.019s
ok github.com/carlosframework/platform/internal/geoip 0.034s
ok github.com/carlosframework/platform/internal/host 157.708s
ok github.com/carlosframework/platform/internal/hostactors 0.467s
ok github.com/carlosframework/platform/internal/hostbox 2.281s
ok github.com/carlosframework/platform/internal/hostclaim 0.483s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 4.695s
ok github.com/carlosframework/platform/internal/hostfence 0.039s
ok github.com/carlosframework/platform/internal/hostfiles 10.486s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 0.969s
ok github.com/carlosframework/platform/internal/hostops 1.532s
? 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.433s
ok github.com/carlosframework/platform/internal/hostresolve 0.124s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.001s
ok github.com/carlosframework/platform/internal/instancectl 4.165s
ok github.com/carlosframework/platform/internal/instanceexec 2.721s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.510s
ok github.com/carlosframework/platform/internal/lease 9.797s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.017s
ok github.com/carlosframework/platform/internal/ledger 0.014s
ok github.com/carlosframework/platform/internal/mail 0.148s
ok github.com/carlosframework/platform/internal/maintenance 0.075s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.505s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.066s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 20.922s
ok github.com/carlosframework/platform/internal/moves/archive 0.763s
ok github.com/carlosframework/platform/internal/objstore 0.160s
ok github.com/carlosframework/platform/internal/ops 0.174s
ok github.com/carlosframework/platform/internal/park 0.197s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 15.766s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.910s
ok github.com/carlosframework/platform/internal/relay 7.233s
ok github.com/carlosframework/platform/internal/release 19.702s
ok github.com/carlosframework/platform/internal/rollout 3.650s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.024s
ok github.com/carlosframework/platform/internal/runs 0.569s
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.448s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 8.889s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.354s
ok github.com/carlosframework/platform/internal/status 0.037s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.216s
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.002s
ok github.com/carlosframework/platform/internal/vet 1.535s
=== 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: 46989474 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 17:56:03 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC59926FAF3E7, 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/claimfilesseed_cli_rootrunner_linux_test.go b/internal/host/claimfilesseed_cli_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..97ca3440a |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_cli_rootrunner_linux_test.go |
| @@ -0,0 +1,315 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "net" |
| + "os" |
| + "os/exec" |
| + "os/user" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/controlsock" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type seedCLIConfiguration struct { |
| + Actor hostbox.Actor |
| + Claims, Executable string |
| +} |
| + |
| +func TestRootFilesSeedDelegatedOneShot(t *testing.T) { |
| + runServingParkPeerNamespace(t, false, false, "seed-cli") |
| +} |
| + |
| +func rootFilesSeedDelegatedCLI(t *testing.T, binary string) { |
| + for _, p := range []string{"/data", "/var/lib/carlos-claims", "/run/carlos-control", "/run/carlos", "/opt/carlos"} { |
| + if err := os.Mkdir(p, 0755); err != nil { |
| + t.Fatal("existing CLI fixture", p, err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(p) }) |
| + } |
| + var captured *servingPreparation |
| + var executor *filesSeedExecutor |
| + capture := filesSeedLiveWitness{seed: func(_ *testing.T, p *servingPreparation, e *filesSeedExecutor) { captured, executor = p, e }} |
| + runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", []bool{true}, []filesSeedLiveWitness{capture}) |
| + f := captured.f |
| + f.actor.ControlBucket = "deployment" |
| + cfg := seedCLIConfiguration{Actor: f.actor, Claims: "/data/cli-claims", Executable: "/opt/carlos/versions/fixture/app"} |
| + claims := release.NewDirStore(cfg.Claims) |
| + keys, err := f.f.claims.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + b, _, err := f.f.claims.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := claims.Put(t.Context(), key, b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.f.claims, f.f.evidence = claims, claims |
| + f.f.guard.Claims = hostguard.StoreReader{Store: claims} |
| + f.f.guard.LockDir = "" |
| + f.p.root.Stop.Coordinator.Claims = hostguard.StoreReader{Store: claims} |
| + f.p.root.Stop.Coordinator.Guard = f.f.guard |
| + f.p.root.Stop.Coordinator.Evidence = claims |
| + f.p.root.Stop.Coordinator.Now = time.Now |
| + production, err := NewProductionClaimStopHandler(f.p.root.Stop.Coordinator, f.f.journal, productionStopControllers{}, f.p.root.Roots) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.p.root.Stop = production.(ClaimStopHTTP) |
| + // Initial installation/roster and the no-process kernel census are fixtures. |
| + f.f.kernel.noProcess = true |
| + f.p.root.Stop.Coordinator.Kernel = f.f.kernel |
| + f.p.root.Stop.Coordinator.RetainNoProcess = f.p.retainLegacyNoProcess |
| + h := servingTransportRoot(t, f) |
| + capability := newLaunchFixture(t, hostclaim.Op{Kind: hostclaim.KindServing, Exec: f.actor.Box}).root |
| + capability.Authenticate = h.Stop.Authenticate |
| + capability.SeedController = verifyFilesSeedDelegation |
| + listener, err := net.Listen("unix", controlsock.Path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(controlsock.Path, 0666); err != nil { |
| + t.Fatal(err) |
| + } |
| + server := &instancectl.Server{ClaimStop: ClaimRootWithLaunch{Root: h, Launch: capability}, PeerOK: func(net.Conn) error { return nil }, Logf: t.Logf} |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan error, 1) |
| + go func() { done <- server.Serve(ctx, listener) }() |
| + t.Cleanup(func() { |
| + cancel() |
| + if err := <-done; err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + reg, err := registry.Open("/data/registry.db") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + row, err := executor.registry.Get(recoveryHost) |
| + if err != nil || row == nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(*row); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + if err := os.MkdirAll(filepath.Dir(cfg.Executable), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + image, err := os.ReadFile(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(cfg.Executable, image, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(cfg) |
| + if err := os.WriteFile("/data/seed-cli.json", raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + account, err := user.Lookup("carlos") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + uid, _ := strconv.Atoi(account.Uid) |
| + gid, _ := strconv.Atoi(account.Gid) |
| + seedCLISharedLocks(t, "/run/carlos/hostguard", uid, gid) |
| + if err := filepath.Walk("/data", func(path string, info os.FileInfo, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + return os.Chown(path, uid, gid) |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err = unitsFS.ReadFile("units/carlos-edge.service") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var lines []string |
| + for _, line := range strings.Split(string(raw), "\n") { |
| + if strings.HasPrefix(line, "Requires=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "Wants=") || strings.HasPrefix(line, "ExecStart=") || strings.HasPrefix(line, "ExecReload=") || strings.HasPrefix(line, "EnvironmentFile=") || strings.HasPrefix(line, "Restart=") { |
| + continue |
| + } |
| + lines = append(lines, line) |
| + } |
| + lines = append(lines, "[Service]", "Type=oneshot", "RemainAfterExit=yes", "Restart=no", "TimeoutStartSec=60s", "TimeoutStopSec=45s", "ExecStart="+binary+" -test.run=^TestRootFilesSeedCLIPayload$ -test.v -test.timeout=50s", "Environment=FILES_SEED_CLI=1 TMPDIR=/data CARLOS_HOST_CLAIM=1 CARLOS_HOST_CLAIM_ORIGINS=1 CARLOS_DATA_DIR=/data CARLOS_DEPLOYMENT_DIR=/data/cli-claims AWS_REGION=eu-west-1 AWS_DEFAULT_REGION=eu-west-1 AWS_ACCESS_KEY_ID=test-key AWS_SECRET_ACCESS_KEY=test-secret AWS_EC2_METADATA_DISABLED=true CARLOS_S3_ENDPOINT="+os.Getenv("CARLOS_S3_ENDPOINT"), "IPAddressDeny=any", "IPAddressAllow=127.0.0.1") |
| + unit := "/run/systemd/system/carlos-edge.service" |
| + file, err := os.OpenFile(unit, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = file.WriteString(strings.Join(lines, "\n") + "\n") |
| + file.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + exec.Command("systemctl", "stop", "carlos-edge.service").Run() |
| + os.Remove(unit) |
| + exec.Command("systemctl", "daemon-reload").Run() |
| + }) |
| + servingRunnerSystemctl(t, "daemon-reload") |
| + run := func() { |
| + out, err := exec.CommandContext(t.Context(), "systemctl", "start", "carlos-edge.service").CombinedOutput() |
| + logs, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager").CombinedOutput() |
| + t.Log(string(logs)) |
| + if err != nil { |
| + t.Fatal("delegated CLI", err, string(out)) |
| + } |
| + state, err := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "ExecMainStatus", "-p", "Result", "-p", "SubState").Output() |
| + if err != nil || !strings.Contains(string(state), "ExecMainStatus=0") || !strings.Contains(string(state), "Result=success") { |
| + t.Fatal("one-shot status", string(state), err) |
| + } |
| + } |
| + run() |
| + observed := f.f.read() |
| + if observed.Claim.Origins == nil || observed.Claim.Holder.WakeRetry == nil || observed.Claim.Holder.WakeRetry.AttemptsReserved != 1 || observed.Claim.Holder.WakeRetry.State != "open" { |
| + t.Fatal("one-shot did not settle publication", observed) |
| + } |
| + files, err := executor.source.Open(t.Context(), claims, executor.assembly.Data, f.actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, found, err := hostfiles.Load(t.Context(), files.(*movesFiles).Files, movesFilesPrefix(observed.Claim.Holder.Location, observed.Claim.Holder.Generation)); err != nil || !found { |
| + t.Fatal("CLI did not publish", err) |
| + } |
| + servingRunnerSystemctl(t, "stop", "carlos-edge.service") |
| + etag := observed.ETag |
| + run() |
| + if f.f.read().ETag != etag { |
| + t.Fatal("delegated established rerun mutated claim") |
| + } |
| +} |
| + |
| +func TestRootFilesSeedCLIPayload(t *testing.T) { |
| + if os.Getenv("FILES_SEED_CLI") != "1" || os.Geteuid() == 0 { |
| + t.Fatal("invoke through delegated CLI fixture") |
| + } |
| + body, err := os.ReadFile("/data/seed-cli.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var cfg seedCLIConfiguration |
| + if err := json.Unmarshal(body, &cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + claimOwnerRegistration.Provider = seedCLIOwners{t: t, cfg: cfg} |
| + RunSystemFiles([]string{"seed", "--host", recoveryHost}) |
| +} |
| + |
| +type seedCLIOwners struct { |
| + t *testing.T |
| + cfg seedCLIConfiguration |
| +} |
| + |
| +func (o seedCLIOwners) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| + _, _, defaults, _ := fix4JournalFixture(o.t) |
| + owners := defaults.ClaimOwnerBindings |
| + owners.Identity = handoverRecoveryIdentity(o.t, o.cfg.Actor) |
| + owners.EvidenceKey = nil |
| + owners.RetirementAuthority = &hostops.ProductionAuthority{Store: store, Directory: hostactors.Directory{Store: store, PublicKey: ed25519.NewKeyFromSeed(make([]byte, 32)).Public().(ed25519.PublicKey), Bucket: o.cfg.Actor.ControlBucket}, Now: time.Now} |
| + data, err := lease.NewVersionedS3(ctx, map[string]string{o.cfg.Actor.Bucket: "eu-west-1"}) |
| + if err != nil { |
| + return owners, err |
| + } |
| + owners.Data = data |
| + owners.Local = &rev2Local{} |
| + owners.Controllers = productionStopControllers{} |
| + fixture := plan9D5Fixture(o.t) |
| + fixture.launch.Executable = o.cfg.Executable |
| + image, err := os.ReadFile(o.cfg.Executable) |
| + if err != nil { |
| + return owners, err |
| + } |
| + sum := sha256.Sum256(image) |
| + fixture.launch.ExecutableSHA256 = hex.EncodeToString(sum[:]) |
| + fixture.launch.Args = []string{"--socket", provision.NestedSocket(recoveryHost), "--db", filepath.Join(r.DataDir, "instances", recoveryHost, recoveryHost+".db")} |
| + fixture.launch.Limit = cgroup.TierDefault |
| + fixture.launch.Env = []string{"CARLOS_ADMIN_TOKEN=test"} |
| + owners.Serving = fixture.owners.Sources |
| + files, err := productionClaimFilesSource().Open(ctx, store, data, o.cfg.Actor) |
| + if err != nil { |
| + return owners, err |
| + } |
| + owners.Serving.Files = files |
| + return owners, nil |
| +} |
| + |
| +func TestFilesSeedCLIStopFixture(t *testing.T) { |
| + var p *servingPreparation |
| + capture := filesSeedLiveWitness{seed: func(_ *testing.T, got *servingPreparation, _ *filesSeedExecutor) { p = got }} |
| + runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", nil, []filesSeedLiveWitness{capture}) |
| + f := p.f |
| + claims := release.NewDirStore(filepath.Join(t.TempDir(), "claims")) |
| + keys, err := f.f.claims.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + body, _, err := f.f.claims.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := claims.Put(t.Context(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.f.claims, f.f.evidence = claims, claims |
| + f.f.guard.Claims = hostguard.StoreReader{Store: claims} |
| + f.f.guard.LockDir = filepath.Join(t.TempDir(), "hostguard") |
| + seedCLISharedLocks(t, f.f.guard.LockDir, os.Getuid(), os.Getgid()) |
| + c := f.p.root.Stop.Coordinator |
| + c.Claims, c.Guard, c.Evidence = hostguard.StoreReader{Store: claims}, f.f.guard, claims |
| + c.Now = time.Now |
| + f.f.kernel.noProcess = true |
| + c.Kernel = f.f.kernel |
| + f.p.root.Stop.Coordinator = c |
| + c.RetainNoProcess = f.p.retainLegacyNoProcess |
| + o := f.f.read() |
| + h := o.Claim.Holder |
| + req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: f.f.peer.BootID, ControllerID: f.f.peer.ControllerID, ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: strconv.FormatUint(f.actor.Revision, 10)} |
| + if _, err := c.FenceAndStop(t.Context(), req); err != nil { |
| + t.Fatal("CLI fixture no-process", err) |
| + } |
| +} |
| + |
| +func seedCLISharedLocks(t *testing.T, path string, uid, gid int) { |
| + t.Helper() |
| + // Match bootstrap's carlos-owned, setgid shared-lock directory. |
| + if err := os.Mkdir(path, 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(path, uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(path, os.ModeSetgid|0770); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_crash_rootrunner_linux_test.go b/internal/host/claimfilesseed_crash_rootrunner_linux_test.go| index c46a85a9a..6262e5f6d 100644 |
| --- a/internal/host/claimfilesseed_crash_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_crash_rootrunner_linux_test.go |
| @@ -36,6 +36,9 @@ type seedCrashFixture struct { |
| } |
| func rootFilesSeedWitnessFor(t *testing.T, binary string) filesSeedLiveWitness { |
| + if strings.Contains(t.Name(), "Socket") { |
| + return filesSeedLiveWitness{seed: rootFilesSeedLifetime} |
| + } |
| if strings.Contains(t.Name(), "WholeExecutorKilledRerun") { |
| return filesSeedLiveWitness{seed: func(t *testing.T, p *servingPreparation, e *filesSeedExecutor) { rootFilesSeedCrash(t, binary, p, e) }} |
| } |
diff --git a/internal/host/claimfilesseed_integration_linux_test.go b/internal/host/claimfilesseed_integration_linux_test.go| index 0d16d5265..d696aac5c 100644 |
| --- a/internal/host/claimfilesseed_integration_linux_test.go |
| +++ b/internal/host/claimfilesseed_integration_linux_test.go |
| @@ -28,12 +28,12 @@ func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation, live ...fi |
| f.actor.HomeRegion = "ie" |
| f.actor.FleetHomeRegion = "ie" |
| p.local.Actor = f.actor |
| - rt := registry.Route{Host: recoveryHost, Kind: "instance", Hibernate: true, Account: "bab", App: "jam", DBPath: f.db, Socket: provision.NestedSocket(recoveryHost)} |
| + rt := registry.Route{Host: f.f.hostName(), Kind: "instance", Hibernate: true, Account: "bab", App: "jam", DBPath: f.db, Socket: provision.NestedSocket(f.f.hostName())} |
| reg, err := registry.Open(filepath.Join(t.TempDir(), "seed.db")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - defer reg.Close() |
| + t.Cleanup(func() { reg.Close() }) |
| if err := reg.Upsert(rt); err != nil { |
| t.Fatal(err) |
| } |
| @@ -198,6 +198,9 @@ func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation, live ...fi |
| } |
| type filesSeedLiveWitness struct { |
| + host string |
| + shared bool |
| + start func(*testing.T, *servingPreparation, hostclaim.Observed, activator.ClaimStart) func() |
| healthy bool |
| prepare func(*servingPreparation) |
| seed func(*testing.T, *servingPreparation, *filesSeedExecutor) |
diff --git a/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go b/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go| index c420d79b2..78608e3cf 100644 |
| --- a/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go |
| @@ -70,8 +70,27 @@ func (c seedIsolatedClient) Stop(ctx context.Context, q hostbox.RoleLaunchReques |
| } |
| func rootFilesSeedIsolated(t *testing.T, binary string) { |
| + witness := rootFilesSeedIsolatedWitness(t, binary) |
| + runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", []bool{true}, []filesSeedLiveWitness{witness}) |
| +} |
| + |
| +func rootFilesSeedIsolatedWitness(t *testing.T, binary string, twoHosts ...bool) filesSeedLiveWitness { |
| + hosts := []string{"seed-isolated.bab.oncarlos.com"} |
| + if len(twoHosts) > 0 { |
| + hosts = seedTwoHostNames() |
| + } |
| + for _, host := range hosts { |
| + path := "/run/systemd/system.control/" + instancectl.SliceName(host) + ".d" |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("refuse existing slice properties", path, err) |
| + } |
| + } |
| for _, dir := range []string{"/data", "/var/lib/carlos-claims", "/opt/carlos", "/run/carlos", "/run/carlos-control", "/etc/carlos"} { |
| - if err := os.Mkdir(dir, 0755); err != nil { |
| + if len(twoHosts) > 1 && (dir == "/data" || dir == "/var/lib/carlos-claims" || dir == "/opt/carlos" || dir == "/etc/carlos") { |
| + if _, err := os.Stat(dir); err != nil { |
| + t.Fatal("missing reboot fixture", dir, err) |
| + } |
| + } else if err := os.Mkdir(dir, 0755); err != nil { |
| t.Fatal("refuse existing fixture path", dir, err) |
| } |
| t.Cleanup(func() { os.RemoveAll(dir) }) |
| @@ -108,6 +127,9 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| } |
| lines = append(lines, line) |
| } |
| + if os.Getenv("FILES_SEED_ISOLATION_MUTATION") == "1" { |
| + lines = append(lines, "[Service]", "BindReadOnlyPaths=/etc/carlos/fixture-secret:/tmp/exposed-root") |
| + } |
| unit := "/run/systemd/system/carlos-instance@.service" |
| file, err := os.OpenFile(unit, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) |
| if err != nil { |
| @@ -120,11 +142,38 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| t.Cleanup(func() { |
| ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| defer cancel() |
| - if out, err := exec.CommandContext(ctx, "systemctl", "stop", instancectl.UnitName(instancectl.RoleRun, recoveryHost)).CombinedOutput(); err != nil { |
| - t.Errorf("stop isolated tenant: %v %s", err, out) |
| + for _, host := range hosts { |
| + if out, err := exec.CommandContext(ctx, "systemctl", "stop", instancectl.UnitName(instancectl.RoleRun, host)).CombinedOutput(); err != nil { |
| + t.Errorf("stop isolated tenant: %v %s", err, out) |
| + } |
| + os.RemoveAll("/run/systemd/system/" + instancectl.UnitName(instancectl.RoleRun, host) + ".d") |
| + slice := instancectl.SliceName(host) |
| + if out, err := exec.CommandContext(ctx, "systemctl", "stop", slice).CombinedOutput(); err != nil { |
| + t.Errorf("stop isolated slice: %v %s", err, out) |
| + } |
| + state, err := exec.CommandContext(ctx, "systemctl", "show", slice, "--property=ActiveState", "--value").Output() |
| + if err != nil || strings.TrimSpace(string(state)) != "inactive" { |
| + t.Errorf("isolated slice survived cleanup: %s %v", state, err) |
| + } |
| + exec.CommandContext(ctx, "systemctl", "reset-failed", instancectl.UnitName(instancectl.RoleRun, host)).Run() |
| + path := "/run/systemd/system.control/" + slice + ".d" |
| + if err := os.RemoveAll(path); err != nil { |
| + t.Error("remove fixture slice properties", err) |
| + } |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Error("fixture slice properties survived", err) |
| + } |
| + } |
| + for _, slice := range []string{"carlos-instances.slice", "carlos.slice"} { |
| + if out, err := exec.CommandContext(ctx, "systemctl", "stop", slice).CombinedOutput(); err != nil { |
| + t.Errorf("stop isolated parent slice: %v %s", err, out) |
| + } |
| + state, err := exec.CommandContext(ctx, "systemctl", "show", slice, "--property=ActiveState", "--value").Output() |
| + if err != nil || strings.TrimSpace(string(state)) != "inactive" { |
| + t.Errorf("isolated parent slice survived cleanup: %s %v", state, err) |
| + } |
| } |
| os.Remove(unit) |
| - os.RemoveAll("/run/systemd/system/" + instancectl.UnitName(instancectl.RoleRun, recoveryHost) + ".d") |
| if out, err := exec.CommandContext(ctx, "systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| t.Errorf("reload isolated unit: %v %s", err, out) |
| } |
| @@ -149,6 +198,7 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| } |
| }) |
| witness := rootFilesSeedLiveWitness(t, app) |
| + witness.host = "seed-isolated.bab.oncarlos.com" |
| configure := witness.configure |
| witness.configure = func(w *ClaimWakeAssembly) { |
| configure(w) |
| @@ -156,7 +206,20 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| selected := owners.Sources.Launch |
| owners.Sources.Launch = func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) { |
| in, err := selected(ctx, rt) |
| - in.Env = append(in.Env, "FILES_SEED_DENIED="+strings.Join(denied, ":")) |
| + paths := append([]string(nil), denied...) |
| + if os.Getenv("FILES_SEED_ISOLATION_MUTATION") == "1" { |
| + paths = append(paths, "/tmp/exposed-root") |
| + } |
| + if len(twoHosts) > 0 { |
| + pair := seedTwoHostNames() |
| + other := pair[1] |
| + if rt.Host == other { |
| + other = pair[0] |
| + } |
| + paths = append(paths, provision.NestedDBPath("/data", other), filepath.Join("/data/instances", other, other, "payload")) |
| + in.Env = append(in.Env, "FILES_SEED_EXPECTED=real seeded bytes:"+rt.Host) |
| + } |
| + in.Env = append(in.Env, "FILES_SEED_DENIED="+strings.Join(paths, ":")) |
| return in, err |
| } |
| } |
| @@ -209,6 +272,7 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| } |
| units := instancectl.ClaimUnits{Systemctl: func(ctx context.Context, args ...string) (string, error) { |
| out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| + t.Logf("isolated systemctl %q: %s (%v)", args, out, err) |
| return string(out), err |
| }, CgroupRoot: "/sys/fs/cgroup", ExeRoot: "/run/carlos-control/exe", UnitRoot: "/run/systemd/system", DataRoot: "/data"} |
| root := &ClaimLaunchRoot{Guard: p.f.f.guard, Journal: p.f.f.journal, DataRoot: "/data", ExecLockDir: t.TempDir(), Local: &rev2Local{}, Clock: execlease.SystemClock{}, Actor: func(context.Context) (hostbox.Actor, error) { return p.f.actor, nil }, Units: units, Kernel: linuxClaimLaunchKernel{OptRoot: "/opt/carlos", CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}, ValidateExecutable: func(string, string, string) error { return nil }, executionState: state} |
| @@ -248,7 +312,7 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| t.Fatal(err) |
| } |
| } |
| - witness.serve = func(t *testing.T, p *servingPreparation, o hostclaim.Observed, start activator.ClaimStart) { |
| + witness.start = func(t *testing.T, p *servingPreparation, o hostclaim.Observed, start activator.ClaimStart) func() { |
| launch, err := start.PrepareLaunch(t.Context()) |
| if err != nil { |
| t.Fatal("prepare isolated launch", err) |
| @@ -258,14 +322,22 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| out, _ := exec.Command("journalctl", "-u", instancectl.UnitName(instancectl.RoleRun, o.Host), "--no-pager", "-n", "30").CombinedOutput() |
| t.Fatalf("isolated launch: %v\n%s", err, out) |
| } |
| - defer func() { |
| - if err := settle(t.Context()); err != nil { |
| - t.Error(err) |
| + stopped := false |
| + stop := func() { |
| + if !stopped { |
| + stopped = true |
| + if err := settle(context.Background()); err != nil { |
| + t.Error(err) |
| + } |
| } |
| - }() |
| + } |
| + t.Cleanup(stop) |
| ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| defer cancel() |
| if err := start.Ready(ctx, cmd); err != nil { |
| + if body, readErr := os.ReadFile(filepath.Join(filepath.Dir(p.f.db), o.Host, "payload.isolation-violation")); readErr == nil { |
| + t.Fatalf("isolation mutation detected: %s", body) |
| + } |
| t.Fatal("isolated readiness", err) |
| } |
| wrong, _ := os.FindProcess(os.Getpid()) |
| @@ -285,7 +357,11 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| } |
| body, err := io.ReadAll(response.Body) |
| response.Body.Close() |
| - if err != nil || response.StatusCode != 200 || string(body) != "real seeded bytes" { |
| + expected := "real seeded bytes" |
| + if len(twoHosts) > 0 { |
| + expected += ":" + o.Host |
| + } |
| + if err != nil || response.StatusCode != 200 || string(body) != expected { |
| t.Fatal("isolated payload", string(body), err) |
| } |
| for _, path := range denied { |
| @@ -294,6 +370,10 @@ func rootFilesSeedIsolated(t *testing.T, binary string) { |
| } |
| } |
| t.Log(fmt.Sprintf("healthy isolated wake: host=%s session=%d pid=%d", o.Host, o.Claim.Holder.Session, cmd.Process.Pid)) |
| + return stop |
| } |
| - runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", []bool{true}, []filesSeedLiveWitness{witness}) |
| + witness.serve = func(t *testing.T, p *servingPreparation, o hostclaim.Observed, start activator.ClaimStart) { |
| + witness.start(t, p, o, start)() |
| + } |
| + return witness |
| } |
diff --git a/internal/host/claimfilesseed_lifetime_rootrunner_linux_test.go b/internal/host/claimfilesseed_lifetime_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..469c8dbdb |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_lifetime_rootrunner_linux_test.go |
| @@ -0,0 +1,181 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestRootFilesSeedSocketDisconnectExecutor(t *testing.T) { |
| + runRootFilesSeedRealOriginPreparation(t, true) |
| +} |
| +func TestRootFilesSeedSocketShutdownExecutor(t *testing.T) { |
| + runRootFilesSeedRealOriginPreparation(t, true) |
| +} |
| +func TestRootFilesSeedSocketCompleteExecutor(t *testing.T) { |
| + runRootFilesSeedRealOriginPreparation(t, true) |
| +} |
| + |
| +func rootFilesSeedLifetime(t *testing.T, p *servingPreparation, e *filesSeedExecutor) { |
| + blocked := !strings.Contains(t.Name(), "Complete") |
| + entered := make(chan struct{}) |
| + factory := e.assembly.Wake.seedFactory |
| + e.assembly.Wake.seedFactory = func(ctx context.Context, op *filesSeedOperation, token *activator.SeedExclusion) (*activator.ClaimRuntime, *ClaimWakeAssembly, error) { |
| + runtime, wake, err := factory(ctx, op, token) |
| + if err != nil { |
| + return runtime, wake, err |
| + } |
| + prepare := runtime.Prepare |
| + runtime.Prepare = func(ctx context.Context, rt registry.Route, o hostclaim.Observed, policy activator.StartPolicy) (activator.ClaimStart, error) { |
| + start, err := prepare(ctx, rt, o, policy) |
| + if err == nil && blocked { |
| + start.Materialize = func(ctx context.Context) error { close(entered); <-ctx.Done(); return ctx.Err() } |
| + } |
| + return start, err |
| + } |
| + return runtime, wake, nil |
| + } |
| + ready := make(chan struct{}) |
| + close(ready) |
| + path := filepath.Join(t.TempDir(), "seed.sock") |
| + if err := os.Chmod(filepath.Dir(path), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Peer and delegated service authentication have their own installed witness. |
| + // Here the real executor is attached to the production connection lifetime. |
| + open := func() *filesSeedServer { |
| + s, err := startFilesSeedSocket(t.Context(), ready, path, func(context.Context, *net.UnixConn) error { return nil }, e.run) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s.finalize = e.close |
| + return s |
| + } |
| + s := open() |
| + shutdown := func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + if err := s.close(ctx); err != nil { |
| + t.Fatal("seed server did not join executor", err) |
| + } |
| + } |
| + defer func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + s.close(ctx) |
| + }() |
| + request := func() net.Conn { |
| + c, err := net.DialTimeout("unix", path, time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c.SetDeadline(time.Now().Add(10 * time.Second)) |
| + if err := json.NewEncoder(c).Encode(filesSeedRequest{Verb: "seed", Host: recoveryHost}); err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| + } |
| + c := request() |
| + defer c.Close() |
| + if blocked { |
| + select { |
| + case <-entered: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("seed did not reach precommit") |
| + } |
| + if err := e.run(t.Context(), recoveryHost, io.Discard); err != activator.ErrFenceHeld { |
| + t.Fatal("second executor escaped exclusion", err) |
| + } |
| + if strings.Contains(t.Name(), "Disconnect") { |
| + c.Close() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for { |
| + e.mu.Lock() |
| + running := e.running |
| + e.mu.Unlock() |
| + if !running { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("executor survived client disconnect") |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + } |
| + shutdown() |
| + e.mu.Lock() |
| + retained, running := len(e.pending), e.running |
| + e.mu.Unlock() |
| + if retained != 0 || running { |
| + t.Fatal("executor outlived server", retained, running) |
| + } |
| + o := p.f.f.read() |
| + if o.Claim.Holder.WakeRetry == nil || o.Claim.Holder.WakeRetry.State != "open" || o.Claim.Holder.WakeRetry.AttemptsReserved != 1 { |
| + t.Fatal("cancellation lost settlement", o) |
| + } |
| + files, err := e.source.Open(t.Context(), e.assembly.Claims, e.assembly.Data, p.f.actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, found, err := hostfiles.Load(t.Context(), files.(*movesFiles).Files, movesFilesPrefix(o.Claim.Holder.Location, o.Claim.Holder.Generation)); err != nil || found { |
| + t.Fatal("canceled seed committed", found, err) |
| + } |
| + blocked = false |
| + p.f.f.now = p.f.f.now.Add(time.Minute) |
| + s = open() |
| + c = request() |
| + defer c.Close() |
| + } |
| + decoder := json.NewDecoder(c) |
| + var text strings.Builder |
| + for { |
| + var reply filesSeedReply |
| + if err := decoder.Decode(&reply); err != nil { |
| + t.Fatal("seed reply", err, text.String()) |
| + } |
| + text.WriteString(reply.Text) |
| + if reply.Code != nil { |
| + if *reply.Code != 0 { |
| + t.Fatal("seed failed", *reply.Code, text.String()) |
| + } |
| + break |
| + } |
| + } |
| + shutdown() |
| + o := p.f.f.read() |
| + files, err := e.source.Open(t.Context(), e.assembly.Claims, e.assembly.Data, p.f.actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest, found, err := hostfiles.Load(t.Context(), files.(*movesFiles).Files, movesFilesPrefix(o.Claim.Holder.Location, o.Claim.Holder.Generation)) |
| + if err != nil || !found || manifest.Entries["payload"].SHA256 == "" { |
| + t.Fatal("seed did not publish", found, err) |
| + } |
| + if !strings.Contains(text.String(), "seeded.") { |
| + t.Fatal(text.String()) |
| + } |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("socket outlived server", err) |
| + } |
| +} |
| + |
| +func TestFilesSeedExecutorLifetime(t *testing.T) { |
| + for _, mode := range []string{"Complete", "Disconnect", "Shutdown"} { |
| + t.Run(mode, func(t *testing.T) { |
| + runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", nil, []filesSeedLiveWitness{{seed: rootFilesSeedLifetime}}) |
| + }) |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_live_rootrunner_linux_test.go b/internal/host/claimfilesseed_live_rootrunner_linux_test.go| index f777eab11..e297bb4d5 100644 |
| --- a/internal/host/claimfilesseed_live_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_live_rootrunner_linux_test.go |
| @@ -45,8 +45,13 @@ func TestRootFilesSeedTenantPayload(t *testing.T) { |
| for _, path := range strings.Split(os.Getenv("FILES_SEED_DENIED"), ":") { |
| if path != "" { |
| if body, err := os.ReadFile(path); err == nil { |
| + _ = os.WriteFile(os.Getenv("FILES_SEED_PAYLOAD")+".isolation-violation", []byte(path), 0600) |
| t.Fatalf("tenant read foreign path %s: %q", path, body) |
| } |
| + if f, err := os.OpenFile(path, os.O_WRONLY, 0); err == nil { |
| + f.Close() |
| + t.Fatalf("tenant opened foreign path for write: %s", path) |
| + } |
| } |
| } |
| db, err := sql.Open("sqlite", *seedTenantDB) |
| @@ -59,7 +64,11 @@ func TestRootFilesSeedTenantPayload(t *testing.T) { |
| t.Fatal(integrity, err) |
| } |
| body, err := os.ReadFile(os.Getenv("FILES_SEED_PAYLOAD")) |
| - if err != nil || string(body) != "real seeded bytes" { |
| + expected := os.Getenv("FILES_SEED_EXPECTED") |
| + if expected == "" { |
| + expected = "real seeded bytes" |
| + } |
| + if err != nil || string(body) != expected { |
| t.Fatal(string(body), err) |
| } |
| listener, err := net.Listen("unix", *seedTenantSocket) |
diff --git a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..e3a9c890b |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| @@ -0,0 +1,294 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "fmt" |
| + "net" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +const seedRebootPath = "/var/lib/carlos-rootrunner-files-seed-reboot" |
| + |
| +type seedRebootObject struct { |
| + Version lease.ObjectVersion |
| + Body []byte |
| +} |
| +type seedRebootHost struct { |
| + Host string |
| + Fixture seedCrashFixture |
| + Objects []seedRebootObject |
| + Snapshot string |
| + Records []servingParkRecord |
| +} |
| +type seedRebootSave struct { |
| + Boot, Scratch, Install string |
| + Hosts []seedRebootHost |
| +} |
| + |
| +func TestRootRebootFilesSeedTwoHostPhase1(t *testing.T) { |
| + if err := os.Mkdir(seedRebootPath, 0700); err != nil { |
| + t.Fatal("refuse existing reboot fixture", err) |
| + } |
| + runServingParkPeerNamespace(t, false, false, "seed-two-host") |
| +} |
| + |
| +func saveFilesSeedReboot(t *testing.T, binary string, hosts []*seedLiveHost) { |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + saved := seedRebootSave{Boot: strings.TrimSpace(string(boot)), Scratch: os.Getenv("TMPDIR"), Install: filepath.Dir(binary)} |
| + for _, h := range hosts { |
| + f := h.p.f |
| + host := f.f.hostName() |
| + cfg := seedCrashFixture{Claims: filepath.Join(seedRebootPath, host, "claims"), Data: f.f.guard.DataDir, Locks: f.f.guard.LockDir, Root: f.rootDisk.dir, Parks: "/var/lib/carlos-claims/authority/serving-parks", Journal: f.f.journalPath, Registry: f.p.root.Reconcile.Registry.Path(), Retirement: f.p.root.Reconcile.Retirement.JournalDir, Actor: f.actor, Now: f.f.now} |
| + claims := release.NewDirStore(cfg.Claims) |
| + keys, err := f.f.claims.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + body, _, err := f.f.claims.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := claims.Put(t.Context(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + sh := seedRebootHost{Host: host, Fixture: cfg, Snapshot: f.f.data.(*leasetest.VersionedBucket).Snapshot()} |
| + versions, err := f.f.data.(*leasetest.VersionedBucket).ListVersions(t.Context(), f.actor.Bucket, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, v := range versions { |
| + var body []byte |
| + if !v.DeleteMarker { |
| + body, err = f.f.data.GetVersion(t.Context(), f.actor.Bucket, v.Key, v.VersionID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + sh.Objects = append(sh.Objects, seedRebootObject{v, body}) |
| + } |
| + names, err := f.p.ledger.state.Names() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range names { |
| + if !strings.HasPrefix(name, "retirement-") || !strings.HasSuffix(name, ".json") { |
| + continue |
| + } |
| + r, err := f.p.ledger.read(strings.TrimSuffix(strings.TrimPrefix(name, "retirement-"), ".json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if r.Authorization.Successor.Host == host && r.Parked && !r.Closed { |
| + sh.Records = append(sh.Records, r) |
| + } |
| + } |
| + if len(sh.Records) == 0 { |
| + t.Fatal("no retirement retained", host) |
| + } |
| + if err := h.executor.registry.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.f.journal.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.p.root.Reconcile.Registry.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + saved.Hosts = append(saved.Hosts, sh) |
| + } |
| + raw, err := json.Marshal(saved) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(seedRebootPath, "fixture.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + syscall.Sync() |
| + // Phase 2 owns these exact durable fixtures. All tenant units have stopped; |
| + // process exit closes the loopback servers without running TempDir removal. |
| + t.Log("two live hosts retired independently; durable fixture ready for actual reboot") |
| + os.Exit(0) |
| +} |
| + |
| +func TestRootRebootFilesSeedTwoHostPhase2(t *testing.T) { |
| + servingRootRunner(t) |
| + raw, err := os.ReadFile(filepath.Join(seedRebootPath, "fixture.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var saved seedRebootSave |
| + if err := json.Unmarshal(raw, &saved); err != nil { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil || strings.TrimSpace(string(boot)) == saved.Boot { |
| + t.Fatal("actual reboot required", err) |
| + } |
| + t.Cleanup(func() { |
| + for _, p := range []string{saved.Scratch, saved.Install, seedRebootPath} { |
| + if !strings.HasPrefix(p, "/var/lib/carlos-rootrunner-") && !strings.HasPrefix(p, "/var/lib/carlos-serving-test-") { |
| + t.Errorf("refuse cleanup path %q", p) |
| + continue |
| + } |
| + if err := os.RemoveAll(p); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + }) |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + witness := rootFilesSeedIsolatedWitness(t, binary, true, true) |
| + servingRunnerMount(t, "/data") |
| + state, err := openClaimServingParkAuthority() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { state.Close() }) |
| + fs := newServingParkFS("/data", state) |
| + ledger := &servingParkLedger{state: state, fs: fs, now: time.Now} |
| + for _, h := range saved.Hosts { |
| + for _, r := range h.Records { |
| + if err := ledger.fs.CheckParked(&r); err == nil { |
| + t.Fatal("old boot authority accepted", h.Host) |
| + } |
| + if !r.Complete { |
| + t.Fatal("unfinished reboot fixture", h.Host) |
| + } |
| + if err := ledger.withNamespace(t.Context(), func() error { return ledger.prepareRebootLocked(t.Context(), r, nil) }); err != nil { |
| + t.Fatal("prepare completed park", h.Host, err) |
| + } |
| + if err := ledger.withNamespace(t.Context(), func() error { return ledger.commitRebootLocked(t.Context(), r, nil) }); err != nil { |
| + t.Fatal("recover park", h.Host, err) |
| + } |
| + } |
| + } |
| + var hosts []*seedLiveHost |
| + for _, savedHost := range saved.Hosts { |
| + h := reopenSeedLiveHost(t, savedHost, witness, state) |
| + hosts = append(hosts, h) |
| + h.wake(t) |
| + } |
| + for _, h := range hosts { |
| + h.probe(t) |
| + } |
| + for i, h := range hosts { |
| + hosts[1-i].probe(t) |
| + h.stop() |
| + h.wake(t) |
| + hosts[1-i].probe(t) |
| + } |
| + for _, h := range hosts { |
| + h.probe(t) |
| + h.stop() |
| + } |
| +} |
| + |
| +func reopenSeedLiveHost(t *testing.T, saved seedRebootHost, witness filesSeedLiveWitness, state rootstate.Store) *seedLiveHost { |
| + t.Helper() |
| + cfg := saved.Fixture |
| + data := leasetest.NewVersionedBucket() |
| + data.AddBucket(cfg.Actor.Bucket) |
| + sort.Slice(saved.Objects, func(i, j int) bool { return saved.Objects[i].Version.VersionID < saved.Objects[j].Version.VersionID }) |
| + next := 1 |
| + for _, o := range saved.Objects { |
| + n, err := strconv.Atoi(strings.TrimPrefix(o.Version.VersionID, "v")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for next < n { |
| + id := data.Put(cfg.Actor.Bucket, "reboot-gap", nil) |
| + if err := data.DeleteVersion(t.Context(), cfg.Actor.Bucket, "reboot-gap", id); err != nil { |
| + t.Fatal(err) |
| + } |
| + next++ |
| + } |
| + var id string |
| + if o.Version.DeleteMarker { |
| + id = data.BareDelete(cfg.Actor.Bucket, o.Version.Key) |
| + } else { |
| + id = data.Put(cfg.Actor.Bucket, o.Version.Key, o.Body) |
| + } |
| + if id != o.Version.VersionID { |
| + t.Fatal("version identity changed", id, o.Version.VersionID) |
| + } |
| + next++ |
| + } |
| + if data.Snapshot() != saved.Snapshot { |
| + t.Fatal("remote fixture version snapshot changed") |
| + } |
| + handler, _ := leasetest.NewS3Server(data) |
| + server := httptest.NewServer(handler) |
| + t.Cleanup(server.Close) |
| + for k, v := range map[string]string{"AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "eu-west-1", "CARLOS_HOST_CLAIM": "1", "CARLOS_HOST_CLAIM_ORIGINS": "1", "CARLOS_MOVES_PHASE1": "", "CARLOS_S3_ENDPOINT": server.URL} { |
| + t.Setenv(k, v) |
| + } |
| + claims := release.NewDirStore(cfg.Claims) |
| + journal, err := hostbox.OpenLaunchJournal(cfg.Journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { journal.Close() }) |
| + roots := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: cfg.Root}} |
| + rf := &recoveryFixture{host: saved.Host, t: t, claims: claims, evidence: claims, journal: journal, journalPath: cfg.Journal, data: data, bucket: cfg.Actor.Bucket, signer: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), now: cfg.Now.Add(time.Minute), seq: 100000} |
| + rf.guard = hostguard.Guard{DataDir: cfg.Data, LockDir: cfg.Locks, Claims: hostguard.StoreReader{Store: claims}, Root: hostguard.NewRootAuthority(roots)} |
| + rf.kernel = &recoveryKernel{journal: journal} |
| + reg, err := registry.Open(cfg.Registry) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + row, err := reg.Get(saved.Host) |
| + if err != nil || row == nil { |
| + t.Fatal("route", err) |
| + } |
| + journals, err := hostbox.NewJournalService(journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &servingRootFixture{t: t, f: rf, rootDisk: roots, db: row.DBPath, actor: cfg.Actor} |
| + f.p = &servingParkRoot{ledger: &servingParkLedger{state: state, fs: newServingParkFS(cfg.Data, state), journal: journal, now: func() time.Time { return rf.now }}, empty: plan9D5Fixture(t).owners} |
| + coordinator := rf.root(nil) |
| + coordinator.Kernel = &noProcessKernelFake{} |
| + coordinator.Inventory = journals.Inventory |
| + f.p.root = ClaimRootHTTP{Roots: map[string]string{"data": cfg.Data}, Journals: journals, Launches: journal, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return rf.peer, nil }}, InventoryActor: func(context.Context) (hostbox.Actor, error) { return f.actor, nil }, RequireNewWork: func(context.Context) error { return nil }} |
| + f.p.root.Reconcile.Registry = reg |
| + f.p.root.Reconcile.Inventory = journals.Inventory |
| + f.p.root.Reconcile.Box, f.p.root.Reconcile.Enrollment = f.actor.Box, f.actor.Enrollment |
| + f.p.root.Reconcile.Retirement.JournalDir = cfg.Retirement |
| + source := &movesFilesSource{DataDir: cfg.Data, Routes: reg, newFiles: func(context.Context, string, string) (release.Store, error) { |
| + return casBucketStore{b: data, bucket: cfg.Actor.Bucket}, nil |
| + }} |
| + opened, err := source.Open(t.Context(), claims, data, cfg.Actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p := newServingPreparation(t, f, "boot", fmt.Sprintf("reboot-%d", os.Getpid())) |
| + return &seedLiveHost{p: p, endpoint: server.URL, files: opened.(*movesFiles), witness: witness} |
| +} |
diff --git a/internal/host/claimfilesseed_restore_linux_test.go b/internal/host/claimfilesseed_restore_linux_test.go| index 15bd3a35b..6c6949c8c 100644 |
| --- a/internal/host/claimfilesseed_restore_linux_test.go |
| +++ b/internal/host/claimfilesseed_restore_linux_test.go |
| @@ -55,7 +55,7 @@ func newServingPreparation(t *testing.T, f *servingRootFixture, boot, controller |
| p := &servingPreparation{f: f, local: claimStopLocal{Actor: f.actor, BootID: boot, ControllerID: controller, PID: 77, ProcessStart: controller + "-start"}} |
| f.f.peer = StopPeer{UID: 1001, PID: p.local.PID, ProcessStart: p.local.ProcessStart, BootID: boot, ControllerID: controller} |
| launchFixture := plan9D5Fixture(t) |
| - launchFixture.launch.Args = []string{"--socket", provision.NestedSocket(recoveryHost), "--db", f.db} |
| + launchFixture.launch.Args = []string{"--socket", provision.NestedSocket(f.f.hostName()), "--db", f.db} |
| launchFixture.launch.Limit = cgroup.TierDefault |
| launchFixture.launch.Env = []string{"CARLOS_ADMIN_TOKEN=test"} |
| sources := launchFixture.owners.Sources |
| @@ -151,7 +151,7 @@ func newServingPreparation(t *testing.T, f *servingRootFixture, boot, controller |
| func (p *servingPreparation) prepare(t *testing.T) (hostclaim.Observed, activator.ClaimStart) { |
| t.Helper() |
| f := p.f |
| - rt := registry.Route{Host: recoveryHost, DBPath: f.db, Socket: provision.NestedSocket(recoveryHost)} |
| + rt := registry.Route{Host: f.f.hostName(), DBPath: f.db, Socket: provision.NestedSocket(f.f.hostName())} |
| o := f.f.read() |
| // Prerequisites' isolated launch preflight requires an installed /data |
| // namespace. Drive its selection, stop and real lease in the same order; |
| @@ -228,17 +228,22 @@ func runFilesSeedPreparationWithLive(t *testing.T, failure, restart string, moun |
| t.Setenv("CARLOS_MOVES_PHASE1", "") |
| var f *servingRootFixture |
| if len(mounted) > 0 && mounted[0] { |
| - |
| - t.Cleanup(func() { |
| - if err := os.RemoveAll("/data"); err != nil { |
| - t.Error(err) |
| - } |
| - }) |
| - t.Cleanup(func() { |
| - if err := os.RemoveAll("/var/lib/carlos-claims"); err != nil { |
| - t.Error(err) |
| - } |
| - }) |
| + shared := false |
| + for _, witness := range live { |
| + shared = shared || witness.shared |
| + } |
| + if !shared { |
| + t.Cleanup(func() { |
| + if err := os.RemoveAll("/data"); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + t.Cleanup(func() { |
| + if err := os.RemoveAll("/var/lib/carlos-claims"); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + } |
| if failure == "restore-enospc" { |
| if err := os.Mkdir("/data", 0700); err != nil { |
| t.Fatal(err) |
| @@ -261,39 +266,47 @@ func runFilesSeedPreparationWithLive(t *testing.T, failure, restart string, moun |
| t.Error(err) |
| } |
| }) |
| - f, _, _ = newServingLegacyRootSetup(t, false, "/data") |
| - instances := filepath.Join(f.f.guard.DataDir, "instances") |
| - if err := unix.Mount(instances, instances, "", unix.MS_BIND, ""); err != nil { |
| - t.Fatal(err) |
| - } |
| - t.Cleanup(func() { |
| - if err := unix.Unmount(instances, 0); err != nil { |
| - t.Error(err) |
| + host := recoveryHost |
| + for _, witness := range live { |
| + if witness.host != "" { |
| + host = witness.host |
| } |
| - }) |
| - if err := unix.Mount("", instances, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_NOSUID|unix.MS_NODEV|unix.MS_NOEXEC, ""); err != nil { |
| - t.Fatal(err) |
| } |
| - if parent, _, err := newServingParkFS(f.f.guard.DataDir, state).parent(); err == nil { |
| - parent.Close() |
| - t.Fatal("production filesystem accepted a mount without nosymfollow") |
| - } |
| - if err := unix.Mount("", instances, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_NOSYMFOLLOW|unix.MS_NOSUID|unix.MS_NODEV|unix.MS_NOEXEC, ""); err != nil { |
| - t.Fatal(err) |
| - } |
| - link := filepath.Join(instances, ".nosymfollow-witness") |
| - if err := os.Symlink(f.db, link); err != nil { |
| - t.Fatal(err) |
| - } |
| - fd, err := unix.Open(link, unix.O_RDONLY|unix.O_CLOEXEC, 0) |
| - if fd >= 0 { |
| - unix.Close(fd) |
| - } |
| - if !errors.Is(err, syscall.ELOOP) { |
| - t.Fatalf("kernel followed symlink on nosymfollow mount: %v", err) |
| - } |
| - if err := os.Remove(link); err != nil { |
| - t.Fatal(err) |
| + f, _, _ = newServingLegacyRootSetup(t, false, "/data", host) |
| + if !shared { |
| + instances := filepath.Join(f.f.guard.DataDir, "instances") |
| + if err := unix.Mount(instances, instances, "", unix.MS_BIND, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := unix.Unmount(instances, 0); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if err := unix.Mount("", instances, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_NOSUID|unix.MS_NODEV|unix.MS_NOEXEC, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if parent, _, err := newServingParkFS(f.f.guard.DataDir, state).parent(); err == nil { |
| + parent.Close() |
| + t.Fatal("production filesystem accepted a mount without nosymfollow") |
| + } |
| + if err := unix.Mount("", instances, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_NOSYMFOLLOW|unix.MS_NOSUID|unix.MS_NODEV|unix.MS_NOEXEC, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + link := filepath.Join(instances, ".nosymfollow-witness") |
| + if err := os.Symlink(f.db, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + fd, err := unix.Open(link, unix.O_RDONLY|unix.O_CLOEXEC, 0) |
| + if fd >= 0 { |
| + unix.Close(fd) |
| + } |
| + if !errors.Is(err, syscall.ELOOP) { |
| + t.Fatalf("kernel followed symlink on nosymfollow mount: %v", err) |
| + } |
| + if err := os.Remove(link); err != nil { |
| + t.Fatal(err) |
| + } |
| } |
| // Use the production UID and mount checks in this isolated namespace. |
| f.p.ledger.state = state |
| @@ -566,7 +579,7 @@ func runFilesSeedPreparationWithLive(t *testing.T, failure, restart string, moun |
| nextBoot = "boot-2" |
| } |
| next := newServingPreparation(t, f, nextBoot, "controller-2") |
| - err = next.runtime.Prerequisites(t.Context(), registry.Route{Host: recoveryHost, DBPath: f.db}, f.f.read(), recoveryPolicy) |
| + err = next.runtime.Prerequisites(t.Context(), registry.Route{Host: f.f.hostName(), DBPath: f.db}, f.f.read(), recoveryPolicy) |
| if !errors.Is(err, hostbox.ErrChanged) { |
| t.Fatalf("retry settlement: %v", err) |
| } |
diff --git a/internal/host/claimfilesseed_twohost_rootrunner_linux_test.go b/internal/host/claimfilesseed_twohost_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..c187f62e5 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_twohost_rootrunner_linux_test.go |
| @@ -0,0 +1,157 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func seedTwoHostNames() []string { |
| + if os.Getenv("FILES_SEED_ISOLATION_MUTATION") == "1" { |
| + return []string{"seed-negative-a.bab.oncarlos.com", "seed-negative-b.bab.oncarlos.com"} |
| + } |
| + return []string{"seed-a.bab.oncarlos.com", "seed-b.bab.oncarlos.com"} |
| +} |
| + |
| +func TestRootFilesSeedTwoHostIsolation(t *testing.T) { |
| + runServingParkPeerNamespace(t, false, false, "seed-two-host") |
| +} |
| + |
| +type seedLiveHost struct { |
| + p *servingPreparation |
| + executor *filesSeedExecutor |
| + endpoint string |
| + files *movesFiles |
| + witness filesSeedLiveWitness |
| + stop func() |
| + observed hostclaim.Observed |
| +} |
| + |
| +func rootFilesSeedTwoHost(t *testing.T, binary string) { |
| + witness := rootFilesSeedIsolatedWitness(t, binary, true) |
| + hosts := make([]*seedLiveHost, 0, 2) |
| + for i, host := range seedTwoHostNames() { |
| + h := &seedLiveHost{witness: witness} |
| + capture := filesSeedLiveWitness{host: host, shared: i > 0, seed: func(t *testing.T, p *servingPreparation, e *filesSeedExecutor) { |
| + h.p, h.executor, h.endpoint = p, e, os.Getenv("CARLOS_S3_ENDPOINT") |
| + if err := os.WriteFile(filepath.Join(filepath.Dir(p.f.db), host, "payload"), []byte("real seeded bytes:"+host), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + }} |
| + runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", []bool{true}, []filesSeedLiveWitness{capture}) |
| + hosts = append(hosts, h) |
| + } |
| + for _, h := range hosts { |
| + t.Setenv("CARLOS_S3_ENDPOINT", h.endpoint) |
| + if err := h.executor.run(t.Context(), h.p.f.f.hostName(), io.Discard); err != nil { |
| + t.Fatal("seed", err) |
| + } |
| + f := h.p.f |
| + opened, err := h.executor.source.Open(t.Context(), f.f.claims, f.f.data.(*leasetest.VersionedBucket), f.actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h.files = opened.(*movesFiles) |
| + h.wake(t) |
| + } |
| + for _, h := range hosts { |
| + h.probe(t) |
| + } |
| + // Retire each real launched generation while the other tenant stays live. |
| + for i, h := range hosts { |
| + other := hosts[1-i] |
| + other.probe(t) |
| + h.stop() |
| + previous := h.observed.Claim.Holder.Generation |
| + h.wake(t) |
| + if h.observed.Claim.Holder.Generation == previous { |
| + t.Fatal("cold wake reused generation") |
| + } |
| + other.probe(t) |
| + h.probe(t) |
| + } |
| + for _, h := range hosts { |
| + h.stop() |
| + } |
| + if t.Name() == "TestRootRebootFilesSeedTwoHostPhase1" { |
| + saveFilesSeedReboot(t, binary, hosts) |
| + } |
| +} |
| + |
| +func (h *seedLiveHost) wake(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("CARLOS_S3_ENDPOINT", h.endpoint) |
| + f := h.p.f |
| + f.f.now = f.f.now.Add(time.Minute) |
| + p := newServingPreparation(t, f, "boot", "controller", func(w *ClaimWakeAssembly) { |
| + w.Inputs.(*claimServingOwners).Sources.Files = h.files |
| + w.ServingPrerequisites = servingFilesPrerequisites(f.f.claims, h.files) |
| + h.witness.configure(w) |
| + }) |
| + h.witness.prepare(p) |
| + admitted, start := p.prepare(t) |
| + if err := start.Restore(t.Context()); err != nil { |
| + t.Fatal("restore", err) |
| + } |
| + if err := start.Reserve(t.Context(), 0); err != nil { |
| + t.Fatal("reserve", err) |
| + } |
| + if err := start.Materialize(t.Context()); err != nil { |
| + t.Fatal("materialize", err) |
| + } |
| + stop := h.witness.start(t, p, admitted, start) |
| + h.observed = admitted |
| + h.stop = func() { |
| + stop() |
| + t.Setenv("CARLOS_S3_ENDPOINT", h.endpoint) |
| + if err := p.runtime.FailedAdmission(t.Context(), admitted, hostclaim.ErrWrongState); err != nil { |
| + t.Fatal("release", err) |
| + } |
| + } |
| +} |
| + |
| +func (h *seedLiveHost) probe(t *testing.T) { |
| + t.Helper() |
| + host := h.p.f.f.hostName() |
| + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", provision.NestedSocket(host)) |
| + }} |
| + defer transport.CloseIdleConnections() |
| + response, err := (&http.Client{Transport: transport, Timeout: time.Second}).Get("http://tenant/") |
| + if err != nil { |
| + t.Fatal("sibling unavailable", host, err) |
| + } |
| + defer response.Body.Close() |
| + body, err := io.ReadAll(response.Body) |
| + if err != nil || response.StatusCode != 200 || string(body) != "real seeded bytes:"+host { |
| + t.Fatal("sibling changed", host, string(body), err) |
| + } |
| +} |
| + |
| +func TestRootFilesSeedIsolationNegativeControl(t *testing.T) { |
| + servingRootRunner(t) |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.CommandContext(t.Context(), binary, "-test.run=^TestRootFilesSeedTwoHostIsolation$", "-test.v", "-test.timeout=2m") |
| + cmd.Env = append(os.Environ(), "FILES_SEED_ISOLATION_MUTATION=1") |
| + output, err := cmd.CombinedOutput() |
| + if err == nil || !strings.Contains(string(output), "isolation mutation detected: /tmp/exposed-root") { |
| + t.Fatalf("isolation mutant not caught: %v\n%s", err, output) |
| + } |
| + t.Log("root-only mutation caught: exposed private-root file was read by the tenant") |
| +} |
diff --git a/internal/host/claimretryrecovery_test.go b/internal/host/claimretryrecovery_test.go| index cbd0f51e3..a0e0d5d75 100644 |
| --- a/internal/host/claimretryrecovery_test.go |
| +++ b/internal/host/claimretryrecovery_test.go |
| @@ -111,6 +111,7 @@ func (k *recoveryKernel) RecoverStopped(ctx context.Context, t hostguard.FenceTa |
| } |
| type recoveryFixture struct { |
| + host string |
| journalPath string |
| data genselect.Reader |
| bucket string |
| @@ -138,14 +139,18 @@ type recoveryController struct { |
| assembly ClaimAssembly |
| } |
| -func newRecoveryFixture(t *testing.T) *recoveryFixture { |
| +func newRecoveryFixture(t *testing.T, hosts ...string) *recoveryFixture { |
| + host := recoveryHost |
| + if len(hosts) > 0 { |
| + host = hosts[0] |
| + } |
| t.Helper() |
| ctx := t.Context() |
| st := release.NewDirStore(t.TempDir()) |
| now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| - in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + recoveryHost + ".json", Account: "bab", App: "jam", Host: recoveryHost, Region: "ie", RecordRegion: "ie", CreatedAt: now} |
| - body := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-06T12:00:00Z"}`) |
| - absent, err := hostclaim.Read(ctx, st, recoveryHost) |
| + in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now} |
| + body := []byte(strings.ReplaceAll(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-06T12:00:00Z"}`, recoveryHost, host)) |
| + absent, err := hostclaim.Read(ctx, st, host) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -166,14 +171,21 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture { |
| t.Fatal(err) |
| } |
| t.Cleanup(func() { journal.Close() }) |
| - return &recoveryFixture{journalPath: journalPath, t: t, claims: st, evidence: release.NewDirStore(t.TempDir()), signer: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: st}}, journal: journal, kernel: &recoveryKernel{journal: journal}, now: now, recordPrelaunch: true} |
| + return &recoveryFixture{host: host, journalPath: journalPath, t: t, claims: st, evidence: release.NewDirStore(t.TempDir()), signer: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: st}}, journal: journal, kernel: &recoveryKernel{journal: journal}, now: now, recordPrelaunch: true} |
| } |
| func (f *recoveryFixture) newID() string { f.seq++; return fmt.Sprintf("id-%d", f.seq) } |
| +func (f *recoveryFixture) hostName() string { |
| + if f.host != "" { |
| + return f.host |
| + } |
| + return recoveryHost |
| +} |
| + |
| func (f *recoveryFixture) read() hostclaim.Observed { |
| f.t.Helper() |
| - o, err := hostclaim.Read(f.t.Context(), f.claims, recoveryHost) |
| + o, err := hostclaim.Read(f.t.Context(), f.claims, f.hostName()) |
| if err != nil { |
| f.t.Fatal(err) |
| } |
diff --git a/internal/host/claimservinglegacyroot_linux_test.go b/internal/host/claimservinglegacyroot_linux_test.go| index ccecb2bd8..3dcdaa62c 100644 |
| --- a/internal/host/claimservinglegacyroot_linux_test.go |
| +++ b/internal/host/claimservinglegacyroot_linux_test.go |
| @@ -25,10 +25,14 @@ import ( |
| func newServingLegacyRootSetup(t *testing.T, empty bool, dataRoot ...string) (*servingRootFixture, ClaimLaunchInputs, ClaimStopCoordinator) { |
| t.Helper() |
| - f, _, _, in := servingRetirementFixture(t) |
| + var hosts []string |
| + if len(dataRoot) > 1 { |
| + hosts = dataRoot[1:] |
| + } |
| + f, _, _, in := servingRetirementFixture(t, hosts...) |
| if len(dataRoot) != 0 { |
| f.guard.DataDir = dataRoot[0] |
| - in.DBPath = provision.NestedDBPath(f.guard.DataDir, recoveryHost) |
| + in.DBPath = provision.NestedDBPath(f.guard.DataDir, f.hostName()) |
| for _, dir := range []string{filepath.Join(f.guard.DataDir, "hostguard"), filepath.Dir(in.DBPath)} { |
| if err := os.MkdirAll(dir, 0700); err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/claimservingpeer_rootrunner_linux_test.go b/internal/host/claimservingpeer_rootrunner_linux_test.go| index a731b71e3..ca10ef57a 100644 |
| --- a/internal/host/claimservingpeer_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpeer_rootrunner_linux_test.go |
| @@ -324,6 +324,14 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| if err := os.WriteFile(installed, body, 0755); err != nil { |
| t.Fatal(err) |
| } |
| + if namespace == "seed-cli" { |
| + rootFilesSeedDelegatedCLI(t, installed) |
| + return |
| + } |
| + if namespace == "seed-two-host" { |
| + rootFilesSeedTwoHost(t, installed) |
| + return |
| + } |
| if namespace == "seed-isolated" { |
| rootFilesSeedIsolated(t, installed) |
| return |
diff --git a/internal/host/claimservingretire_linux_test.go b/internal/host/claimservingretire_linux_test.go| index 98ea1c3e7..995368673 100644 |
| --- a/internal/host/claimservingretire_linux_test.go |
| +++ b/internal/host/claimservingretire_linux_test.go |
| @@ -135,10 +135,10 @@ func (w servingRetirementWork) With(ctx context.Context, _ hostwrite.Identity, f |
| }, func() error { w.p.inside = true; defer func() { w.p.inside = false }(); return fn() }) |
| } |
| -func servingRetirementFixture(t *testing.T) (*recoveryFixture, *claimServingArtifactRetirement, *servingParkFixture, ClaimLaunchInputs) { |
| +func servingRetirementFixture(t *testing.T, hosts ...string) (*recoveryFixture, *claimServingArtifactRetirement, *servingParkFixture, ClaimLaunchInputs) { |
| t.Helper() |
| - f := newRecoveryFixture(t) |
| - db := provision.NestedDBPath(f.guard.DataDir, recoveryHost) |
| + f := newRecoveryFixture(t, hosts...) |
| + db := provision.NestedDBPath(f.guard.DataDir, f.hostName()) |
| if err := os.MkdirAll(filepath.Dir(db), 0700); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/host/plan12_restore_linux_test.go b/internal/host/plan12_restore_linux_test.go| index ea171df75..52fcf9faf 100644 |
| --- a/internal/host/plan12_restore_linux_test.go |
| +++ b/internal/host/plan12_restore_linux_test.go |
| @@ -406,7 +406,7 @@ func plan12RestoreBaseline(t *testing.T, f *recoveryFixture, extraBytes ...int) |
| if _, err := spec.WriteTo(&out); err != nil { |
| t.Fatal(err) |
| } |
| - key := "instances/" + recoveryHost + "/g-src/0009/" + ltx.FormatFilename(1, 1) |
| + key := "instances/" + f.hostName() + "/g-src/0009/" + ltx.FormatFilename(1, 1) |
| bucket := leasetest.NewVersionedBucket() |
| bucket.AddBucket("data") |
| version := bucket.Put("data", key, out.Bytes()) |
diff --git a/internal/instancectl/claimexec.go b/internal/instancectl/claimexec.go| index 528584872..ad13c1c3b 100644 |
| --- a/internal/instancectl/claimexec.go |
| +++ b/internal/instancectl/claimexec.go |
| @@ -8,6 +8,7 @@ import ( |
| "net" |
| "os" |
| "path/filepath" |
| + "strconv" |
| "time" |
| "github.com/carlosframework/platform/internal/safefile" |
| @@ -84,7 +85,7 @@ func (u ClaimUnits) prepareExecGate(ctx context.Context, host string, p ClaimExe |
| return gate, nil |
| } |
| -func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnitStarted, check func() error) error { |
| +func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s *ClaimUnitStarted, check func() error) error { |
| deadline := time.Now().Add(u.budget()) |
| if d, ok := ctx.Deadline(); ok && d.Before(deadline) { |
| deadline = d |
| @@ -104,10 +105,22 @@ func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnit |
| if err = conn.SetDeadline(deadline); err != nil { |
| return err |
| } |
| + // PrivatePIDs startup can hand MainPID from systemd's setup process to |
| + // the namespace init after start returns. Pin the current main process |
| + // only within the same invocation and cgroup, then authenticate the peer. |
| + state, err := u.show(ctx, s.Unit, "InvocationID", "MainPID", "ControlGroup", "ActiveState") |
| + if err != nil { |
| + return err |
| + } |
| + pid, err := strconv.Atoi(state["MainPID"]) |
| + if err != nil || pid <= 0 || state["InvocationID"] != s.InvocationID || state["ControlGroup"] != "/"+s.ControlGroup || state["ActiveState"] != "active" { |
| + return ErrClaimUnit |
| + } |
| + s.MainPID = pid |
| if err = claimExecPeer(conn, s.MainPID); err != nil { |
| return err |
| } |
| - if err = u.Same(ctx, s); err != nil { |
| + if err = u.Same(ctx, *s); err != nil { |
| return err |
| } |
| var asked ClaimExecPermit |
| @@ -121,7 +134,7 @@ func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnit |
| if err = check(); err != nil { |
| return err |
| } |
| - if err = u.Same(ctx, s); err != nil { |
| + if err = u.Same(ctx, *s); err != nil { |
| return err |
| } |
| // One connection, one permit. Closing/restarting root cannot replay it. |
diff --git a/internal/instancectl/claimexec_rev3_linux_test.go b/internal/instancectl/claimexec_rev3_linux_test.go| index 753c1705b..5a88e9f54 100644 |
| --- a/internal/instancectl/claimexec_rev3_linux_test.go |
| +++ b/internal/instancectl/claimexec_rev3_linux_test.go |
| @@ -13,7 +13,7 @@ import ( |
| ) |
| func TestRev3ReservedUnitChecksAtTrampolineBoundary(t *testing.T) { |
| - for _, name := range []string{"valid", "expired during setup", "wrong peer", "wrong reservation", "replacement invocation"} { |
| + for _, name := range []string{"valid", "expired during setup", "wrong peer", "wrong reservation", "replacement invocation", "main PID handed over"} { |
| t.Run(name, func(t *testing.T) { |
| u, ctl, in, _ := claimUnitFixture(t) |
| dir, err := os.MkdirTemp("", "rg") |
| @@ -31,13 +31,18 @@ func TestRev3ReservedUnitChecksAtTrampolineBoundary(t *testing.T) { |
| original := u.Systemctl |
| u.Systemctl = func(ctx context.Context, args ...string) (string, error) { |
| out, err := original(ctx, args...) |
| + if name == "main PID handed over" && args[0] == "show" && strings.Contains(strings.Join(args, ","), "MainPID") { |
| + ctl.mu.Lock() |
| + ctl.pid = os.Getpid() |
| + ctl.mu.Unlock() |
| + } |
| if args[0] != "start" || err != nil { |
| return out, err |
| } |
| setup = true |
| ctl.mu.Lock() |
| ctl.pid = os.Getpid() |
| - if name == "wrong peer" { |
| + if name == "wrong peer" || name == "main PID handed over" { |
| ctl.pid++ |
| } |
| ctl.mu.Unlock() |
| @@ -66,7 +71,7 @@ func TestRev3ReservedUnitChecksAtTrampolineBoundary(t *testing.T) { |
| return out, nil |
| } |
| l := Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{LiveDir + "/" + in.Host}} |
| - _, err = u.StartReserved(t.Context(), in, l, p, func() error { |
| + started, err := u.StartReserved(t.Context(), in, l, p, func() error { |
| checks++ |
| if setup && name == "expired during setup" { |
| return ErrClaimUnit |
| @@ -79,8 +84,8 @@ func TestRev3ReservedUnitChecksAtTrampolineBoundary(t *testing.T) { |
| return nil |
| }) |
| clientErr := <-received |
| - if name == "valid" { |
| - if err != nil || clientErr != nil || checks != 2 { |
| + if name == "valid" || name == "main PID handed over" { |
| + if err != nil || clientErr != nil || checks != 2 || started.MainPID != os.Getpid() { |
| t.Fatalf("start=%v gate=%v checks=%d", err, clientErr, checks) |
| } |
| source, _, _ := u.launchPaths(in.Host) |
diff --git a/internal/instancectl/claimunit.go b/internal/instancectl/claimunit.go| index aa6832f46..e5e14ba65 100644 |
| --- a/internal/instancectl/claimunit.go |
| +++ b/internal/instancectl/claimunit.go |
| @@ -203,7 +203,7 @@ func (u ClaimUnits) start(ctx context.Context, in ClaimUnit, launch *Launch, che |
| } |
| started := ClaimUnitStarted{Unit: unit, InvocationID: id, ControlGroup: ClaimUnitControlGroup(in.Host), MainPID: pid} |
| if gate != nil { |
| - if err := gate.authorize(ready, u, started, check); err != nil { |
| + if err := gate.authorize(ready, u, &started, check); err != nil { |
| cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), u.budget()) |
| defer cancel() |
| if sameErr := u.Same(cleanup, started); sameErr != nil { |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index 5a2a248fe..090a298df 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -1,4 +1,4 @@ |
| -# Files seed overlay loads LAST. Run 26; FS-R6 legacy dispositions explicitly ruled; other rows remain proposals. |
| +# Files seed overlay loads LAST. Run 27; FS-R6 legacy dispositions explicitly ruled; other rows remain proposals. |
| @legacy c4009ab5fb7d91ab65fde5cda910d796b2a98109831c7e90e1bef18c1a77ff48 internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; diagnostic throttling only; registry remains legacy registry:Hibernate@12209,registry:Hibernate@21380,registry:Hibernate@5124,registry:Hibernate@6794,registry:Hibernate@9200,registry:Host@21449,registry:Host@21507,registry:Host@21879,registry:Host@21972,registry:Host@22707,registry:Host@22789,registry:Host@23854,registry:Host@24033,registry:Host@24082,registry:NextDue@22662,registry:Status@21396,whole:Route@12211,whole:Route@14144,whole:Route@15854,whole:Route@16044,whole:Route@16337,whole:Route@16509,whole:Route@16656,whole:Route@20867,whole:Route@22376,whole:Route@22716,whole:Route@22723,whole:Route@23669,whole:Route@24066,whole:Route@5126,whole:Route@6796,whole:Route@9202 74872baf2607ef6e24d8466dca33aa9f449b45c712377d3474f7d5c87368f62f |
| @legacy fae097bc67df1153c4fe96526cdbbdc0800fe9f989edae9c34e243962fb5c19d internal/activator/servingretry.go ServingRetries.Wrap legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; BeforeReserve refuses only; cleanup deadline bounds lifetime; route identity remains legacy file:Prepare@128,file:Prepare@4325,file:Prepare@4455,file:Read@2888,file:Read@3531,file:Read@5054,file:Read@5991,file:Read@6656,whole:Route@1212,whole:Route@1550,whole:Route@1648,whole:Route@1948,whole:Route@2360,whole:Route@4473,whole:Route@5421,whole:Route@6522,whole:Route@876,whole:Route@954 e0fe1c8acbe4b1631ff9c3ce63dceba8297fbeab55fc6e4ace72be1d2d9d5bfa |
| @legacy e84357c7d4acebbf3a83055c10796752ad67b32f98248f54361a387d2682d7d6 internal/host/relocationflush.go ReleaseFlushFiles.Flush legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; tenant files and env are published data, never authority file:Open@772 7cc12267c16d9d1ae1116c9d10528f97d3983c77fb6486e0aac73142d8999c4b |
| @@ -332,3 +332,6 @@ system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, |
| internal/host/rootexecution_recovery.go recoverRootExecutionBeforeUpdate authority D rev3=PROPOSED-seed; extracted update recovery ordering; root instance-control identity; broker durable controller state then best-effort local park and retirement journals; retained park failures cannot gate update eb97b162b0d712dd2fe8971984353ebc10efe1142077a3c2531781dd969cbbf5 |
| internal/lease/leasetest/s3server.go NewS3Server authority C,J4 rev3=PROPOSED-seed; test-process S3 fixture only, including privileged runner; HTTP query selects simulated versions, GET exposes stored ETag and conditional PUT refuses stale owners under the fixture mutex; not called by production root paths file:Query@479 ac2435bd4d5114736ba9b7e964f62d5fac3dd97eee5f11cbadd4facc342a9583 |
| @coverage internal/lease/leasetest a589d673ded02391c4dfbbe6a9b86890f44522d3985bfa219c19d6ed40a0fd07 |
| +internal/instancectl/claimexec.go claimExecGate.authorize authority C,G,J2,J4 rev3=PROPOSED-seed; root instance-control; systemd MainPID refreshed only within the same active invocation and cgroup at accepted trampoline connection; exact kernel peer match and repeated Same checks before permit 2c7461c0ce1f033f163bb660214b9e906945e75a8067bb1d6b1faa3b6916649d |
| +internal/instancectl/claimunit.go ClaimUnits.start authority C,G,J2,J4 rev3=PROPOSED-seed; root instance-control; retain the refreshed authenticated trampoline MainPID for post-start kernel launch verification and exact-invocation cleanup 7f01cf7ac8fd9db0f74f7b08c412ee7d229ff2e097ae1d69706d8b3c87f1e30d |
| +@coverage internal/instancectl a9b87eaf6abd540b9e80a4f2fccbce59877e4cf9049b47fbf8829b348d6ef14d |