WIP files seed checkpoint 10 (not for merge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+564
−0
internal/host/claimservinghandover_linux_test.go+118 −0internal/host/claimservingpeer_rootrunner_linux_test.go+446 −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 344.338s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.986s
ok github.com/carlosframework/platform/cmd/moveprobe 92.300s
ok github.com/carlosframework/platform/cmd/zddprobe 0.712s
ok github.com/carlosframework/platform/internal/activator 234.070s
ok github.com/carlosframework/platform/internal/agentsock 0.019s
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.361s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.152s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.972s
? 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.012s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.694s
ok github.com/carlosframework/platform/internal/changelog 0.294s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 367.110s
? 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.703s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.134s
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.159s
? 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.121s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.014s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 138.210s
ok github.com/carlosframework/platform/internal/hostactors 0.345s
ok github.com/carlosframework/platform/internal/hostbox 2.241s
ok github.com/carlosframework/platform/internal/hostclaim 0.499s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 4.986s
ok github.com/carlosframework/platform/internal/hostfence 0.039s
ok github.com/carlosframework/platform/internal/hostfiles 10.563s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.981s
ok github.com/carlosframework/platform/internal/hostops 1.457s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.005s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.463s
ok github.com/carlosframework/platform/internal/hostresolve 0.142s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.819s
ok github.com/carlosframework/platform/internal/instanceexec 2.736s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.431s
ok github.com/carlosframework/platform/internal/lease 9.462s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.007s
ok github.com/carlosframework/platform/internal/ledger 0.014s
ok github.com/carlosframework/platform/internal/mail 0.160s
ok github.com/carlosframework/platform/internal/maintenance 0.084s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.540s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.081s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.087s
ok github.com/carlosframework/platform/internal/moves/archive 0.770s
ok github.com/carlosframework/platform/internal/objstore 0.180s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.218s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 13.578s
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.064s
ok github.com/carlosframework/platform/internal/relay 7.254s
ok github.com/carlosframework/platform/internal/release 19.935s
ok github.com/carlosframework/platform/internal/rollout 3.776s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.329s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.878s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.837s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.180s
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.564s
=== 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
--- 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.023s
=== RUN TestT4StorageProbeMinio
2026/10/14 17:59:00 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE772DE387707E, 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.022s
minio contract: PASS
diff --git a/internal/host/claimservinghandover_linux_test.go b/internal/host/claimservinghandover_linux_test.go| new file mode 100644 |
| index 000000000..1b2640714 |
| --- /dev/null |
| +++ b/internal/host/claimservinghandover_linux_test.go |
| @@ -0,0 +1,118 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// Characterize the installation gate still separating the preparation fixture |
| +// from production. ColdHandover parks the whole state directory. A serving |
| +// handover must preserve it before the database-only retirement can be bound. |
| +func TestServingParkColdHandoverRequiresStatePreservation(t *testing.T) { |
| + runServingColdHandoverBoundary(t, nil) |
| +} |
| + |
| +func runServingColdHandoverBoundary(t *testing.T, mount func(*testing.T, string)) { |
| + t.Helper() |
| + f := newServingRootFixture(t, true) |
| + if mount != nil { |
| + mount(t, f.f.guard.DataDir) |
| + } |
| + ctx := t.Context() |
| + inventory := f.p.root.Journals.Inventory |
| + captured := hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp} |
| + in, _, err := inventory.Snapshot(ctx, f.prior.Host, captured) |
| + if err != nil || len(in.Entries) != 1 { |
| + t.Fatal("predecessor state inventory", err) |
| + } |
| + owner := registry.ClaimOwnership{ClaimIncarnation: f.prior.Incarnation, ClaimStamp: f.prior.Stamp, ClaimOwner: in.Entries[0].Ownership, ClaimRowID: "prior-route"} |
| + reg := restartTestRegistry(t) |
| + row := registry.Route{Host: f.prior.Host, Account: f.admitted.Claim.Holder.Account, App: f.admitted.Claim.Holder.App, Kind: "instance", DBPath: f.db, Socket: provision.NestedSocket(f.prior.Host), ClaimOwnership: owner} |
| + if err := reg.Upsert(row); err != nil { |
| + t.Fatal(err) |
| + } |
| + in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: row.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}) |
| + if err := inventory.WriteStopInventory(ctx, in); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, revision, err := inventory.Snapshot(ctx, row.Host, captured) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Fixture bootstrap only. The transition below uses production handover, |
| + // root inventory refresh, exact receipt verification and the typed parker. |
| + f.mutateRoot(func(m map[string]any) { m["Installation"].(map[string]any)["InventoryRevision"] = revision }) |
| + guard := f.f.guard |
| + guard.Root = hostguard.NewRootAuthorityWithInventory(f.rootDisk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| + _, rev, err := inventory.Snapshot(ctx, host, c) |
| + return rev, err |
| + }) |
| + stateDir := filepath.Dir(f.db) |
| + for name, body := range map[string]string{"files/upload": "tenant upload", "env": "tenant configuration"} { |
| + path := filepath.Join(stateDir, name) |
| + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before := f.f.read() |
| + next := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: "7", ETag: before.ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session} |
| + handover := hostbox.ColdHandover{Guard: guard, Claims: f.f.claims, Registry: reg, Inventory: inventory, Launches: f.f.journal, Roots: f.p.root.Roots, JournalDir: filepath.Join(t.TempDir(), "handover"), VerifyReceipt: verifyRetirementReceipt} |
| + err = handover.Run(ctx, f.receipt.Target, next) |
| + if mount != nil { |
| + if !errors.Is(err, unix.EXDEV) { |
| + t.Fatalf("production cold handover across the instance bind: %v", err) |
| + } |
| + for name, want := range map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} { |
| + body, err := os.ReadFile(filepath.Join(stateDir, name)) |
| + if err != nil || string(body) != want { |
| + t.Fatalf("failed handover changed %s: %q %v", name, body, err) |
| + } |
| + } |
| + if f.f.read().ETag != before.ETag { |
| + t.Fatal("failed handover rewrote the claim") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal("production cold handover", err) |
| + } |
| + if _, err := os.Lstat(stateDir); !os.IsNotExist(err) { |
| + t.Fatalf("cold handover now preserves state; replace this gate witness with the serving integration: %v", err) |
| + } |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", next.Host, next.Incarnation, next.Stamp, next.Box))) |
| + parked := filepath.Join(guard.DataDir, ".parked", next.Incarnation, next.Host, "handover-"+hex.EncodeToString(sum[:])+"-old", "data", "instances", provision.InstanceID(next.Host)) |
| + for name, want := range map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} { |
| + body, err := os.ReadFile(filepath.Join(parked, name)) |
| + if err != nil || string(body) != want { |
| + t.Fatalf("cold handover retained %s: %q %v", name, body, err) |
| + } |
| + } |
| + f.p.root.Stop.Coordinator.Guard = guard |
| + if _, err := f.park(); err == nil { |
| + t.Fatal("database retirement accepted the removed source") |
| + } |
| + after := f.f.read() |
| + if after.ETag != before.ETag { |
| + t.Fatal("handover or refused park rewrote claim tags") |
| + } |
| + if names, err := f.disk.Names(); err != nil || len(names) != 0 { |
| + t.Fatalf("removed source gained serving retirement authority: %v %v", names, err) |
| + } |
| +} |
diff --git a/internal/host/claimservingpeer_rootrunner_linux_test.go b/internal/host/claimservingpeer_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..bd21f49d0 |
| --- /dev/null |
| +++ b/internal/host/claimservingpeer_rootrunner_linux_test.go |
| @@ -0,0 +1,446 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "os" |
| + "os/exec" |
| + "os/user" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// The control socket coordinates the test only. Every effect request uses the |
| +// production journal client and its separately authenticated Unix connection. |
| +type servingPeerCommand struct { |
| + Action string |
| + Current, Prior servingArtifactIdentity |
| + Target hostguard.FenceTarget |
| + ID string |
| +} |
| + |
| +type servingPeerReply struct { |
| + Peer StopPeer |
| + Authorization servingParkAuthorization |
| + Launch hostbox.LaunchInventory |
| + Error string |
| +} |
| + |
| +// Invoked only by the disposable edge unit, or its same-UID sibling control. |
| +func TestRootServingParkPeerPayload(t *testing.T) { |
| + control, socket := os.Getenv("SERVING_PEER_CONTROL"), os.Getenv("SERVING_PEER_SOCKET") |
| + if control == "" || socket == "" || os.Geteuid() == 0 { |
| + t.Fatal("invoke through TestRootServingParkAuthenticatedPeer") |
| + } |
| + call := rootJournalClient(socket) |
| + if os.Getenv("SERVING_PEER_SIBLING") == "1" { |
| + var q servingPeerCommand |
| + if err := json.NewDecoder(os.Stdin).Decode(&q); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := (hostbox.ServingParkClient{Call: call}).Park(t.Context(), q.Current, q.Prior); err == nil { |
| + t.Fatal("same-UID sibling was authorized") |
| + } |
| + return |
| + } |
| + conn, err := net.DialTimeout("unix", control, 5*time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.Close() |
| + if err := conn.SetDeadline(time.Now().Add(2 * time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + start, err := claimConstructionProcessStart() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + controller, err := (productionStopControllers{}).VerifiedController(t.Context(), os.Getpid()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + peer := StopPeer{UID: uint32(os.Geteuid()), PID: os.Getpid(), ProcessStart: start, BootID: strings.TrimSpace(string(boot)), ControllerID: controller} |
| + encode, decode := json.NewEncoder(conn), json.NewDecoder(conn) |
| + if err := encode.Encode(servingPeerReply{Peer: peer}); err != nil { |
| + t.Fatal(err) |
| + } |
| + client := hostbox.ServingParkClient{Call: call} |
| + var manager *cgroup.Manager |
| + for { |
| + var q servingPeerCommand |
| + if err := decode.Decode(&q); errors.Is(err, io.EOF) { |
| + return |
| + } else if err != nil { |
| + t.Fatal(err) |
| + } |
| + var reply servingPeerReply |
| + var callErr error |
| + switch q.Action { |
| + case "park": |
| + reply.Authorization, callErr = client.Park(t.Context(), q.Current, q.Prior) |
| + case "complete": |
| + callErr = client.Complete(t.Context(), q.ID, q.Current) |
| + case "sibling": |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.CommandContext(t.Context(), binary, "-test.run=^TestRootServingParkPeerPayload$", "-test.v", "-test.timeout=20s") |
| + cmd.Env = append(os.Environ(), "SERVING_PEER_SIBLING=1") |
| + body, err := json.Marshal(q) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd.Stdin = strings.NewReader(string(body)) |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + callErr = fmt.Errorf("sibling: %w: %s", err, out) |
| + } |
| + case "prelaunch": |
| + if manager == nil { |
| + manager = cgroup.Setup(t.Logf) |
| + if manager == nil { |
| + t.Fatal("edge unit has no delegated cgroup") |
| + } |
| + } |
| + target := q.Target |
| + target.BootID, target.ControllerID = peer.BootID, peer.ControllerID |
| + roles := &ClaimRoleAssembly{Manager: manager} |
| + parent, group, err := roles.preparePrelaunchGroup(claimContainmentName(target), cgroup.TierDefault) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = parent.Close() }) |
| + launch := hostbox.LaunchInventory{Target: target, ControllerPID: peer.PID, ProcessStart: peer.ProcessStart, NoState: true, Groups: []hostbox.LaunchGroup{group}} |
| + journal, _ := hostbox.RemoteJournals(call) |
| + // Each forged observation must be refused before any launch record. |
| + for _, field := range []string{"pid", "start", "controller", "boot", "inode", "path"} { |
| + bad := launch |
| + bad.Groups = append([]hostbox.LaunchGroup(nil), launch.Groups...) |
| + switch field { |
| + case "pid": |
| + bad.ControllerPID++ |
| + case "start": |
| + bad.ProcessStart += "0" |
| + case "controller": |
| + bad.Target.ControllerID = "forged" |
| + case "boot": |
| + bad.Target.BootID = "forged" |
| + case "inode": |
| + bad.Groups[0].Inode++ |
| + case "path": |
| + bad.Groups[0].RelativePath += "-foreign" |
| + } |
| + if err := journal.Prelaunch(t.Context(), bad); err == nil { |
| + t.Fatalf("forged prelaunch %s was authorized", field) |
| + } |
| + } |
| + callErr = journal.Prelaunch(t.Context(), launch) |
| + reply.Launch = launch |
| + default: |
| + t.Fatalf("unknown test action %q", q.Action) |
| + } |
| + if callErr != nil { |
| + reply.Error = callErr.Error() |
| + } |
| + if err := encode.Encode(reply); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// This witness needs an unused carlos-edge.service name because production |
| +// authentication resolves that exact unit. It refuses existing deployments. |
| +// Claim, installation, predecessor stop/start and roster/census data are fixtures; |
| +// peer authentication, prelaunch containment, park authority and IO are real. |
| +func TestRootServingParkAuthenticatedPeer(t *testing.T) { |
| + servingRootRunner(t) |
| + pid1, err := os.ReadFile("/proc/1/comm") |
| + if err != nil || strings.TrimSpace(string(pid1)) != "systemd" { |
| + t.Fatal("root runner needs systemd as PID 1") |
| + } |
| + probe, probeErr := exec.CommandContext(t.Context(), "systemctl", "show", "carlos-edge.service", "-p", "LoadState", "--value").Output() |
| + if strings.TrimSpace(string(probe)) != "not-found" { |
| + t.Fatalf("refusing existing or unreadable carlos-edge.service: %s %v", probe, probeErr) |
| + } |
| + // The runner is disposable, but only a user created here is removed here. |
| + account, err := user.Lookup("carlos") |
| + if _, absent := err.(user.UnknownUserError); absent { |
| + if _, groupErr := user.LookupGroup("carlos"); groupErr == nil { |
| + t.Fatal("refusing pre-existing carlos group without its user") |
| + } else if _, unknown := groupErr.(user.UnknownGroupError); !unknown { |
| + t.Fatal(groupErr) |
| + } |
| + out, createErr := exec.CommandContext(t.Context(), "useradd", "--system", "--no-create-home", "--user-group", "--shell", "/usr/sbin/nologin", "carlos").CombinedOutput() |
| + if createErr != nil { |
| + t.Fatalf("create runner user: %v %s", createErr, out) |
| + } |
| + t.Cleanup(func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + if out, err := exec.CommandContext(ctx, "userdel", "carlos").CombinedOutput(); err != nil { |
| + t.Errorf("remove runner user: %v %s", err, out) |
| + return |
| + } |
| + if _, err := user.LookupGroup("carlos"); err == nil { |
| + if out, err := exec.CommandContext(ctx, "groupdel", "carlos").CombinedOutput(); err != nil { |
| + t.Errorf("remove runner group: %v %s", err, out) |
| + } |
| + } |
| + }) |
| + account, err = user.Lookup("carlos") |
| + } |
| + if err != nil || account == nil || account.Uid == "0" { |
| + t.Fatalf("runner service user: %v", err) |
| + } |
| + root, err := os.MkdirTemp("/var/lib", "carlos-rootrunner-peer-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := os.RemoveAll(root); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if err := os.Chmod(root, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := os.ReadFile(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + installed := filepath.Join(root, "runner.test") |
| + if err := os.WriteFile(installed, body, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := newServingRootFixture(t, true) |
| + servingRunnerMount(t, f.f.guard.DataDir) |
| + // Refresh the state inventory after the bind mount. This remains a fixture |
| + // installation; it does not exercise the production installation door. |
| + f.install(f.admitted) |
| + state, err := rootstate.Open(filepath.Join(root, "parks"), true, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = state.Close() }) |
| + f.p.ledger.state = state |
| + f.p.ledger.fs = newServingParkFS(f.f.guard.DataDir, state) |
| + coordinator := f.f.root(nil) |
| + coordinator.Evidence = f.f.claims |
| + production, err := NewProductionClaimStopHandler(coordinator, f.f.journal, productionStopControllers{}, f.p.root.Roots) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.p.root.Stop = production.(ClaimStopHTTP) |
| + f.p.root.Reconcile.Box, f.p.root.Reconcile.Enrollment = f.actor.Box, f.actor.Enrollment |
| + var admitted atomic.Bool |
| + admitted.Store(true) |
| + f.p.root.RequireNewWork = func(context.Context) error { |
| + if !admitted.Load() { |
| + return hostbox.ErrUnsupported |
| + } |
| + return nil |
| + } |
| + h := servingTransportRoot(t, f) |
| + path := filepath.Join(root, "root.sock") |
| + listener, err := net.Listen("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(path, 0666); err != nil { |
| + listener.Close() |
| + t.Fatal(err) |
| + } |
| + server := &instancectl.Server{ClaimStop: h, 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) |
| + } |
| + }) |
| + controlPath := filepath.Join(root, "control.sock") |
| + control, err := net.ListenUnix("unix", &net.UnixAddr{Name: controlPath, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { control.Close() }) |
| + if err := os.Chmod(controlPath, 0666); 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, "ReadWritePaths=") || strings.HasPrefix(line, "Restart=") || strings.HasPrefix(line, "Slice=") { |
| + continue |
| + } |
| + lines = append(lines, line) |
| + } |
| + // Add a Service section because the embedded unit ends in Install. |
| + content := strings.Join(lines, "\n") + fmt.Sprintf("\n[Service]\nType=simple\nRestart=no\nTimeoutStopSec=10s\nExecStart=%s -test.run=^TestRootServingParkPeerPayload$ -test.v -test.timeout=2m\nEnvironment=SERVING_PEER_CONTROL=%s SERVING_PEER_SOCKET=%s\nIPAddressDeny=any\n", installed, controlPath, path) |
| + unitPath := "/run/systemd/system/carlos-edge.service" |
| + unit, err := os.OpenFile(unitPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + if out, err := exec.CommandContext(ctx, "systemctl", "stop", "carlos-edge.service").CombinedOutput(); err != nil { |
| + t.Errorf("stop test edge: %v %s", err, out) |
| + } |
| + if err := os.Remove(unitPath); err != nil { |
| + t.Error(err) |
| + } |
| + if out, err := exec.CommandContext(ctx, "systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| + t.Errorf("reload test edge: %v %s", err, out) |
| + } |
| + }) |
| + _, writeErr := io.WriteString(unit, content) |
| + if err := errors.Join(writeErr, unit.Close()); err != nil { |
| + t.Fatal(err) |
| + } |
| + servingRunnerSystemctl(t, "daemon-reload") |
| + connect := func() (net.Conn, *json.Encoder, *json.Decoder, StopPeer) { |
| + t.Helper() |
| + servingRunnerSystemctl(t, "start", "carlos-edge.service") |
| + if err := control.SetDeadline(time.Now().Add(15 * time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + conn, err := control.Accept() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { conn.Close() }) |
| + if err := conn.SetDeadline(time.Now().Add(90 * time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + enc, dec := json.NewEncoder(conn), json.NewDecoder(conn) |
| + var hello servingPeerReply |
| + if err := dec.Decode(&hello); err != nil { |
| + t.Fatal(err) |
| + } |
| + peer, err := h.Stop.Authenticate(t.Context(), conn) |
| + if err != nil || peer != hello.Peer || strconv.FormatUint(uint64(peer.UID), 10) != account.Uid { |
| + t.Fatalf("production peer: %+v %+v %v", peer, hello.Peer, err) |
| + } |
| + return conn, enc, dec, peer |
| + } |
| + old, _, _, first := connect() |
| + servingRunnerSystemctl(t, "stop", "carlos-edge.service") |
| + _, encode, decode, next := connect() |
| + if first == next { |
| + t.Fatal("edge restart kept the process tuple") |
| + } |
| + if _, err := f.p.parkServingDatabase(t.Context(), old, f.current, f.prior); err == nil { |
| + t.Fatal("old controller connection authorized retirement") |
| + } |
| + command := func(q servingPeerCommand, refused bool) servingPeerReply { |
| + t.Helper() |
| + if err := encode.Encode(q); err != nil { |
| + t.Fatal(err) |
| + } |
| + var reply servingPeerReply |
| + if err := decode.Decode(&reply); err != nil { |
| + t.Fatal(err) |
| + } |
| + if (reply.Error != "") != refused { |
| + t.Fatalf("%s: expected refusal=%v: %s", q.Action, refused, reply.Error) |
| + } |
| + return reply |
| + } |
| + q := servingPeerCommand{Action: "park", Current: f.current, Prior: f.prior} |
| + if _, err := (hostbox.ServingParkClient{Call: rootJournalClient(path)}).Park(t.Context(), f.current, f.prior); err == nil { |
| + t.Fatal("root caller authorized retirement") |
| + } |
| + q.Action = "sibling" |
| + command(q, false) |
| + q.Action = "park" |
| + admitted.Store(false) |
| + command(q, true) |
| + admitted.Store(true) |
| + bad := q |
| + bad.Current.Generation = "foreign-generation" |
| + command(bad, true) |
| + before, err := os.ReadFile(f.db) |
| + if err != nil || string(before) != "served bytes" { |
| + t.Fatal("refusal changed source", err) |
| + } |
| + names, err := state.Names() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range names { |
| + if name != "namespace.lock" { |
| + t.Fatalf("refusal wrote park authority: %s", name) |
| + } |
| + } |
| + target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor}) |
| + pre := command(servingPeerCommand{Action: "prelaunch", Target: target}, false) |
| + actual, err := f.f.journal.Read(t.Context(), pre.Launch.Target) |
| + if err != nil || actual.ControllerPID != next.PID || actual.ProcessStart != next.ProcessStart || len(actual.Groups) != 1 || actual.Groups[0] != pre.Launch.Groups[0] { |
| + t.Fatalf("prelaunch not retained: %+v %v", actual, err) |
| + } |
| + parked := command(q, false).Authorization |
| + if parked.ID == "" { |
| + t.Fatal("empty park identity") |
| + } |
| + command(servingPeerCommand{Action: "complete", ID: parked.ID, Current: f.current}, true) |
| + f.tags(f.current.Stamp) |
| + command(servingPeerCommand{Action: "complete", ID: parked.ID, Current: f.current}, false) |
| + r, err := f.p.ledger.read(parked.ID) |
| + if err != nil || !r.Complete || r.Disposition != servingParkRetain { |
| + t.Fatalf("completed park: %+v %v", r, err) |
| + } |
| + if err := f.p.ledger.fs.CheckParked(&r); err != nil { |
| + t.Fatal(err) |
| + } |
| + for suffix, want := range map[string]string{"": "served bytes", ".gen": "G", "-wal": "WAL", "-shm": "SHM"} { |
| + if _, err := os.Lstat(f.db + suffix); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("park left source", suffix, err) |
| + } |
| + body, err := os.ReadFile(filepath.Join(f.f.guard.DataDir, "instances", ".serving-parked", parked.ID, filepath.Base(f.db)+suffix)) |
| + if err != nil || string(body) != want { |
| + t.Fatal("park changed bytes", suffix, err) |
| + } |
| + } |
| + if replay := command(q, false).Authorization; replay != parked { |
| + t.Fatal("authenticated replay changed retirement") |
| + } |
| +} |
| + |
| +// Exercise the actual earlier cold-handover parker on the nested mount. This |
| +// is a refusal witness, not successful serving installation or Restore. |
| +func TestRootServingParkColdHandoverBoundary(t *testing.T) { |
| + servingRootRunner(t) |
| + runServingColdHandoverBoundary(t, servingRunnerMount) |
| +} |