WIP files seed checkpoint 25 (not for merge)
Authority-review fixes FS-R1..R6 (wrapped-handler crash, recovery ordering and paging, per-journal isolation, pressure budget, census reclasses); Task 1 live-agent socket/client, killed-controller settlement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
37 files changed,
+1753
−130
internal/activator/cleanup.go+67 −0internal/activator/cleanup_test.go+62 −0internal/activator/servingretry.go+3 −3internal/host/agent.go+43 −4internal/host/claimfilesseed.go+18 −1internal/host/claimfilesseed_command.go+11 −11internal/host/claimfilesseed_delegation_linux.go+93 −0internal/host/claimfilesseed_integration_linux_test.go+12 −1internal/host/claimfilesseed_killed_linux.go+90 −0internal/host/claimfilesseed_killed_linux_test.go+86 −0internal/host/claimfilesseed_live_rootrunner_linux_test.go+153 −0internal/host/claimfilesseed_restore_linux_test.go+5 −2internal/host/claimfilesseed_runtime.go+1 −1internal/host/claimfilesseed_runtime_test.go+35 −0internal/host/claimfilesseed_transport_linux.go+301 −0internal/host/claimfilesseed_transport_linux_test.go+244 −0internal/host/claimfilesseed_transport_other.go+17 −0internal/host/claimlaunch_production.go+2 −0internal/host/claimrecovery_rulings_test.go+82 −0internal/host/claimretire_transport.go+18 −27internal/host/claimservingmaintenance.go+21 −2internal/host/claimservingmaintenance_linux.go+3 −11internal/host/claimservingpark_reap_test.go+32 −0internal/host/claimservingpark_records.go+0 −26internal/host/claimservingpark_recovery.go+15 −4internal/host/claimservingpark_rootrunner_linux_test.go+18 −1internal/host/claimservingpark_transport.go+4 −0internal/host/claimservingpeer_rootrunner_linux_test.go+182 −2internal/host/claimstop_kernel_linux.go+3 −0internal/host/claimstop_run.go+5 −2internal/host/rootexecution_recovery.go+8 −2internal/hostbox/servinghandover.go+8 −4internal/hostbox/servinghandover_linux_test.go+27 −0internal/provision/plan14_legacy_identity_test.go+12 −3internal/provision/plan14_root_reads_test.go+2 −0internal/provision/testdata/plan14-root-reads-seed.tsv+54 −23system_test.go+16 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? 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 304.111s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.735s
ok github.com/carlosframework/platform/cmd/moveprobe 93.287s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 249.810s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
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.482s
ok github.com/carlosframework/platform/internal/apperr 0.074s
ok github.com/carlosframework/platform/internal/applog 0.128s
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.660s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.703s
ok github.com/carlosframework/platform/internal/changelog 0.304s
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 391.944s
? 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.934s
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.002s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.365s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.003s
ok github.com/carlosframework/platform/internal/edge 26.780s
? 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.134s
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.010s
ok github.com/carlosframework/platform/internal/geoip 0.031s
ok github.com/carlosframework/platform/internal/host 163.293s
ok github.com/carlosframework/platform/internal/hostactors 0.367s
ok github.com/carlosframework/platform/internal/hostbox 2.470s
ok github.com/carlosframework/platform/internal/hostclaim 0.520s
ok github.com/carlosframework/platform/internal/hostevidence 0.014s
ok github.com/carlosframework/platform/internal/hostexec 4.939s
ok github.com/carlosframework/platform/internal/hostfence 0.030s
ok github.com/carlosframework/platform/internal/hostfiles 10.514s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.024s
ok github.com/carlosframework/platform/internal/hostops 1.597s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.014s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.478s
ok github.com/carlosframework/platform/internal/hostresolve 0.140s
ok github.com/carlosframework/platform/internal/hostwrite 0.014s
ok github.com/carlosframework/platform/internal/infraview 0.014s
ok github.com/carlosframework/platform/internal/instancectl 4.163s
ok github.com/carlosframework/platform/internal/instanceexec 2.727s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.020s
ok github.com/carlosframework/platform/internal/layout 1.531s
ok github.com/carlosframework/platform/internal/lease 9.817s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.019s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.165s
ok github.com/carlosframework/platform/internal/maintenance 0.090s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.562s
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.197s
ok github.com/carlosframework/platform/internal/moves/archive 0.780s
ok github.com/carlosframework/platform/internal/objstore 0.177s
ok github.com/carlosframework/platform/internal/ops 0.176s
ok github.com/carlosframework/platform/internal/park 0.210s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 16.566s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.991s
ok github.com/carlosframework/platform/internal/relay 7.239s
ok github.com/carlosframework/platform/internal/release 20.433s
ok github.com/carlosframework/platform/internal/rollout 3.767s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.008s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.575s
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.489s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.825s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.083s
ok github.com/carlosframework/platform/internal/status 0.040s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.244s
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.006s
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.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.556s
=== 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.024s
=== RUN TestT4StorageProbeMinio
2026/10/15 16:06:54 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEBFA46B1277BE, 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.021s
minio contract: PASS
diff --git a/internal/activator/cleanup.go b/internal/activator/cleanup.go| new file mode 100644 |
| index 000000000..a472868cd |
| --- /dev/null |
| +++ b/internal/activator/cleanup.go |
| @@ -0,0 +1,67 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "sync" |
| + "time" |
| +) |
| + |
| +type cleanupBudgetKey struct{} |
| +type cleanupContextKey struct{} |
| +type cleanupBudget struct { |
| + mu sync.Mutex |
| + base context.Context |
| + source context.Context |
| + parent *cleanupBudget |
| + limit time.Duration |
| + ctx context.Context |
| + cancel context.CancelFunc |
| +} |
| + |
| +func (b *cleanupBudget) start() context.Context { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + if b.ctx == nil { |
| + base := b.base |
| + if b.parent != nil && b.parent.source.Err() != nil { |
| + base = b.parent.start() |
| + } |
| + b.ctx, b.cancel = context.WithTimeout(base, b.limit) |
| + } |
| + return b.ctx |
| +} |
| + |
| +// WithCleanupBudget shares the cancellation grace period across a request, |
| +// its nested failure paths, and the owner's final join. Normal work is untimed. |
| +func WithCleanupBudget(ctx context.Context, limit time.Duration) (context.Context, context.CancelFunc) { |
| + parent, _ := ctx.Value(cleanupBudgetKey{}).(*cleanupBudget) |
| + b := &cleanupBudget{base: context.WithoutCancel(ctx), source: ctx, parent: parent, limit: limit} |
| + stop := context.AfterFunc(ctx, func() { b.start() }) |
| + return context.WithValue(ctx, cleanupBudgetKey{}, b), func() { stop(); b.start(); b.cancel() } |
| +} |
| + |
| +// CleanupContext keeps an existing cleanup deadline. Cancellation starts the |
| +// owner's single grace period; it never grants each nested call a fresh one. |
| +func CleanupContext(ctx context.Context, limit time.Duration) (context.Context, context.CancelFunc) { |
| + if ctx.Value(cleanupContextKey{}) != nil { |
| + return context.WithTimeout(ctx, limit) |
| + } |
| + base := context.WithoutCancel(ctx) |
| + var stop func() bool |
| + if b, ok := ctx.Value(cleanupBudgetKey{}).(*cleanupBudget); ok { |
| + if ctx.Err() != nil { |
| + base = b.start() |
| + } |
| + cleanup, cancel := context.WithTimeout(base, limit) |
| + stop = context.AfterFunc(ctx, func() { budget := b.start(); context.AfterFunc(budget, cancel) }) |
| + return context.WithValue(cleanup, cleanupContextKey{}, true), func() { stop(); cancel() } |
| + } |
| + // Callers without an owner budget still cannot extend an explicit deadline. |
| + if deadline, ok := ctx.Deadline(); ok { |
| + bounded, cancel := context.WithDeadline(base, deadline) |
| + cleanup, done := context.WithTimeout(bounded, limit) |
| + return context.WithValue(cleanup, cleanupContextKey{}, true), func() { done(); cancel() } |
| + } |
| + cleanup, cancel := context.WithTimeout(base, limit) |
| + return context.WithValue(cleanup, cleanupContextKey{}, true), cancel |
| +} |
diff --git a/internal/activator/cleanup_test.go b/internal/activator/cleanup_test.go| new file mode 100644 |
| index 000000000..06e30357c |
| --- /dev/null |
| +++ b/internal/activator/cleanup_test.go |
| @@ -0,0 +1,62 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCleanupBudgetSharedAfterCancellation(t *testing.T) { |
| + parent, stop := context.WithCancel(t.Context()) |
| + ctx, finish := WithCleanupBudget(parent, 80*time.Millisecond) |
| + defer finish() |
| + stop() |
| + first, cancel := CleanupContext(ctx, time.Second) |
| + defer cancel() |
| + deadline, ok := first.Deadline() |
| + if !ok || time.Until(deadline) > 100*time.Millisecond { |
| + t.Fatal("cleanup escaped shutdown budget") |
| + } |
| + nested, done := CleanupContext(first, time.Hour) |
| + defer done() |
| + if next, _ := nested.Deadline(); next.After(deadline) { |
| + t.Fatal("nested cleanup extended deadline") |
| + } |
| + <-first.Done() |
| + later, end := CleanupContext(ctx, time.Second) |
| + defer end() |
| + if later.Err() == nil { |
| + t.Fatal("cleanup budget restarted") |
| + } |
| +} |
| + |
| +func TestCleanupBudgetDoesNotLimitNormalOperation(t *testing.T) { |
| + ctx, finish := WithCleanupBudget(t.Context(), time.Millisecond) |
| + defer finish() |
| + time.Sleep(3 * time.Millisecond) |
| + cleanup, cancel := CleanupContext(ctx, time.Second) |
| + defer cancel() |
| + if cleanup.Err() != nil || ctx.Err() != nil { |
| + t.Fatal("normal request exhausted shutdown budget") |
| + } |
| +} |
| + |
| +func TestCleanupBudgetNestedOwnerUsesShutdownDeadline(t *testing.T) { |
| + parent, stop := context.WithCancel(t.Context()) |
| + parent, finish := WithCleanupBudget(parent, 60*time.Millisecond) |
| + defer finish() |
| + request, cancel := context.WithCancel(parent) |
| + defer cancel() |
| + request, end := WithCleanupBudget(request, time.Second) |
| + defer end() |
| + stop() |
| + owner, done := CleanupContext(parent, time.Second) |
| + defer done() |
| + handler, joined := CleanupContext(request, time.Second) |
| + defer joined() |
| + a, _ := owner.Deadline() |
| + b, _ := handler.Deadline() |
| + if !a.Equal(b) { |
| + t.Fatalf("handler extended shared deadline: %v %v", a, b) |
| + } |
| +} |
diff --git a/internal/activator/servingretry.go b/internal/activator/servingretry.go| index 7e4aa3dd1..d0ab0c193 100644 |
| --- a/internal/activator/servingretry.go |
| +++ b/internal/activator/servingretry.go |
| @@ -97,7 +97,7 @@ func (d ServingRetries) Wrap(base *ClaimRuntime) (*ClaimRuntime, error) { |
| cleanupObservation := o |
| defer func() { |
| if resultErr != nil { |
| - recovery, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + recovery, cancel := CleanupContext(ctx, 10*time.Second) |
| defer cancel() |
| resultErr = errors.Join(resultErr, base.FailedAdmission(recovery, cleanupObservation, resultErr)) |
| } |
| @@ -133,7 +133,7 @@ func (d ServingRetries) Wrap(base *ClaimRuntime) (*ClaimRuntime, error) { |
| err = hostbox.ErrChanged |
| } |
| if err != nil { |
| - recovery, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + recovery, cancel := CleanupContext(ctx, 10*time.Second) |
| defer cancel() |
| // An ambiguous admission or bind is settled using the stored attempt, never refunded. |
| fresh, readErr := hostclaim.Read(recovery, d.Claims, o.Host) |
| @@ -212,7 +212,7 @@ func (d ServingRetries) Wrap(base *ClaimRuntime) (*ClaimRuntime, error) { |
| return err |
| } |
| s.Failed = func(ctx context.Context, _ int) error { |
| - recovery, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) |
| + recovery, cancel := CleanupContext(ctx, 10*time.Second) |
| defer cancel() |
| fresh, err := hostclaim.Read(recovery, d.Claims, o.Host) |
| if err != nil { |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 448379b50..7caebda1d 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -159,6 +159,7 @@ import ( |
| "net/http" |
| "os" |
| "os/exec" |
| + "os/signal" |
| "path" |
| "path/filepath" |
| "runtime" |
| @@ -166,6 +167,7 @@ import ( |
| "strings" |
| "sync" |
| "sync/atomic" |
| + "syscall" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| @@ -664,9 +666,22 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| close(started) |
| } |
| + safeClaimClose := true |
| + var shutdownCtx context.Context |
| + var shutdownCancel context.CancelFunc |
| defer func() { |
| + if shutdownCancel != nil { |
| + defer shutdownCancel() |
| + } |
| cancelAgent() |
| - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + if !safeClaimClose { |
| + return |
| + } |
| + base := shutdownCtx |
| + if base == nil { |
| + base = context.Background() |
| + } |
| + ctx, cancel := context.WithTimeout(base, 5*time.Second) |
| defer cancel() |
| if err := claims.close(ctx); err != nil { |
| log.Printf("agent: regional claims shutdown: %v", err) |
| @@ -813,6 +828,21 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| go runDuties() |
| } |
| + // Seed handlers borrow this agent's resources and finish before they close. |
| + seedCtx, seedCancel := signal.NotifyContext(claimCtx, syscall.SIGTERM, os.Interrupt) |
| + defer seedCancel() |
| + seedCtx, seedFinish := activator.WithCleanupBudget(seedCtx, 30*time.Second) |
| + defer seedFinish() |
| + stopSeed := context.AfterFunc(startupCtx, seedCancel) |
| + defer stopSeed() |
| + var seedServer *filesSeedServer |
| + if claims.Writes.Enabled && os.Getenv("CARLOS_HOST_CLAIM_ORIGINS") == "1" && svc != nil { |
| + seedServer, err = startAgentFilesSeed(seedCtx, svc.Startup, svc) |
| + if err != nil { |
| + log.Printf("Files seed failed: %s.", err) |
| + } |
| + } |
| + |
| // The same serving loop as `carlos edge`, with the activator injected. |
| // Caching applies here too: cached hits still avoid the activator, which |
| // is the entire point of checking the cache before the hibernation branch. |
| @@ -884,8 +914,18 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| go usageLoop(m.Drain, am) |
| }, edgeOpts...) |
| + shutdownCtx, shutdownCancel = activator.CleanupContext(context.Background(), 60*time.Second) |
| cancelStartup() |
| - joinCtx, joinCancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + seedCancel() |
| + seedJoin, seedJoinCancel := activator.CleanupContext(seedCtx, 30*time.Second) |
| + seedErr := seedServer.close(seedJoin) |
| + seedJoinCancel() |
| + if seedErr != nil { |
| + safeClaimClose = false |
| + log.Printf("Files seed failed: %s.", seedErr) |
| + os.Exit(1) |
| + } |
| + joinCtx, joinCancel := context.WithTimeout(shutdownCtx, 5*time.Second) |
| defer joinCancel() |
| joinAgentStartup(joinCtx, startupDone) |
| joinAgentStartup(joinCtx, dutiesDone) |
| @@ -893,8 +933,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // The edge has drained; take the children down too (they're this process's |
| // children — orphaning them would strand their leases until TTL expiry). |
| if svc != nil { |
| - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| - defer cancel() |
| + ctx := shutdownCtx |
| svc.Shutdown(ctx) |
| // Then the scheduler, under the same budget (spec §8.6, Task 11): an |
| // attempt whose swap Shutdown just cancelled finishes its done(), its |
diff --git a/internal/host/claimfilesseed.go b/internal/host/claimfilesseed.go| index 9847e3e41..880e912ba 100644 |
| --- a/internal/host/claimfilesseed.go |
| +++ b/internal/host/claimfilesseed.go |
| @@ -215,7 +215,7 @@ func (e *filesSeedExecutor) host(ctx context.Context, o hostclaim.Observed, out |
| return err |
| } |
| defer func() { |
| - reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), hostbox.RequestLimit) |
| + reportCtx, cancel := activator.CleanupContext(ctx, hostbox.RequestLimit) |
| defer cancel() |
| fresh, err := hostclaim.Read(reportCtx, a.Claims, o.Host) |
| if err == nil { |
| @@ -264,3 +264,20 @@ func (e *filesSeedExecutor) host(ctx context.Context, o hostclaim.Observed, out |
| _, err = fmt.Fprintf(out, "Host %q: seeded.\n", o.Host) |
| return err |
| } |
| + |
| +// close is called only after the serial handler has joined. A failed private |
| +// runtime stays retained until it joins; shared owners must not close beneath it. |
| +func (e *filesSeedExecutor) close(ctx context.Context) error { |
| + e.mu.Lock() |
| + defer e.mu.Unlock() |
| + if e.running { |
| + return activator.ErrFenceHeld |
| + } |
| + var result error |
| + for _, pending := range e.pending { |
| + if pending.wake != nil { |
| + result = errors.Join(result, pending.wake.Close(ctx)) |
| + } |
| + } |
| + return result |
| +} |
diff --git a/internal/host/claimfilesseed_command.go b/internal/host/claimfilesseed_command.go| index fa1b4112f..f67725c76 100644 |
| --- a/internal/host/claimfilesseed_command.go |
| +++ b/internal/host/claimfilesseed_command.go |
| @@ -4,7 +4,6 @@ import ( |
| "context" |
| "errors" |
| "flag" |
| - "fmt" |
| "io" |
| "log" |
| "os" |
| @@ -48,8 +47,10 @@ func parseFilesSeed(args []string) (string, error) { |
| } |
| func RunSystemFiles(args []string) { |
| - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) |
| + signalCtx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) |
| defer cancel() |
| + ctx, finish := activator.WithCleanupBudget(signalCtx, 30*time.Second) |
| + defer finish() |
| if err := runSystemFiles(ctx, args, os.Stdout); err != nil { |
| log.Printf("Files seed failed: %s.", err) |
| os.Exit(filesSeedExitCode(err)) |
| @@ -84,9 +85,12 @@ func runSystemFiles(ctx context.Context, args []string, out io.Writer) error { |
| return errFilesSeedDelegation |
| } |
| pid, err := filesSeedEdgePID(ctx) |
| - if err != nil || pid != os.Getpid() { |
| + if err != nil { |
| return errFilesSeedDelegation |
| } |
| + if pid != os.Getpid() { |
| + return filesSeedClient(ctx, host, out, uid) |
| + } |
| manager, err := filesSeedManager(ctx) |
| if err != nil { |
| return err |
| @@ -135,16 +139,12 @@ func runFilesSeedOneShot(ctx context.Context, host string, out io.Writer, manage |
| } |
| var executor *filesSeedExecutor |
| defer func() { |
| - cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) |
| + cleanup, cancel := activator.CleanupContext(ctx, 30*time.Second) |
| defer cancel() |
| if executor != nil { |
| - for name, pending := range executor.pending { |
| - if pending.wake != nil { |
| - if err := pending.wake.Close(cleanup); err != nil { |
| - safeToClose = false |
| - resultErr = errors.Join(resultErr, fmt.Errorf("%s: %w", name, err)) |
| - } |
| - } |
| + if err := executor.close(cleanup); err != nil { |
| + safeToClose = false |
| + resultErr = errors.Join(resultErr, err) |
| } |
| } |
| if safeToClose { |
diff --git a/internal/host/claimfilesseed_delegation_linux.go b/internal/host/claimfilesseed_delegation_linux.go| index a347220c0..c9d540795 100644 |
| --- a/internal/host/claimfilesseed_delegation_linux.go |
| +++ b/internal/host/claimfilesseed_delegation_linux.go |
| @@ -7,6 +7,7 @@ import ( |
| "fmt" |
| "github.com/carlosframework/platform/internal/cgroup" |
| "golang.org/x/sys/unix" |
| + "net" |
| "os" |
| "os/exec" |
| "path/filepath" |
| @@ -130,3 +131,95 @@ func filesSeedManager(ctx context.Context) (*cgroup.Manager, error) { |
| } |
| return cgroup.SetupAtRetaining(root, func(string, ...any) {}) |
| } |
| + |
| +// The connection supplies the PID. pidfd pins that process while two complete |
| +// observations detect exit, reuse, cgroup movement and an edge restart. |
| +func authenticateFilesSeedPeer(ctx context.Context, conn *net.UnixConn, uid int, server bool) error { |
| + raw, err := conn.SyscallConn() |
| + if err != nil { |
| + return err |
| + } |
| + var peer *unix.Ucred |
| + var peerErr error |
| + if err := raw.Control(func(fd uintptr) { peer, peerErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) }); err != nil { |
| + return err |
| + } |
| + if peerErr != nil { |
| + return peerErr |
| + } |
| + if peer == nil || peer.Pid <= 1 || peer.Uid != uint32(uid) || uid == 0 { |
| + return ErrClaimStopIdentity |
| + } |
| + fd, err := unix.PidfdOpen(int(peer.Pid), 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(fd) |
| + first, err := filesSeedPeerObservation(ctx, int(peer.Pid), server) |
| + if err != nil { |
| + return err |
| + } |
| + second, err := filesSeedPeerObservation(ctx, int(peer.Pid), server) |
| + if err != nil { |
| + return err |
| + } |
| + if first != second { |
| + return ErrClaimStopIdentity |
| + } |
| + if err := unix.PidfdSendSignal(fd, 0, nil, 0); err != nil { |
| + return err |
| + } |
| + return ctx.Err() |
| +} |
| + |
| +func filesSeedPeerObservation(ctx context.Context, pid int, server bool) (string, error) { |
| + edge, err := filesSeedEdgePID(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + if server && edge != pid || !server && edge != os.Getpid() { |
| + return "", ErrClaimStopIdentity |
| + } |
| + controller, err := (productionStopControllers{}).VerifiedController(ctx, edge) |
| + if err != nil { |
| + return "", err |
| + } |
| + root, err := agentUnitCgroup(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + rel, err := filepath.Rel("/sys/fs/cgroup", root) |
| + if err != nil || !filepath.IsLocal(rel) || rel == "." { |
| + return "", ErrClaimStopIdentity |
| + } |
| + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) |
| + if err != nil { |
| + return "", err |
| + } |
| + end := strings.LastIndexByte(string(raw), ')') |
| + if end < 0 { |
| + return "", ErrClaimStopIdentity |
| + } |
| + fields := strings.Fields(string(raw)[end+1:]) |
| + if len(fields) < 20 || fields[0] == "Z" { |
| + return "", ErrClaimStopIdentity |
| + } |
| + groups, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)) |
| + if err != nil { |
| + return "", err |
| + } |
| + group := strings.TrimSpace(string(groups)) |
| + if !strings.HasPrefix(group, "0::/") || strings.Contains(group, "\n") { |
| + return "", ErrClaimStopIdentity |
| + } |
| + member := strings.TrimPrefix(group, "0::") |
| + unit := "/" + filepath.ToSlash(rel) |
| + if server { |
| + if member != unit+"/supervise" { |
| + return "", ErrClaimStopIdentity |
| + } |
| + } else if member == unit || strings.HasPrefix(member, unit+"/") { |
| + return "", ErrClaimStopIdentity |
| + } |
| + return fmt.Sprintf("%d:%s:%s:%s:%s", edge, controller, root, fields[19], group), nil |
| +} |
diff --git a/internal/host/claimfilesseed_integration_linux_test.go b/internal/host/claimfilesseed_integration_linux_test.go| index 7669e6b12..aad01e295 100644 |
| --- a/internal/host/claimfilesseed_integration_linux_test.go |
| +++ b/internal/host/claimfilesseed_integration_linux_test.go |
| @@ -20,7 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation) { |
| +func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation, live ...filesSeedLiveWitness) { |
| t.Helper() |
| f := p.f |
| f.actor.Bucket = f.f.bucket |
| @@ -127,6 +127,9 @@ func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation) { |
| normal := newServingPreparation(t, f, "boot", "controller", func(w *ClaimWakeAssembly) { |
| w.Inputs.(*claimServingOwners).Sources.Files = ordinary |
| w.ServingPrerequisites = servingFilesPrerequisites(f.f.claims, ordinary) |
| + for _, witness := range live { |
| + witness.configure(w) |
| + } |
| }) |
| admitted, start := normal.prepare(t) |
| if err := start.Restore(t.Context()); err != nil { |
| @@ -150,6 +153,9 @@ func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation) { |
| if err != nil || string(b) != "real seeded bytes" { |
| t.Fatal("restored files", string(b), err) |
| } |
| + for _, witness := range live { |
| + witness.serve(t, normal, admitted, start) |
| + } |
| if err := start.Failed(t.Context(), 0); err != nil { |
| t.Fatal(err) |
| } |
| @@ -166,6 +172,11 @@ func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation) { |
| t.Log(output.String()) |
| } |
| +type filesSeedLiveWitness struct { |
| + configure func(*ClaimWakeAssembly) |
| + serve func(*testing.T, *servingPreparation, hostclaim.Observed, activator.ClaimStart) |
| +} |
| + |
| func TestFilesSeedRealOriginPreparation(t *testing.T) { |
| runFilesSeedOrdinaryPreparation(t, "real-seed", "same-controller") |
| } |
diff --git a/internal/host/claimfilesseed_killed_linux.go b/internal/host/claimfilesseed_killed_linux.go| new file mode 100644 |
| index 000000000..d2a1e4915 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_killed_linux.go |
| @@ -0,0 +1,90 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +// systemd removes an edge unit's empty delegated subtree after SIGKILL. A |
| +// seed that never reserved a tenant role can settle that absence in the same |
| +// boot. This path neither kills a replacement group nor invents a reboot. |
| +func (k LinuxStopKernel) recoverUnlaunchedAbsent(ctx context.Context, t hostguard.FenceTarget, in StopInventory, j hostbox.StopKernelJournal, journal interface { |
| + SettleRecovered(context.Context, hostguard.FenceTarget) error |
| +}) (StopKernelEvidence, DrainAcknowledgement, error) { |
| + refuse := func(err error) (StopKernelEvidence, DrainAcknowledgement, error) { |
| + return StopKernelEvidence{}, DrainAcknowledgement{}, err |
| + } |
| + if j.V != 1 || j.PendingRoles || len(j.Cgroups) != 1 || len(j.UnitReservationIDs) != 0 { |
| + return refuse(ErrClaimStopInventory) |
| + } |
| + raw, ok := k.Journal.(interface { |
| + Read(context.Context, hostguard.FenceTarget) (hostbox.LaunchInventory, error) |
| + }) |
| + if !ok { |
| + return refuse(ErrClaimStopInventory) |
| + } |
| + launch, err := raw.Read(ctx, t) |
| + if err != nil { |
| + return refuse(err) |
| + } |
| + if len(launch.Reservations) != 0 || len(launch.Groups) != 1 { |
| + return refuse(ErrClaimStopInventory) |
| + } |
| + paths, err := k.stopDescriptorPaths(in) |
| + if err != nil { |
| + return refuse(err) |
| + } |
| + check := func(expected hostbox.StopKernelJournal) error { |
| + fresh, err := k.readContainedJournal(ctx, t) |
| + if err != nil { |
| + return err |
| + } |
| + if !reflect.DeepEqual(fresh, expected) { |
| + return ErrClaimStopInventory |
| + } |
| + again, err := raw.Read(ctx, t) |
| + if err != nil { |
| + return err |
| + } |
| + if len(again.Reservations) != 0 || !reflect.DeepEqual(again.Groups, launch.Groups) { |
| + return ErrClaimStopInventory |
| + } |
| + boot, err := recoveryControllerAt(k.ProcRoot, t, fresh) |
| + if err != nil { |
| + return err |
| + } |
| + if boot != t.BootID { |
| + return ErrClaimStopIdentity |
| + } |
| + if _, err := os.Lstat(filepath.Join(k.CgroupRoot, j.Cgroups[0].RelativePath)); !errors.Is(err, os.ErrNotExist) { |
| + if err != nil { |
| + return err |
| + } |
| + return ErrClaimStopInventory |
| + } |
| + return scanStopDescriptors(ctx, k.ProcRoot, j.Files, paths) |
| + } |
| + if err := check(j); err != nil { |
| + return refuse(err) |
| + } |
| + if err := journal.SettleRecovered(ctx, t); err != nil { |
| + return refuse(err) |
| + } |
| + settled := j |
| + settled.Settled = true |
| + if err := check(settled); err != nil { |
| + return refuse(err) |
| + } |
| + group := j.Cgroups[0] |
| + evidence := StopKernelEvidence{ObservedBootID: t.BootID, InventorySHA256: digestStop(in), LocalJournalSHA256: digestStop(settled), CgroupIdentities: []string{fmt.Sprintf("unlaunched-absent=%s;previous_identity=%d:%d;controller=%s", group.RelativePath, group.Device, group.Inode, t.ControllerID)}, UnitInvocationIDs: []string{}} |
| + return evidence, DrainClaimAcknowledgement(t.FenceID, t.ControllerID, j.ProcessStart, j.ProducerIDs, j.Completed), nil |
| +} |
diff --git a/internal/host/claimfilesseed_killed_linux_test.go b/internal/host/claimfilesseed_killed_linux_test.go| new file mode 100644 |
| index 000000000..b3b62b8f0 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_killed_linux_test.go |
| @@ -0,0 +1,86 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +type seedAbsentJournal struct { |
| + rebootJournalFixture |
| + raw hostbox.LaunchInventory |
| + after func() |
| +} |
| + |
| +func (j *seedAbsentJournal) Read(context.Context, hostguard.FenceTarget) (hostbox.LaunchInventory, error) { |
| + return j.raw, nil |
| +} |
| +func (j *seedAbsentJournal) SettleRecovered(context.Context, hostguard.FenceTarget) error { |
| + j.j.Settled = true |
| + if j.after != nil { |
| + j.after() |
| + } |
| + return nil |
| +} |
| + |
| +func TestFilesSeedKilledUnlaunchedRecovery(t *testing.T) { |
| + for _, fault := range []string{"none", "live-controller", "reused-parent", "reappears", "role", "boot-change", "descriptor", "foreign-parent", "no-journal"} { |
| + t.Run(fault, func(t *testing.T) { |
| + root, proc := t.TempDir(), t.TempDir() |
| + os.MkdirAll(filepath.Join(proc, "sys/kernel/random"), 0700) |
| + os.WriteFile(filepath.Join(proc, "sys/kernel/random/boot_id"), []byte("boot\n"), 0600) |
| + target := hostguard.FenceTarget{Host: "seed.example", Incarnation: "inc", Stamp: 11, Session: 13, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller", FenceID: "fence"} |
| + agent := "/sys/fs/cgroup/system.slice/carlos-edge.service" |
| + parent, err := claimContainmentParent(agent, target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + group := hostbox.StopCgroupIdentity{RelativePath: parent, Device: 1, Inode: 2} |
| + j := &seedAbsentJournal{rebootJournalFixture: rebootJournalFixture{j: hostbox.StopKernelJournal{V: 1, Target: target, ControllerPID: 42, ProcessStart: "123", Cgroups: []hostbox.StopCgroupIdentity{group}}}, raw: hostbox.LaunchInventory{Target: target, Groups: []hostbox.LaunchGroup{{RelativePath: parent, Device: 1, Inode: 2}}, NoState: true}} |
| + state := t.TempDir() |
| + k := LinuxStopKernel{AgentCgroup: func(context.Context) (string, error) { return agent, nil }, Journal: j, CgroupRoot: root, ProcRoot: proc, ManagedRoots: map[string]string{"data": state}} |
| + switch fault { |
| + case "live-controller": |
| + os.MkdirAll(filepath.Join(proc, "42"), 0700) |
| + os.WriteFile(filepath.Join(proc, "42/stat"), []byte("42 (fixture) S "+strings.Repeat("0 ", 18)+"123 0"), 0600) |
| + case "reused-parent": |
| + os.MkdirAll(filepath.Join(root, parent), 0700) |
| + case "reappears": |
| + j.after = func() { os.MkdirAll(filepath.Join(root, parent), 0700) } |
| + case "role": |
| + j.j.UnitReservationIDs = []string{"main"} |
| + case "boot-change": |
| + os.WriteFile(filepath.Join(proc, "sys/kernel/random/boot_id"), []byte("other"), 0600) |
| + case "descriptor": |
| + os.MkdirAll(filepath.Join(proc, "42/fd"), 0700) |
| + os.WriteFile(filepath.Join(proc, "42/stat"), []byte("42 (fixture) S "+strings.Repeat("0 ", 18)+"456 0"), 0600) |
| + os.Symlink(filepath.Join(state, "data.db")+" (deleted)", filepath.Join(proc, "42/fd/3")) |
| + case "foreign-parent": |
| + j.j.Cgroups[0].RelativePath = "other/group" |
| + case "no-journal": |
| + k.Journal = &j.rebootJournalFixture |
| + } |
| + in := StopInventory{Complete: true, Entries: []hostbox.StopInventoryEntry{{Kind: "path", ManagedRoot: "data", RelativePath: "data.db"}}} |
| + evidence, _, err := k.recoverUnlaunchedAbsent(t.Context(), target, in, j.j, j) |
| + if fault == "none" { |
| + if err != nil || !j.j.Settled || evidence.ObservedBootID != "boot" { |
| + t.Fatal(err, evidence) |
| + } |
| + return |
| + } |
| + if err == nil { |
| + t.Fatal("unsafe recovery accepted", fault) |
| + } |
| + if fault != "reappears" && j.j.Settled { |
| + t.Fatal("refusal settled journal") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_live_rootrunner_linux_test.go b/internal/host/claimfilesseed_live_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..0e69299b7 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_live_rootrunner_linux_test.go |
| @@ -0,0 +1,153 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "flag" |
| + "io" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +var seedTenantSocket = flag.String("socket", "", "") |
| +var seedTenantDB = flag.String("db", "", "") |
| + |
| +func init() { |
| + if os.Getenv("FILES_SEED_TENANT") == "1" { |
| + os.Args = append(os.Args, "-test.run=^TestRootFilesSeedTenantPayload$", "-test.timeout=30s") |
| + } |
| +} |
| + |
| +// This process is launched from the ordinary selected Build closure after C8. |
| +// The surrounding fixture still supplies role authority and stop evidence; |
| +// this witness does not stand in for the installed isolated role-launch test. |
| +func TestRootFilesSeedTenantPayload(t *testing.T) { |
| + if os.Getenv("FILES_SEED_TENANT") != "1" { |
| + t.Fatal("invoke through real-origin tenant witness") |
| + } |
| + db, err := sql.Open("sqlite", *seedTenantDB) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Close() |
| + var integrity string |
| + if err := db.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil || integrity != "ok" { |
| + t.Fatal(integrity, err) |
| + } |
| + body, err := os.ReadFile(os.Getenv("FILES_SEED_PAYLOAD")) |
| + if err != nil || string(body) != "real seeded bytes" { |
| + t.Fatal(string(body), err) |
| + } |
| + listener, err := net.Listen("unix", *seedTenantSocket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer listener.Close() |
| + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(body) })} |
| + defer server.Close() |
| + if err := server.Serve(listener); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func rootFilesSeedLiveWitness(t *testing.T, binary string) filesSeedLiveWitness { |
| + body, err := os.ReadFile(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + digest := sha256.Sum256(body) |
| + return filesSeedLiveWitness{configure: func(w *ClaimWakeAssembly) { |
| + owners := w.Inputs.(*claimServingOwners) |
| + selectLaunch := owners.Sources.Launch |
| + owners.Sources.Launch = func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) { |
| + launch, err := selectLaunch(ctx, rt) |
| + if err != nil { |
| + return launch, err |
| + } |
| + launch.Executable = binary |
| + launch.ExecutableSHA256 = hex.EncodeToString(digest[:]) |
| + launch.Env = append(launch.Env, "FILES_SEED_TENANT=1", "FILES_SEED_PAYLOAD="+filepath.Join(filepath.Dir(rt.DBPath), rt.Host, "payload")) |
| + return launch, nil |
| + } |
| + }, serve: func(t *testing.T, p *servingPreparation, o hostclaim.Observed, start activator.ClaimStart) { |
| + socket := provision.NestedSocket(o.Host) |
| + if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := start.Build() |
| + cmd.Stdin = os.Stdin |
| + var output bytes.Buffer |
| + cmd.Stdout = &output |
| + cmd.Stderr = &output |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No readiness or success is fabricated: the selected process owns the socket. |
| + defer func() { cmd.Process.Kill(); cmd.Wait(); os.Remove(socket) }() |
| + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) |
| + defer cancel() |
| + if err := start.Ready(ctx, cmd); err != nil { |
| + cmd.Process.Kill() |
| + cmd.Wait() |
| + t.Fatalf("tenant readiness: %v %s", err, output.String()) |
| + } |
| + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + }} |
| + defer transport.CloseIdleConnections() |
| + response, err := (&http.Client{Transport: transport, Timeout: time.Second}).Get("http://tenant/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + payload, err := io.ReadAll(response.Body) |
| + response.Body.Close() |
| + if err != nil || response.StatusCode != 200 || string(payload) != "real seeded bytes" { |
| + t.Fatal("tenant served wrong restored payload", err, string(payload)) |
| + } |
| + wrong, _ := os.FindProcess(os.Getpid()) |
| + refuse, stop := context.WithTimeout(t.Context(), 40*time.Millisecond) |
| + defer stop() |
| + if err := start.Ready(refuse, &exec.Cmd{Process: wrong}); err == nil { |
| + t.Fatal("readiness accepted controller instead of tenant") |
| + } |
| + }} |
| +} |
| + |
| +func TestFilesSeedLiveFixtureShape(t *testing.T) { |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + witness := rootFilesSeedLiveWitness(t, binary) |
| + f := plan9D5Fixture(t) |
| + rt := registry.Route{Host: recoveryHost, DBPath: provision.NestedDBPath("/data", recoveryHost), Socket: provision.NestedSocket(recoveryHost)} |
| + f.launch.Args = []string{"--socket", rt.Socket, "--db", rt.DBPath} |
| + f.launch.Env = []string{"CARLOS_ADMIN_TOKEN=test"} |
| + w := &ClaimWakeAssembly{Inputs: f.owners} |
| + witness.configure(w) |
| + launch, err := f.owners.Sources.Launch(t.Context(), rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + role := &ClaimRoleAssembly{Root: fakeLaunchRoot{}, ManagedRoots: map[string]string{"data": "/data"}} |
| + in := ClaimLaunchInputs{Limit: cgroup.TierDefault, Roles: []string{"main"}, Identity: launch.Identity, Executable: launch.Executable, ExecutableSHA256: launch.ExecutableSHA256, Args: launch.Args, Env: launch.Env, DBPath: rt.DBPath} |
| + if err := role.PreflightLaunch(rt.Host, in); err != nil { |
| + t.Fatal(err, launch.Args, launch.Env) |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_restore_linux_test.go b/internal/host/claimfilesseed_restore_linux_test.go| index 080fb483a..15bd3a35b 100644 |
| --- a/internal/host/claimfilesseed_restore_linux_test.go |
| +++ b/internal/host/claimfilesseed_restore_linux_test.go |
| @@ -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} |
| + rt := registry.Route{Host: recoveryHost, DBPath: f.db, Socket: provision.NestedSocket(recoveryHost)} |
| o := f.f.read() |
| // Prerequisites' isolated launch preflight requires an installed /data |
| // namespace. Drive its selection, stop and real lease in the same order; |
| @@ -216,6 +216,9 @@ func TestFilesSeedOrdinaryPreparationRestore(t *testing.T) { |
| } |
| func runFilesSeedOrdinaryPreparation(t *testing.T, failure, restart string, mounted ...bool) { |
| + runFilesSeedPreparationWithLive(t, failure, restart, mounted, nil) |
| +} |
| +func runFilesSeedPreparationWithLive(t *testing.T, failure, restart string, mounted []bool, live []filesSeedLiveWitness) { |
| if failure == "real-seed" { |
| t.Setenv("CARLOS_HOST_CLAIM", "1") |
| t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| @@ -409,7 +412,7 @@ func runFilesSeedOrdinaryPreparation(t *testing.T, failure, restart string, moun |
| } |
| p := newServingPreparation(t, f, "boot", "controller") |
| if failure == "real-seed" { |
| - runFilesSeedRealPreparation(t, p) |
| + runFilesSeedRealPreparation(t, p, live...) |
| return |
| } |
| var legacy servingParkRecord |
diff --git a/internal/host/claimfilesseed_runtime.go b/internal/host/claimfilesseed_runtime.go| index e242f2755..c12639f6e 100644 |
| --- a/internal/host/claimfilesseed_runtime.go |
| +++ b/internal/host/claimfilesseed_runtime.go |
| @@ -67,7 +67,7 @@ func prepareFilesSeed(ctx context.Context, rt registry.Route, runtime *activator |
| cleanupObservation := o |
| var start activator.ClaimStart |
| defer func() { |
| - cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) |
| + cleanup, cancel := activator.CleanupContext(ctx, 30*time.Second) |
| defer cancel() |
| var stopErr error |
| if start.Failed != nil { |
diff --git a/internal/host/claimfilesseed_runtime_test.go b/internal/host/claimfilesseed_runtime_test.go| index eaef16a78..c4a4bd2ae 100644 |
| --- a/internal/host/claimfilesseed_runtime_test.go |
| +++ b/internal/host/claimfilesseed_runtime_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "os/exec" |
| "reflect" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostbox" |
| @@ -153,3 +154,37 @@ func TestFilesSeedExitCodes(t *testing.T) { |
| t.Fatal("incorrect status") |
| } |
| } |
| + |
| +func TestFilesSeedPreparationCleanupSharesCanceledBudget(t *testing.T) { |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + ctx, finish := activator.WithCleanupBudget(ctx, 50*time.Millisecond) |
| + defer finish() |
| + obs := hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc"}}} |
| + token, err := (&activator.Service{}).AcquireSeedExclusion(ctx, recoveryHost, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var deadline time.Time |
| + runtime := &activator.ClaimRuntime{ |
| + Read: func(ctx context.Context, _ string) (hostclaim.Observed, error) { return obs, ctx.Err() }, |
| + Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) error { return nil }, |
| + Admit: func(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) (hostclaim.Observed, error) { |
| + return obs, nil |
| + }, |
| + Prepare: func(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) (activator.ClaimStart, error) { |
| + return activator.ClaimStart{Restore: func(context.Context) error { return nil }, Reserve: func(context.Context, int) error { return nil }, Materialize: func(context.Context) error { cancel(); return context.Canceled }, Failed: func(ctx context.Context, _ int) error { deadline, _ = ctx.Deadline(); <-ctx.Done(); return ctx.Err() }}, nil |
| + }, |
| + FailedAdmission: func(ctx context.Context, _ hostclaim.Observed, _ error) error { |
| + d, _ := ctx.Deadline() |
| + if !d.Equal(deadline) || ctx.Err() == nil { |
| + t.Error("cleanup received fresh budget") |
| + } |
| + return ctx.Err() |
| + }, |
| + } |
| + started := time.Now() |
| + _, settled, err := prepareFilesSeed(ctx, registry.Route{Host: recoveryHost}, runtime, obs, token) |
| + if err == nil || settled || time.Since(started) > time.Second || token.Check(recoveryHost) != nil { |
| + t.Fatal("unbounded or falsely settled cleanup", err, settled) |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_transport_linux.go b/internal/host/claimfilesseed_transport_linux.go| new file mode 100644 |
| index 000000000..049d2559c |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_transport_linux.go |
| @@ -0,0 +1,301 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bufio" |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/controlsock" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +const filesSeedSocket = "/run/carlos/files-seed.sock" |
| + |
| +type filesSeedRequest struct { |
| + Verb string `json:"verb"` |
| + Host string `json:"host,omitempty"` |
| +} |
| +type filesSeedReply struct { |
| + Text string `json:"text,omitempty"` |
| + Code *int `json:"code,omitempty"` |
| +} |
| +type filesSeedServer struct { |
| + listener *net.UnixListener |
| + cancel context.CancelFunc |
| + done chan struct{} |
| + handlers sync.WaitGroup |
| + finalize func(context.Context) error |
| +} |
| +type filesSeedStream struct { |
| + conn net.Conn |
| + encoder *json.Encoder |
| +} |
| + |
| +func (s filesSeedStream) Write(p []byte) (int, error) { |
| + s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) |
| + if err := s.encoder.Encode(filesSeedReply{Text: string(p)}); err != nil { |
| + return 0, err |
| + } |
| + return len(p), nil |
| +} |
| + |
| +func startFilesSeedSocket(ctx context.Context, ready <-chan struct{}, path string, auth func(context.Context, *net.UnixConn) error, run func(context.Context, string, io.Writer) error) (*filesSeedServer, error) { |
| + select { |
| + case <-ctx.Done(): |
| + return nil, ctx.Err() |
| + case <-ready: |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + if auth == nil || run == nil { |
| + return nil, hostbox.ErrUnsupported |
| + } |
| + parent, err := os.Lstat(filepath.Dir(path)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var dir unix.Stat_t |
| + if err := unix.Lstat(filepath.Dir(path), &dir); err != nil { |
| + return nil, err |
| + } |
| + if !parent.IsDir() || parent.Mode()&os.ModeSymlink != 0 || parent.Mode().Perm()&0022 != 0 || int(dir.Uid) != os.Geteuid() { |
| + return nil, ErrClaimStopIdentity |
| + } |
| + if err := removeStaleFilesSeedSocket(ctx, path); err != nil { |
| + return nil, err |
| + } |
| + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := os.Chmod(path, 0600); err != nil { |
| + listener.Close() |
| + return nil, err |
| + } |
| + if err := ctx.Err(); err != nil { |
| + listener.Close() |
| + return nil, err |
| + } |
| + live, cancel := context.WithCancel(ctx) |
| + live, finish := activator.WithCleanupBudget(live, 30*time.Second) |
| + s := &filesSeedServer{listener: listener, cancel: cancel, done: make(chan struct{})} |
| + stop := context.AfterFunc(live, func() { listener.Close() }) |
| + go func() { |
| + defer close(s.done) |
| + defer finish() |
| + defer stop() |
| + defer listener.Close() |
| + busy := make(chan struct{}, 1) |
| + for { |
| + conn, err := listener.AcceptUnix() |
| + if err != nil { |
| + break |
| + } |
| + select { |
| + case busy <- struct{}{}: |
| + default: |
| + conn.Close() |
| + continue |
| + } |
| + s.handlers.Add(1) |
| + go func() { |
| + defer s.handlers.Done() |
| + defer func() { <-busy }() |
| + defer conn.Close() |
| + serveFilesSeed(live, conn, auth, run) |
| + }() |
| + } |
| + s.handlers.Wait() |
| + }() |
| + return s, nil |
| +} |
| + |
| +func (s *filesSeedServer) close(ctx context.Context) error { |
| + if s == nil { |
| + return nil |
| + } |
| + s.cancel() |
| + s.listener.Close() |
| + select { |
| + case <-s.done: |
| + if s.finalize != nil { |
| + return s.finalize(ctx) |
| + } |
| + return nil |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + } |
| +} |
| + |
| +func serveFilesSeed(parent context.Context, conn *net.UnixConn, auth func(context.Context, *net.UnixConn) error, run func(context.Context, string, io.Writer) error) { |
| + ctx, cancel := context.WithCancel(parent) |
| + defer cancel() |
| + ctx, finish := activator.WithCleanupBudget(ctx, 30*time.Second) |
| + defer finish() |
| + stop := context.AfterFunc(ctx, func() { conn.Close() }) |
| + defer stop() |
| + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) |
| + if err := auth(ctx, conn); err != nil { |
| + return |
| + } |
| + reader := bufio.NewReaderSize(conn, 1024) |
| + line, err := reader.ReadSlice('\n') |
| + if err != nil || len(line) > 1024 { |
| + return |
| + } |
| + var q filesSeedRequest |
| + decoder := json.NewDecoder(bytes.NewReader(line)) |
| + decoder.DisallowUnknownFields() |
| + if err := decoder.Decode(&q); err != nil { |
| + return |
| + } |
| + // Canonical encoding also rejects duplicate fields and trailing JSON. |
| + canonical, _ := json.Marshal(q) |
| + var compact bytes.Buffer |
| + if json.Compact(&compact, line) != nil || !bytes.Equal(compact.Bytes(), canonical) { |
| + return |
| + } |
| + if q.Verb != "seed" { |
| + return |
| + } |
| + if q.Host != "" { |
| + if _, err := hostclaim.Key(q.Host); err != nil { |
| + return |
| + } |
| + } |
| + if err := auth(ctx, conn); err != nil { |
| + return |
| + } |
| + conn.SetReadDeadline(time.Time{}) |
| + monitor := make(chan struct{}) |
| + go func() { defer close(monitor); _, _ = reader.ReadByte(); cancel() }() |
| + stream := filesSeedStream{conn: conn, encoder: json.NewEncoder(conn)} |
| + result := run(ctx, q.Host, stream) |
| + code := filesSeedExitCode(result) |
| + if result != nil { |
| + _, _ = stream.Write([]byte(result.Error() + "\n")) |
| + } |
| + conn.SetWriteDeadline(time.Now().Add(time.Second)) |
| + _ = stream.encoder.Encode(filesSeedReply{Code: &code}) |
| + conn.Close() |
| + <-monitor |
| +} |
| + |
| +func filesSeedClient(ctx context.Context, host string, out io.Writer, uid int) error { |
| + var st unix.Stat_t |
| + if err := unix.Lstat(filesSeedSocket, &st); err != nil { |
| + return errors.Join(errFilesSeedDelegation, err) |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Mode&0777 != 0600 || st.Uid != uint32(uid) { |
| + return ErrClaimStopIdentity |
| + } |
| + conn, err := (&net.Dialer{}).DialContext(ctx, "unix", filesSeedSocket) |
| + if err != nil { |
| + return errors.Join(errFilesSeedDelegation, err) |
| + } |
| + defer conn.Close() |
| + stop := context.AfterFunc(ctx, func() { conn.Close() }) |
| + defer stop() |
| + if err := authenticateFilesSeedPeer(ctx, conn.(*net.UnixConn), uid, true); err != nil { |
| + return err |
| + } |
| + var after unix.Stat_t |
| + if err := unix.Lstat(filesSeedSocket, &after); err != nil || st.Dev != after.Dev || st.Ino != after.Ino { |
| + return ErrClaimStopIdentity |
| + } |
| + if err := json.NewEncoder(conn).Encode(filesSeedRequest{Verb: "seed", Host: host}); err != nil { |
| + return err |
| + } |
| + reader := bufio.NewReaderSize(conn, 64*1024) |
| + for { |
| + line, err := reader.ReadSlice('\n') |
| + if err != nil { |
| + return err |
| + } |
| + var reply filesSeedReply |
| + if err := json.Unmarshal(line, &reply); err != nil { |
| + return err |
| + } |
| + if reply.Text != "" { |
| + if _, err := io.WriteString(out, reply.Text); err != nil { |
| + return err |
| + } |
| + } |
| + if reply.Code != nil { |
| + switch *reply.Code { |
| + case 0: |
| + return nil |
| + case 2: |
| + return filesSeedIncompleteError{hostbox.ErrChanged} |
| + default: |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + } |
| +} |
| + |
| +func startAgentFilesSeed(ctx context.Context, ready <-chan struct{}, svc *activator.Service) (*filesSeedServer, error) { |
| + entry, _ := ctx.Value(claimEntryKey{}).(claimEntry) |
| + resources := entry.Resources |
| + source, ok := productionClaimFilesSource().(*movesFilesSource) |
| + if !ok || entry.Assembly == nil || svc == nil || resources.Manager == nil { |
| + return nil, hostbox.ErrUnsupported |
| + } |
| + if err := NewClaimRoleRootClient(controlsock.Path).KindCapability(ctx, "files-seed"); err != nil { |
| + return nil, err |
| + } |
| + executor := &filesSeedExecutor{assembly: entry.Assembly, service: svc, registry: resources.Registry, source: source} |
| + server, err := startFilesSeedSocket(ctx, ready, filesSeedSocket, func(ctx context.Context, c *net.UnixConn) error { |
| + return authenticateFilesSeedPeer(ctx, c, os.Geteuid(), false) |
| + }, executor.run) |
| + if server != nil { |
| + server.finalize = executor.close |
| + } |
| + return server, err |
| +} |
| + |
| +// Recovery removes only an unchanged, service-owned socket which the kernel |
| +// refused to connect to. A live or uncertain listener always wins. |
| +func removeStaleFilesSeedSocket(ctx context.Context, path string) error { |
| + var before unix.Stat_t |
| + err := unix.Lstat(path, &before) |
| + if errors.Is(err, unix.ENOENT) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if before.Mode&unix.S_IFMT != unix.S_IFSOCK || before.Mode&0777 != 0600 || int(before.Uid) != os.Geteuid() { |
| + return ErrClaimStopIdentity |
| + } |
| + conn, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, "unix", path) |
| + if err == nil { |
| + conn.Close() |
| + return hostbox.ErrChanged |
| + } |
| + if !errors.Is(err, unix.ECONNREFUSED) { |
| + return err |
| + } |
| + var after unix.Stat_t |
| + if err := unix.Lstat(path, &after); err != nil { |
| + return err |
| + } |
| + if before.Dev != after.Dev || before.Ino != after.Ino || before.Ctim != after.Ctim { |
| + return ErrClaimStopIdentity |
| + } |
| + return os.Remove(path) |
| +} |
diff --git a/internal/host/claimfilesseed_transport_linux_test.go b/internal/host/claimfilesseed_transport_linux_test.go| new file mode 100644 |
| index 000000000..400589865 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_transport_linux_test.go |
| @@ -0,0 +1,244 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestFilesSeedSocketStartupDisconnectAndShutdown(t *testing.T) { |
| + for _, canceled := range []bool{false, true} { |
| + t.Run(map[bool]string{true: "canceled", false: "ready"}[canceled], func(t *testing.T) { |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + ready := make(chan struct{}) |
| + dir := t.TempDir() |
| + if err := os.Chmod(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "seed.sock") |
| + entered, joined := make(chan struct{}), make(chan struct{}) |
| + var calls atomic.Int32 |
| + run := func(ctx context.Context, host string, out io.Writer) error { |
| + calls.Add(1) |
| + close(entered) |
| + <-ctx.Done() |
| + close(joined) |
| + return ctx.Err() |
| + } |
| + result := make(chan *filesSeedServer, 1) |
| + go func() { |
| + s, _ := startFilesSeedSocket(ctx, ready, path, func(context.Context, *net.UnixConn) error { return nil }, run) |
| + result <- s |
| + }() |
| + select { |
| + case <-result: |
| + t.Fatal("bound before startup") |
| + case <-time.After(20 * time.Millisecond): |
| + } |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("early socket", err) |
| + } |
| + if canceled { |
| + cancel() |
| + } |
| + close(ready) |
| + s := <-result |
| + if canceled { |
| + if s != nil { |
| + t.Fatal("late socket") |
| + } |
| + return |
| + } |
| + if s == nil { |
| + t.Fatal("no socket") |
| + } |
| + c, err := net.Dial("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := json.NewEncoder(c).Encode(filesSeedRequest{Verb: "seed", Host: recoveryHost}); err != nil { |
| + t.Fatal(err) |
| + } |
| + <-entered |
| + c.Close() |
| + select { |
| + case <-joined: |
| + case <-time.After(time.Second): |
| + t.Fatal("disconnect detached request") |
| + } |
| + cancel() |
| + join, cancelJoin := context.WithTimeout(context.Background(), time.Second) |
| + defer cancelJoin() |
| + if err := s.close(join); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls.Load() != 1 { |
| + t.Fatal(calls.Load()) |
| + } |
| + if _, err := net.Dial("unix", path); err == nil { |
| + t.Fatal("listener survived") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestFilesSeedSocketRefusesMalformedAndUntrusted(t *testing.T) { |
| + for _, body := range []string{`{"verb":"seed","host":"../bad"}`, `{"verb":"repair"}`, `{"verb":"seed","pid":1}`, `{"verb":"seed","host":"a","host":"b"}`} { |
| + t.Run(body, func(t *testing.T) { |
| + ready := make(chan struct{}) |
| + close(ready) |
| + dir := t.TempDir() |
| + if err := os.Chmod(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "seed.sock") |
| + s, err := startFilesSeedSocket(t.Context(), ready, path, func(context.Context, *net.UnixConn) error { return nil }, func(context.Context, string, io.Writer) error { t.Error("malformed request executed"); return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.close(t.Context()) |
| + c, err := net.Dial("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer c.Close() |
| + c.SetDeadline(time.Now().Add(time.Second)) |
| + io.WriteString(c, body+"\n") |
| + var r filesSeedReply |
| + if json.NewDecoder(c).Decode(&r) == nil && r.Code != nil && *r.Code == 0 { |
| + t.Fatal("accepted") |
| + } |
| + }) |
| + } |
| + ready := make(chan struct{}) |
| + close(ready) |
| + dir := t.TempDir() |
| + if err := os.Chmod(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "seed.sock") |
| + s, err := startFilesSeedSocket(t.Context(), ready, path, func(context.Context, *net.UnixConn) error { return errors.New("refused") }, func(context.Context, string, io.Writer) error { t.Error("untrusted request executed"); return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.close(t.Context()) |
| + c, err := net.Dial("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer c.Close() |
| + json.NewEncoder(c).Encode(filesSeedRequest{Verb: "seed"}) |
| + var r filesSeedReply |
| + if json.NewDecoder(c).Decode(&r) == nil && r.Code != nil && *r.Code == 0 { |
| + t.Fatal("untrusted accepted") |
| + } |
| + if _, err := startFilesSeedSocket(t.Context(), ready, path, nil, nil); err == nil { |
| + t.Fatal("replaced live socket") |
| + } |
| +} |
| + |
| +func TestFilesSeedSocketStaleRecoveryAndBoundedJoin(t *testing.T) { |
| + dir := t.TempDir() |
| + os.Chmod(dir, 0700) |
| + path := filepath.Join(dir, "seed.sock") |
| + old, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + old.SetUnlinkOnClose(false) |
| + os.Chmod(path, 0600) |
| + old.Close() |
| + ready := make(chan struct{}) |
| + close(ready) |
| + entered, release := make(chan struct{}), make(chan struct{}) |
| + s, err := startFilesSeedSocket(t.Context(), ready, path, func(context.Context, *net.UnixConn) error { return nil }, func(context.Context, string, io.Writer) error { close(entered); <-release; return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var closed atomic.Bool |
| + s.finalize = func(context.Context) error { closed.Store(true); return nil } |
| + c, err := net.Dial("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer c.Close() |
| + json.NewEncoder(c).Encode(filesSeedRequest{Verb: "seed"}) |
| + <-entered |
| + if err := removeStaleFilesSeedSocket(t.Context(), path); err == nil { |
| + t.Fatal("live listener removed") |
| + } |
| + deadline, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + if err := s.close(deadline); !errors.Is(err, context.DeadlineExceeded) || closed.Load() { |
| + t.Fatal("unjoined runtime allowed close", err) |
| + } |
| + close(release) |
| + if err := s.close(t.Context()); err != nil || !closed.Load() { |
| + t.Fatal("joined runtime not finalized", err) |
| + } |
| +} |
| + |
| +func TestFilesSeedSocketWaitsConfigureAndStart(t *testing.T) { |
| + for _, phase := range []string{"configure", "start", "ready"} { |
| + t.Run(phase, func(t *testing.T) { |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + dir := t.TempDir() |
| + os.Chmod(dir, 0700) |
| + path := filepath.Join(dir, "seed.sock") |
| + configured, allowConfigure, starting, allowStart, ready := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) |
| + startup := make(chan struct{}) |
| + go func() { |
| + defer close(startup) |
| + <-(activatorStart{Claims: true, Configure: func(context.Context) { close(configured); <-allowConfigure }, Start: func() { close(starting); <-allowStart }, Ready: func() { close(ready) }}).Run(ctx) |
| + }() |
| + result := make(chan *filesSeedServer, 1) |
| + go func() { |
| + s, _ := startFilesSeedSocket(ctx, ready, path, func(context.Context, *net.UnixConn) error { return nil }, func(context.Context, string, io.Writer) error { return nil }) |
| + result <- s |
| + }() |
| + <-configured |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("socket during Configure") |
| + } |
| + if phase == "configure" { |
| + cancel() |
| + } |
| + close(allowConfigure) |
| + if phase != "configure" { |
| + <-starting |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("socket during Start") |
| + } |
| + if phase == "start" { |
| + cancel() |
| + } |
| + } |
| + close(allowStart) |
| + <-startup |
| + s := <-result |
| + if phase != "ready" { |
| + if s != nil { |
| + t.Fatal("socket after canceled startup") |
| + } |
| + return |
| + } |
| + if s == nil { |
| + t.Fatal("no socket after readiness") |
| + } |
| + if err := s.close(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_transport_other.go b/internal/host/claimfilesseed_transport_other.go| new file mode 100644 |
| index 000000000..1e916d19b |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_transport_other.go |
| @@ -0,0 +1,17 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/activator" |
| + "io" |
| +) |
| + |
| +type filesSeedServer struct{} |
| + |
| +func (*filesSeedServer) close(context.Context) error { return nil } |
| +func startAgentFilesSeed(context.Context, <-chan struct{}, *activator.Service) (*filesSeedServer, error) { |
| + return nil, errFilesSeedDelegation |
| +} |
| +func filesSeedClient(context.Context, string, io.Writer, int) error { return errFilesSeedDelegation } |
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index 0183c9b99..07a737920 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -45,6 +45,8 @@ func (h ClaimRootWithLaunch) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| h.Launch.ServeHTTP(w, r) |
| } |
| +func (h ClaimRootWithLaunch) Start(ctx context.Context) { h.Root.Start(ctx) } |
| + |
| func (h ClaimRootWithLaunch) RecoverPending(ctx context.Context) error { |
| return h.Root.RecoverPending(ctx) |
| } |
diff --git a/internal/host/claimrecovery_rulings_test.go b/internal/host/claimrecovery_rulings_test.go| new file mode 100644 |
| index 000000000..ede38fb5e |
| --- /dev/null |
| +++ b/internal/host/claimrecovery_rulings_test.go |
| @@ -0,0 +1,82 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestWrappedClaimRootStartsAndServes(t *testing.T) { |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + var handler http.Handler = ClaimRootWithLaunch{Root: ClaimRootHTTP{}} |
| + lifecycle, ok := handler.(interface{ Start(context.Context) }) |
| + if !ok { |
| + t.Fatal("wrapped handler lost lifecycle") |
| + } |
| + lifecycle.Start(ctx) |
| + r := httptest.NewRecorder() |
| + handler.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/claim/retire", nil)) |
| + if r.Code != http.StatusServiceUnavailable { |
| + t.Fatal(r.Code) |
| + } |
| +} |
| + |
| +type recoveryPages struct { |
| + servingParkAuthority |
| + calls []string |
| +} |
| + |
| +func (p *recoveryPages) recoverServingParks(_ context.Context, after string, limit int) (servingParkRecoveryPage, error) { |
| + p.calls = append(p.calls, after) |
| + page := servingParkRecoveryPage{} |
| + count := 128 |
| + if after != "" { |
| + count = 72 |
| + } |
| + for i := 0; i < count; i++ { |
| + page.Results = append(page.Results, servingParkRecoveryResult{Host: "kept", ID: "park", Err: errServingParkRetagPending}) |
| + } |
| + if after == "" { |
| + page.More = true |
| + page.Next = "128" |
| + } |
| + return page, nil |
| +} |
| +func TestServingParkRecoveryPagesRetainedRecords(t *testing.T) { |
| + a := &recoveryPages{} |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + p := &servingParkRuntime{authority: a, ctx: ctx, cancel: cancel, idle: make(chan struct{})} |
| + for i := 0; i < 2; i++ { |
| + if err := p.recoverPending(ctx); err != nil { |
| + t.Fatal("retained parks blocked recovery", err) |
| + } |
| + } |
| + if len(a.calls) != 2 || a.calls[0] != "" || a.calls[1] != "128" { |
| + t.Fatal(a.calls) |
| + } |
| +} |
| +func TestServingParkPressurePrecedesSlowRecovery(t *testing.T) { |
| + cleaned := false |
| + ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel() |
| + _, err := maintainServingParkPass(ctx, "", func(call context.Context) error { |
| + if call.Err() != nil { |
| + t.Fatal(call.Err()) |
| + } |
| + cleaned = true |
| + return nil |
| + }, func(call context.Context, _ string, _ int) (servingParkRecoveryPage, error) { |
| + if !cleaned { |
| + t.Fatal("recovery starved pressure cleanup") |
| + } |
| + <-call.Done() |
| + return servingParkRecoveryPage{}, call.Err() |
| + }, 10*time.Millisecond) |
| + if !cleaned || err == nil { |
| + t.Fatal(cleaned, err) |
| + } |
| +} |
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go| index 9b9f6ff88..d8868b02d 100644 |
| --- a/internal/host/claimretire_transport.go |
| +++ b/internal/host/claimretire_transport.go |
| @@ -34,39 +34,30 @@ type ClaimRootHTTP struct { |
| Roots map[string]string |
| } |
| -func (h ClaimRootHTTP) RecoverPending(ctx context.Context) (recoveryErr error) { |
| - // Serving intents retain their own authority checks even when another |
| - // startup recovery failed. Attempt them before the service accepts work. |
| - defer func() { recoveryErr = errors.Join(recoveryErr, h.servingParks.recoverPending(ctx)) }() |
| +func (h ClaimRootHTTP) Start(ctx context.Context) { |
| + if h.executionRuntime != nil { |
| + h.executionRuntime.Start(ctx) |
| + } |
| + h.servingParks.Start(ctx) |
| +} |
| - if err := h.servingHandover().Recover(ctx); err != nil { |
| - return err |
| +func (h ClaimRootHTTP) RecoverPending(ctx context.Context) (recoveryErr error) { |
| + // Controller recovery is independent of per-host retained journals and parks. |
| + if h.Execution != nil { |
| + recoveryErr = h.Execution.Recover(ctx) |
| } |
| + recoveryErr = errors.Join(recoveryErr, h.Stop.RecoverPending(ctx)) |
| + recoveryErr = errors.Join(recoveryErr, h.servingHandover().Recover(ctx)) |
| cold := hostbox.ColdHandover{OperationsOnly: true, Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt} |
| - if err := cold.Recover(ctx); err != nil { |
| - return err |
| - } |
| - if err := h.Stop.RecoverPending(ctx); err != nil { |
| - return err |
| - } |
| + recoveryErr = errors.Join(recoveryErr, cold.Recover(ctx)) |
| journals, err := hostbox.SavedRetirementJournals(h.Reconcile.Retirement.JournalDir) |
| - if err != nil { |
| - return err |
| - } |
| + recoveryErr = errors.Join(recoveryErr, err) |
| for _, journal := range journals { |
| - if err = h.retire(ctx, *journal.Target); err != nil { |
| - return err |
| - } |
| - } |
| - if err := h.recoverRootInventory(ctx); err != nil { |
| - return err |
| - } |
| - if h.Execution != nil { |
| - // Retained failures deny their host; recovery must not remove the |
| - // socket needed to finish their existing stop protocol. |
| - _ = h.Execution.Recover(ctx) |
| + recoveryErr = errors.Join(recoveryErr, h.retire(ctx, *journal.Target)) |
| } |
| - return ctx.Err() |
| + recoveryErr = errors.Join(recoveryErr, h.recoverRootInventory(ctx)) |
| + recoveryErr = errors.Join(recoveryErr, h.servingParks.recoverPending(ctx)) |
| + return errors.Join(recoveryErr, ctx.Err()) |
| } |
| func (h ClaimRootHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/claim/execution" && r.URL.Path != "/claim/recovery-rebind" && r.URL.Path != "/claim/retire" && r.URL.Path != "/claim/handover" && r.URL.Path != "/claim/journal" { |
diff --git a/internal/host/claimservingmaintenance.go b/internal/host/claimservingmaintenance.go| index a26fcc34a..03dd44719 100644 |
| --- a/internal/host/claimservingmaintenance.go |
| +++ b/internal/host/claimservingmaintenance.go |
| @@ -132,14 +132,16 @@ func (p *servingParkRuntime) Start(ctx context.Context) { |
| ticker := time.NewTicker(time.Minute) |
| defer ticker.Stop() |
| after := "" |
| + lastLog := time.Time{} |
| for { |
| - bounded, cancel := context.WithTimeout(call, 10*time.Second) |
| + bounded, cancel := context.WithCancel(call) |
| if a, ok := p.authority.(interface { |
| maintainServingParks(context.Context, string) (string, error) |
| }); ok { |
| var err error |
| after, err = a.maintainServingParks(bounded, after) |
| - if err != nil && call.Err() == nil { |
| + if err != nil && call.Err() == nil && time.Since(lastLog) >= time.Hour { |
| + lastLog = time.Now() |
| log.Print(err) |
| } |
| } |
| @@ -152,3 +154,20 @@ func (p *servingParkRuntime) Start(ctx context.Context) { |
| } |
| }() |
| } |
| + |
| +func maintainServingParkPass(ctx context.Context, after string, cleanup func(context.Context) error, recover func(context.Context, string, int) (servingParkRecoveryPage, error), budget time.Duration) (string, error) { |
| + pressure, stopPressure := context.WithTimeout(ctx, budget) |
| + pressureErr := cleanup(pressure) |
| + stopPressure() |
| + recovery, stopRecovery := context.WithTimeout(ctx, budget) |
| + defer stopRecovery() |
| + page, err := recover(recovery, after, servingParkRecoveryLimit) |
| + for _, result := range page.Results { |
| + err = errors.Join(err, result.Err) |
| + } |
| + next := "" |
| + if page.More { |
| + next = page.Next |
| + } |
| + return next, errors.Join(pressureErr, err) |
| +} |
diff --git a/internal/host/claimservingmaintenance_linux.go b/internal/host/claimservingmaintenance_linux.go| index 852c6a2a3..dd6ebebbc 100644 |
| --- a/internal/host/claimservingmaintenance_linux.go |
| +++ b/internal/host/claimservingmaintenance_linux.go |
| @@ -4,9 +4,9 @@ package host |
| import ( |
| "context" |
| - "errors" |
| "github.com/carlosframework/platform/internal/instancefs" |
| "os" |
| + "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/instancectl" |
| @@ -37,19 +37,11 @@ func servingRoomFile(parent *os.File) error { |
| } |
| func (p *servingParkRoot) maintainServingParks(ctx context.Context, after string) (string, error) { |
| - page, err := p.recoverServingParks(ctx, after, servingParkRecoveryLimit) |
| - for _, result := range page.Results { |
| - err = errors.Join(err, result.Err) |
| - } |
| - next := "" |
| - if page.More { |
| - next = page.Next |
| - } |
| fs, ok := p.ledger.fs.(*servingParkFS) |
| if !ok { |
| - return next, hostbox.ErrUnsupported |
| + return after, hostbox.ErrUnsupported |
| } |
| - return next, errors.Join(err, p.ledger.maintainParks(ctx, fs.room)) |
| + return maintainServingParkPass(ctx, after, func(call context.Context) error { return p.ledger.maintainParks(call, fs.room) }, p.recoverServingParks, 10*time.Second) |
| } |
| func claimServingRoom(data string) error { |
diff --git a/internal/host/claimservingpark_reap_test.go b/internal/host/claimservingpark_reap_test.go| new file mode 100644 |
| index 000000000..c8318da3d |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_reap_test.go |
| @@ -0,0 +1,32 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| +) |
| + |
| +// reapLocked needs only namespace/slot/record authority; it never takes a host |
| +// guard. Pressure selection and authenticated replacement are separate callers. |
| +func (l *servingParkLedger) reapLocked(ctx context.Context, id string) error { |
| + r, err := l.read(id) |
| + if err != nil { |
| + return err |
| + } |
| + unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(r.Authorization.Successor.Box, r.Authorization.Successor.Host)+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + unlockRecord, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlockRecord() |
| + if r.Closed { |
| + return nil |
| + } |
| + if !r.Complete || r.Disposition != servingParkRetain || l.now().Before(r.ReapAfter) { |
| + return hostbox.ErrChanged |
| + } |
| + return l.deleteLocked(ctx, &r, "expiry") |
| +} |
diff --git a/internal/host/claimservingpark_records.go b/internal/host/claimservingpark_records.go| index e4c06c985..b1a7901c7 100644 |
| --- a/internal/host/claimservingpark_records.go |
| +++ b/internal/host/claimservingpark_records.go |
| @@ -658,32 +658,6 @@ func (l *servingParkLedger) deleteLocked(ctx context.Context, r *servingParkReco |
| return save() |
| } |
| -// reapLocked needs only namespace/slot/record authority; it never takes a host |
| -// guard. Pressure selection and authenticated replacement are separate callers. |
| -func (l *servingParkLedger) reapLocked(ctx context.Context, id string) error { |
| - r, err := l.read(id) |
| - if err != nil { |
| - return err |
| - } |
| - unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(r.Authorization.Successor.Box, r.Authorization.Successor.Host)+".lock") |
| - if err != nil { |
| - return err |
| - } |
| - defer unlock() |
| - unlockRecord, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| - if err != nil { |
| - return err |
| - } |
| - defer unlockRecord() |
| - if r.Closed { |
| - return nil |
| - } |
| - if !r.Complete || r.Disposition != servingParkRetain || l.now().Before(r.ReapAfter) { |
| - return hostbox.ErrChanged |
| - } |
| - return l.deleteLocked(ctx, &r, "expiry") |
| -} |
| - |
| func (l *servingParkLedger) replaceLocked(ctx context.Context, id string) error { |
| unlock, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| if err != nil { |
diff --git a/internal/host/claimservingpark_recovery.go b/internal/host/claimservingpark_recovery.go| index 278e92a51..b0e594a4f 100644 |
| --- a/internal/host/claimservingpark_recovery.go |
| +++ b/internal/host/claimservingpark_recovery.go |
| @@ -4,8 +4,10 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "log" |
| "sort" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| ) |
| @@ -126,7 +128,15 @@ func (p *servingParkRuntime) recoverPending(ctx context.Context) error { |
| if !ok { |
| return hostbox.ErrUnsupported |
| } |
| - page, err := recovery.recoverServingParks(call, "", servingParkRecoveryLimit) |
| + p.recoveryMu.Lock() |
| + defer p.recoveryMu.Unlock() |
| + page, err := recovery.recoverServingParks(call, p.recoveryAfter, servingParkRecoveryLimit) |
| + if err == nil { |
| + p.recoveryAfter = "" |
| + if page.More { |
| + p.recoveryAfter = page.Next |
| + } |
| + } |
| var failures []error |
| if err != nil { |
| failures = append(failures, hostbox.ErrChanged) |
| @@ -146,8 +156,9 @@ func (p *servingParkRuntime) recoverPending(ctx context.Context) error { |
| failures = append(failures, fmt.Errorf("Host %q: serving database retirement %q retained: %s.", result.Host, result.ID, reason)) |
| } |
| } |
| - if page.More { |
| - failures = append(failures, hostbox.ErrChanged) |
| + if len(failures) > 0 && time.Since(p.lastRecoveryLog) >= time.Hour { |
| + log.Print(errors.Join(failures...)) |
| + p.lastRecoveryLog = time.Now() |
| } |
| - return errors.Join(failures...) |
| + return errors.Join(err, call.Err()) |
| } |
diff --git a/internal/host/claimservingpark_rootrunner_linux_test.go b/internal/host/claimservingpark_rootrunner_linux_test.go| index d6a8bb932..e348c8fc0 100644 |
| --- a/internal/host/claimservingpark_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpark_rootrunner_linux_test.go |
| @@ -682,6 +682,10 @@ func TestRootRebootServingParkAcceptancePhase2(t *testing.T) { |
| } |
| func TestRootFilesSeedRealOriginPreparation(t *testing.T) { |
| + runRootFilesSeedRealOriginPreparation(t, false) |
| +} |
| +func TestRootFilesSeedRealOriginTenant(t *testing.T) { runRootFilesSeedRealOriginPreparation(t, true) } |
| +func runRootFilesSeedRealOriginPreparation(t *testing.T, live bool) { |
| servingRootRunner(t) |
| if os.Getenv("PLAN14_ROOT_FIXTURE") == t.Name() { |
| if err := unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, ""); err != nil { |
| @@ -690,7 +694,11 @@ func TestRootFilesSeedRealOriginPreparation(t *testing.T) { |
| if !plan14RootFixture(t) { |
| t.Fatal("root child did not enter its scratch root") |
| } |
| - runFilesSeedOrdinaryPreparation(t, "real-seed", "same-controller", true) |
| + if live { |
| + runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", []bool{true}, []filesSeedLiveWitness{rootFilesSeedLiveWitness(t, "/tenant.test")}) |
| + } else { |
| + runFilesSeedOrdinaryPreparation(t, "real-seed", "same-controller", true) |
| + } |
| return |
| } |
| root := t.TempDir() |
| @@ -706,6 +714,15 @@ func TestRootFilesSeedRealOriginPreparation(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + if live { |
| + body, err := os.ReadFile(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(root, "tenant.test"), body, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| cmd := exec.CommandContext(t.Context(), binary, "-test.run=^"+t.Name()+"$", "-test.v", "-test.timeout=3m") |
| cmd.Env = append(os.Environ(), "PLAN14_ROOT_FIXTURE="+t.Name(), "PLAN14_ROOT_DIR="+root) |
| cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: unix.CLONE_NEWNS} |
diff --git a/internal/host/claimservingpark_transport.go b/internal/host/claimservingpark_transport.go| index 9bf5d1145..42ae7040c 100644 |
| --- a/internal/host/claimservingpark_transport.go |
| +++ b/internal/host/claimservingpark_transport.go |
| @@ -7,6 +7,7 @@ import ( |
| "net" |
| "net/http" |
| "sync" |
| + "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -38,6 +39,9 @@ type servingParkRuntime struct { |
| cancel context.CancelFunc |
| mu sync.Mutex |
| active int |
| + recoveryMu sync.Mutex |
| + recoveryAfter string |
| + lastRecoveryLog time.Time |
| closing, closed, started bool |
| idle chan struct{} |
| } |
diff --git a/internal/host/claimservingpeer_rootrunner_linux_test.go b/internal/host/claimservingpeer_rootrunner_linux_test.go| index 4a00ef143..6d6883a2c 100644 |
| --- a/internal/host/claimservingpeer_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpeer_rootrunner_linux_test.go |
| @@ -3,6 +3,7 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| @@ -18,14 +19,17 @@ import ( |
| "strconv" |
| "strings" |
| "sync/atomic" |
| + "syscall" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/rootstate" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -95,6 +99,12 @@ func TestRootServingParkPeerPayload(t *testing.T) { |
| } |
| client := hostbox.ServingParkClient{Call: call} |
| var manager *cgroup.Manager |
| + var seedServer *filesSeedServer |
| + defer func() { |
| + if seedServer != nil { |
| + seedServer.close(t.Context()) |
| + } |
| + }() |
| for { |
| var q servingPeerCommand |
| if err := decode.Decode(&q); errors.Is(err, io.EOF) { |
| @@ -105,6 +115,25 @@ func TestRootServingParkPeerPayload(t *testing.T) { |
| var reply servingPeerReply |
| var callErr error |
| switch q.Action { |
| + case "seed-listen": |
| + ready := make(chan struct{}) |
| + close(ready) |
| + seedServer, callErr = startFilesSeedSocket(t.Context(), ready, filesSeedSocket, func(ctx context.Context, c *net.UnixConn) error { |
| + return authenticateFilesSeedPeer(ctx, c, os.Geteuid(), false) |
| + }, func(ctx context.Context, host string, out io.Writer) error { |
| + _, err := io.WriteString(out, "accepted "+host+"\n") |
| + return err |
| + }) |
| + case "seed-flat-client": |
| + binary, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.CommandContext(t.Context(), binary, "-test.run=^TestRootFilesSeedClientPayload$", "-test.v") |
| + cmd.Env = append(os.Environ(), "FILES_SEED_EXPECT_REFUSAL=1", "FILES_SEED_CLIENT=1") |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + callErr = fmt.Errorf("flat client: %w: %s", err, out) |
| + } |
| case "seed-manager": |
| manager, callErr = filesSeedManager(t.Context()) |
| case "seed-capability": |
| @@ -312,6 +341,9 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| t.Fatal(err) |
| } |
| f.p.root.Stop = production.(ClaimStopHTTP) |
| + if namespace == "seed-killed" { |
| + f.p.root.Stop.Coordinator.Kernel = servingSeedRecoveryTrace{LinuxStopKernel: f.p.root.Stop.Coordinator.Kernel.(LinuxStopKernel), t: t} |
| + } |
| kernelResults := make(chan error, 8) |
| if writer { |
| kernel := f.p.root.Stop.Coordinator.Kernel |
| @@ -342,7 +374,7 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| t.Fatal(err) |
| } |
| var handler http.Handler = h |
| - if namespace == "seed-delegation" { |
| + if strings.HasPrefix(namespace, "seed-") { |
| capability := newLaunchFixture(t, hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box-1"}).root |
| capability.Authenticate = h.Stop.Authenticate |
| capability.SeedController = verifyFilesSeedDelegation |
| @@ -350,6 +382,9 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| } |
| server := &instancectl.Server{ClaimStop: handler, PeerOK: func(net.Conn) error { return nil }, Logf: t.Logf} |
| ctx, cancel := context.WithCancel(context.Background()) |
| + if lifecycle, ok := handler.(interface{ Start(context.Context) }); ok { |
| + lifecycle.Start(ctx) |
| + } |
| done := make(chan error, 1) |
| go func() { done <- server.Serve(ctx, listener) }() |
| t.Cleanup(func() { |
| @@ -380,6 +415,18 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| } |
| // 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) |
| + if namespace == "seed-live" { |
| + if err := os.Mkdir("/run/carlos", 0755); err != nil { |
| + t.Fatal("refusing existing runtime directory", err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll("/run/carlos") }) |
| + uid, _ := strconv.Atoi(account.Uid) |
| + gid, _ := strconv.Atoi(account.Gid) |
| + if err := os.Chown("/run/carlos", uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + content += "ReadWritePaths=/run/carlos\n" |
| + } |
| 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 { |
| @@ -452,10 +499,83 @@ func runServingParkPeerNamespace(t *testing.T, handover, writer bool, namespace |
| } |
| return reply |
| } |
| - if namespace == "seed-delegation" { |
| + if strings.HasPrefix(namespace, "seed-") { |
| command(servingPeerCommand{Action: "seed-capability"}, true) |
| command(servingPeerCommand{Action: "seed-manager"}, false) |
| command(servingPeerCommand{Action: "seed-capability"}, false) |
| + if namespace == "seed-killed" { |
| + observed := f.f.read() |
| + attempt := hostclaim.WakeRetryAttempt{ID: "killed-attempt", Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: next.BootID, ControllerID: next.ControllerID, Session: observed.Claim.Holder.Session, InvocationID: "killed-invocation", LaunchInputs: "seed-inputs"} |
| + observed, err = hostclaim.Update(t.Context(), f.f.claims, observed, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: observed.Claim.Holder.Incarnation, EpisodeID: "killed-seed", Attempt: attempt, Now: f.f.now})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + target := recoveryTuple(observed, claimStopLocal{Actor: f.actor}) |
| + pre := command(servingPeerCommand{Action: "prelaunch", Target: target}, false) |
| + former := next |
| + servingRunnerSystemctl(t, "kill", "--signal=KILL", "carlos-edge.service") |
| + // Start waits for systemd to reap the killed MainPID; no graceful cleanup ran. |
| + servingRunnerSystemctl(t, "stop", "carlos-edge.service") |
| + _, encode, decode, next = connect() |
| + if former.PID == next.PID || former.ControllerID == next.ControllerID { |
| + t.Fatal("kill did not replace controller") |
| + } |
| + command(servingPeerCommand{Action: "seed-manager"}, false) |
| + command(servingPeerCommand{Action: "seed-capability"}, false) |
| + rt := registry.Route{Host: observed.Host, DBPath: f.db} |
| + files := &movesFiles{DataDir: f.f.guard.DataDir, EnvFile: func(host, db string) (claimEnvFile, error) { |
| + return claimEnvFile{Nested: true, Dir: filepath.Dir(db), Name: host + ".env"}, nil |
| + }} |
| + service := &activator.Service{} |
| + exclusion, err := service.AcquireSeedExclusion(t.Context(), rt.Host, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer exclusion.Release() |
| + stops := 0 |
| + retries := activator.ServingRetries{Claims: f.f.claims, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: next.BootID, ControllerID: next.ControllerID, Now: func() time.Time { return f.f.now }, NewID: f.f.newID, Inputs: func(context.Context, registry.Route) (string, error) { return "seed-inputs", nil }, ExactHealth: func(context.Context, *exec.Cmd, hostclaim.WakeRetryAttempt) error { return hostbox.ErrChanged }, StopAttempt: func(ctx context.Context, o hostclaim.Observed, a hostclaim.WakeRetryAttempt) error { |
| + stops++ |
| + req := StopRequest{Region: f.actor.Region, Recover: true, Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: o.Claim.Holder.Stamp, WriterSession: o.Claim.Holder.Session, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: a.BootID, ControllerID: a.ControllerID, ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: pre.Launch.Target.DirectoryRevision} |
| + command(servingPeerCommand{Action: "stop", Stop: req}, false) |
| + return nil |
| + }} |
| + settled, err := settleFilesSeedRerun(t.Context(), rt, files, retries, observed, exclusion) |
| + if err != nil { |
| + t.Fatal("killed rerun settlement", err) |
| + } |
| + retry := settled.Claim.Holder.WakeRetry |
| + if retry == nil || retry.State != "open" || retry.AttemptsReserved != 1 || retry.Attempt != nil || stops != 1 { |
| + t.Fatal("retry debt not settled exactly", retry, stops) |
| + } |
| + again, err := settleFilesSeedRerun(t.Context(), rt, files, retries, settled, exclusion) |
| + if err != nil || again.ETag != settled.ETag || stops != 1 { |
| + t.Fatal("rerun repeated stop or reservation", err) |
| + } |
| + return |
| + } |
| + if namespace == "seed-live" { |
| + command(servingPeerCommand{Action: "seed-listen"}, false) |
| + uid, _ := strconv.Atoi(account.Uid) |
| + gid, _ := strconv.Atoi(account.Gid) |
| + cmd := exec.CommandContext(t.Context(), installed, "-test.run=^TestRootFilesSeedClientPayload$", "-test.v") |
| + cmd.Dir = root |
| + cmd.Env = append(os.Environ(), "FILES_SEED_CLIENT=1") |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}} |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("external client: %v %s", err, out) |
| + } |
| + command(servingPeerCommand{Action: "seed-flat-client"}, false) |
| + c, err := net.Dial("unix", filesSeedSocket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + json.NewEncoder(c).Encode(filesSeedRequest{Verb: "seed"}) |
| + c.SetReadDeadline(time.Now().Add(time.Second)) |
| + if _, err := c.Read(make([]byte, 1)); err == nil { |
| + t.Fatal("root client accepted") |
| + } |
| + c.Close() |
| + } |
| if err := verifyFilesSeedDelegation(t.Context(), first); err == nil { |
| t.Fatal("former controller accepted") |
| } |
| @@ -683,3 +803,63 @@ func (p servingPeerParkTrace) completeServingDatabasePark(ctx context.Context, c |
| func TestRootFilesSeedDelegatedController(t *testing.T) { |
| runServingParkPeerNamespace(t, false, false, "seed-delegation") |
| } |
| + |
| +func TestRootFilesSeedLiveTransport(t *testing.T) { |
| + runServingParkPeerNamespace(t, false, false, "seed-live") |
| +} |
| + |
| +func TestRootFilesSeedClientPayload(t *testing.T) { |
| + if os.Getenv("FILES_SEED_CLIENT") != "1" { |
| + t.Fatal("invoke through live transport witness") |
| + } |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| + t.Setenv("CARLOS_MOVES_PHASE1", "") |
| + var out bytes.Buffer |
| + err := runSystemFiles(t.Context(), []string{"seed", "--host", recoveryHost}, &out) |
| + if os.Getenv("FILES_SEED_EXPECT_REFUSAL") == "1" { |
| + if err == nil { |
| + t.Fatal("tenant admitted") |
| + } |
| + return |
| + } |
| + if err != nil || out.String() != "accepted "+recoveryHost+"\n" { |
| + t.Fatal(err, out.String()) |
| + } |
| + // A same-UID impostor can own a pathname but cannot impersonate MainPID. |
| + held := filesSeedSocket + ".held" |
| + if err := os.Rename(filesSeedSocket, held); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { |
| + if err := os.Rename(held, filesSeedSocket); err != nil { |
| + t.Error(err) |
| + } |
| + }() |
| + fake, err := net.ListenUnix("unix", &net.UnixAddr{Name: filesSeedSocket, Net: "unix"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer fake.Close() |
| + if err := os.Chmod(filesSeedSocket, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := runSystemFiles(t.Context(), []string{"seed"}, io.Discard); !errors.Is(err, ErrClaimStopIdentity) { |
| + t.Fatal("impostor accepted", err) |
| + } |
| +} |
| + |
| +func TestRootFilesSeedKilledControllerRerun(t *testing.T) { |
| + runServingParkPeerNamespace(t, false, false, "seed-killed") |
| +} |
| + |
| +type servingSeedRecoveryTrace struct { |
| + LinuxStopKernel |
| + t *testing.T |
| +} |
| + |
| +func (k servingSeedRecoveryTrace) RecoverStopped(ctx context.Context, target hostguard.FenceTarget, in StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) { |
| + evidence, ack, err := k.LinuxStopKernel.RecoverStopped(ctx, target, in) |
| + k.t.Logf("killed controller root recovery: %v", err) |
| + return evidence, ack, err |
| +} |
diff --git a/internal/host/claimstop_kernel_linux.go b/internal/host/claimstop_kernel_linux.go| index 1ce3933c3..edbb0b1cf 100644 |
| --- a/internal/host/claimstop_kernel_linux.go |
| +++ b/internal/host/claimstop_kernel_linux.go |
| @@ -315,6 +315,9 @@ func (k LinuxStopKernel) RecoverStopped(ctx context.Context, t hostguard.FenceTa |
| } |
| } |
| if _, err := scanStopCgroupsMode(ctx, k.CgroupRoot, j.Cgroups, false); err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return k.recoverUnlaunchedAbsent(ctx, t, in, j, journal) |
| + } |
| return refuse(err) |
| } |
| groups := append([]hostbox.StopCgroupIdentity(nil), j.Cgroups...) |
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go| index a576159c0..bc33340a5 100644 |
| --- a/internal/host/claimstop_run.go |
| +++ b/internal/host/claimstop_run.go |
| @@ -82,8 +82,11 @@ func RunInstanceControl(args []string) { |
| log.Print(err) |
| } |
| finishStartup() |
| - execution.Start(ctx) |
| - handler.(ClaimRootHTTP).servingParks.Start(ctx) |
| + lifecycle, ok := handler.(interface{ Start(context.Context) }) |
| + if !ok { |
| + log.Fatal(ErrClaimStopUnavailable) |
| + } |
| + lifecycle.Start(ctx) |
| fenceDone := make(chan struct{}) |
| go func() { defer close(fenceDone); runHostFencePass(ctx) }() |
| defer func() { cancel(); <-fenceDone }() |
diff --git a/internal/host/rootexecution_recovery.go b/internal/host/rootexecution_recovery.go| index 4edc29977..3b87f649d 100644 |
| --- a/internal/host/rootexecution_recovery.go |
| +++ b/internal/host/rootexecution_recovery.go |
| @@ -3,6 +3,7 @@ package host |
| import ( |
| "context" |
| "errors" |
| + "log" |
| "path/filepath" |
| "time" |
| @@ -42,6 +43,8 @@ func recoverRootExecution(ctx context.Context, dataDir, guardDir string) (result |
| return err |
| } |
| defer reg.Close() |
| + // FS-R4: an enrolled census keeps retained root state under maintenance |
| + // even if claims are now off; the no-census branch above stays separate. |
| ctx = context.WithValue(ctx, claimEntryKey{}, claimEntry{FlagRead: true, Enabled: true, Store: store}) |
| ctx = withClaimRuntimeResources(ctx, ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: dataDir, OptDir: filepath.Dir(filepath.Dir(guardDir)), UnitsDir: "/etc/systemd/system"}) |
| ctx, err = ensureProductionClaimAssembly(ctx) |
| @@ -69,8 +72,11 @@ func recoverRootExecution(ctx context.Context, dataDir, guardDir string) (result |
| return err |
| } |
| root := handler.(ClaimRootWithLaunch).Root |
| - if err := root.RecoverPending(ctx); err != nil { |
| + if err := runtime.broker.Recover(ctx); err != nil { |
| return err |
| } |
| - return runtime.broker.Recover(ctx) |
| + if err := root.RecoverPending(ctx); err != nil { |
| + log.Print(err) |
| + } |
| + return nil |
| } |
diff --git a/internal/hostbox/servinghandover.go b/internal/hostbox/servinghandover.go| index 8109e0ee5..14fac53eb 100644 |
| --- a/internal/hostbox/servinghandover.go |
| +++ b/internal/hostbox/servinghandover.go |
| @@ -253,17 +253,20 @@ func (h ServingHandover) Recover(ctx context.Context) error { |
| if err != nil { |
| return err |
| } |
| + var failures []error |
| for _, e := range entries { |
| if e.IsDir() || filepath.Ext(e.Name()) != ".json" { |
| continue |
| } |
| body, err := readTrustedJournal(filepath.Join(h.JournalDir, e.Name())) |
| if err != nil { |
| - return err |
| + failures = append(failures, err) |
| + continue |
| } |
| var j coldJournal |
| if decodeStrict(body, &j) != nil || j.V != 1 || j.ID+".json" != e.Name() || j.ServingInstallation == nil { |
| - return ErrTypedJournal |
| + failures = append(failures, ErrTypedJournal) |
| + continue |
| } |
| if j.Phase == "complete" { |
| found := false |
| @@ -275,10 +278,11 @@ func (h ServingHandover) Recover(ctx context.Context) error { |
| } |
| } |
| if err = h.Run(ctx, j.Prior, j.Successor); err != nil { |
| - return err |
| + failures = append(failures, err) |
| + continue |
| } |
| } |
| - return nil |
| + return errors.Join(failures...) |
| } |
| func (h ServingHandover) at(point string) error { |
| if h.checkpoint != nil { |
diff --git a/internal/hostbox/servinghandover_linux_test.go b/internal/hostbox/servinghandover_linux_test.go| index d5fecd0e0..51df3f7ca 100644 |
| --- a/internal/hostbox/servinghandover_linux_test.go |
| +++ b/internal/hostbox/servinghandover_linux_test.go |
| @@ -165,3 +165,30 @@ func TestServingHandoverRefusesOldDestructiveRecovery(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +func TestServingHandoverStaleJournalDoesNotBlockHealthy(t *testing.T) { |
| + h, p, n := servingHandoverFixture(t) |
| + h.checkpoint = func(point string) error { |
| + if point == "intent" { |
| + return errors.New("interrupt") |
| + } |
| + return nil |
| + } |
| + if h.Run(t.Context(), p, n) == nil { |
| + t.Fatal("no interrupted intent") |
| + } |
| + stale := filepath.Join(h.JournalDir, "000-stale.json") |
| + if err := os.WriteFile(stale, []byte("stale journal"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.checkpoint = nil |
| + if h.Recover(t.Context()) == nil { |
| + t.Fatal("stale journal was not reported") |
| + } |
| + if b, err := os.ReadFile(stale); err != nil || string(b) != "stale journal" { |
| + t.Fatal("stale evidence changed", err) |
| + } |
| + if err := h.Guard.With(t.Context(), p.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}, func(*hostclaim.Claim) error { return nil }, func() error { return nil }); err != nil { |
| + t.Fatal("healthy journal not recovered", err) |
| + } |
| +} |
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go| index 0dd77dfa2..1cfe45139 100644 |
| --- a/internal/provision/plan14_legacy_identity_test.go |
| +++ b/internal/provision/plan14_legacy_identity_test.go |
| @@ -616,12 +616,21 @@ func TestPlan14SeedOverlayPreservesLegacySeal(t *testing.T) { |
| if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-seed.tsv", &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| - if len(inv.Legacy) != before { |
| - t.Fatal("seed changed the legacy fingerprints") |
| + if len(inv.Legacy) != before+3 { |
| + t.Fatal("FS-R6 permits exactly three additional legacy fingerprints") |
| } |
| - if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + if errs := checkRootReadLegacy(inv, "b5b04f416089d97dbff3b9c6b6cb9d9c83f874449577946f651dc8c7a66a9f67"); len(errs) != 0 { |
| t.Fatal(errs) |
| } |
| + // FS-R6 keeps these existing legacy classes; the seed cannot promote them. |
| + for _, key := range []string{"internal/activator/activator.go:Service.sweepOnce", "internal/activator/servingretry.go:ServingRetries.Wrap", "internal/host/relocationflush.go:ReleaseFlushFiles.Flush"} { |
| + if e := inv.Entries[key]; e.Class != "legacy" || e.Task != "accepted-legacy" || !strings.Contains(e.Basis, "census-ruling-FS-R6") { |
| + t.Fatalf("unruled legacy disposition %s: %+v", key, e) |
| + } |
| + } |
| + if e := inv.Entries["internal/host/claimwakeassembly.go:ClaimWakeAssembly.Build"]; e.Task != "C,G,J1,J4" { |
| + t.Fatal("wake assembly lost its owners", e.Task) |
| + } |
| for _, key := range []string{ |
| "internal/host/claimconstruction.go:OpenProductionClaimAssembly", |
| "internal/host/claimproduction.go:ProductionClaimOwnerProvider.OpenClaimOwners", |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 37f398285..4706c311b 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -747,6 +747,8 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-seed.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // FS-R6 explicitly preserves these three legacy readers; all other proposed rows remain unapproved. |
| + seal = "b5b04f416089d97dbff3b9c6b6cb9d9c83f874449577946f651dc8c7a66a9f67" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index de7ec3c7c..51c29f1e7 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -1,7 +1,16 @@ |
| -# Files seed overlay; loads LAST. Run 24 proposals, not census approval. |
| +# Files seed overlay loads LAST. Run 25; 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 |
| internal/activator/activator.go Service.serveWakeError diagnostic C,J4 rev3=PROPOSED-seed; incarnation-keyed missing-files diagnostics and indexed retry results; throttles output only and grants no retry, stop or launch registry:Host@1353,registry:Host@1417 377f6ed44f225f9aa6bba4ebfb4350cd7dcf9c01dd3f932522b35a7df6b29032 |
| -internal/activator/activator.go Service.sweepOnce authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative 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 |
| +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 |
| internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative ad71a6a968693f86285e2d61f8a65df19251761e3b7694056fc829767e51c9ac |
| +internal/activator/cleanup.go CleanupContext authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join cf999f42acf01f4c68998f66da84ac40df4026fc5d1337958f703129f4cba01d |
| +internal/activator/cleanup.go WithCleanupBudget authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join 01b9fcc13496122c211b9f5520a73b42b8b16b81a0a0f9c3174774c269ca8e22 |
| +internal/activator/cleanup.go cleanupBudget.start authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join 7131591c80ae84772fc33e5cd933ef938cf38ea85b6d1b40a987eabc4ee8c69b |
| +internal/activator/cleanup.go type cleanupBudget authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join 3dd761b939faa3a5f26a8b5a099bdbc4761d642e77202aeae1d2fd484afbdc89 |
| +internal/activator/cleanup.go type cleanupBudgetKey authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join 62209a88c68983d0d6dd17667c2f1d07ec3f54f1add299191bdfcc6953333402 |
| +internal/activator/cleanup.go type cleanupContextKey authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join d568563d33ac11e33615e2a294deacbaa726321529d49fc71fde8f63ab204c4f |
| internal/activator/maintenance.go Service.LiftFence authority B,C,D,F,J4 rev3=PROPOSED-seed; shared activator state and opaque owner token; waits existing request, scheduled-token and stop preparations before fencing; exact owner alone may resume or release e3b978890731f441369476da9151e9b6cddc17627bea370548eb686c5ef7494f |
| internal/activator/maintenance.go Service.LiftFenceAs authority B,C,D,F,J4 rev3=PROPOSED-seed; shared activator state and opaque owner token; waits existing request, scheduled-token and stop preparations before fencing; exact owner alone may resume or release 260411d3ccb3648990fe43be308e7caccd54a43b89a0e9adce69add5172369dd |
| internal/activator/maintenance.go Service.fenceAndStop authority B,C,D,F,J4 rev3=PROPOSED-seed; shared activator state and opaque owner token; waits existing request, scheduled-token and stop preparations before fencing; exact owner alone may resume or release 770b4a033e91e154549e86c03683ca6512d0de3025d6d354f6bb86dfc864b2c4 |
| @@ -16,35 +25,38 @@ internal/activator/seed_exclusion.go SeedExclusion.Release authority B,C,D,F,J4 |
| internal/activator/seed_exclusion.go Service.AcquireSeedExclusion authority B,C,D,F,J4 rev3=PROPOSED-seed; shared activator state and opaque owner token; waits existing request, scheduled-token and stop preparations before fencing; exact owner alone may resume or release cd98d58320a590564a599bd379cf146b3ed1499032089bbfac3ddde2424619aa |
| internal/activator/seed_exclusion.go type SeedExclusion authority B,C,D,F,J4 rev3=PROPOSED-seed; shared activator state and opaque owner token; waits existing request, scheduled-token and stop preparations before fencing; exact owner alone may resume or release 9259b90bd5130f5abd76c46ffc395b89820e26c3bd73461128d82f4fedca96d6 |
| internal/activator/servingretry.go ServingRetries.SettleAbandoned authority C,J4 rev3=PROPOSED-seed; current serving retry identity, stop settlement and bounded reservation; capacity checked before side effects and immediately before reservation; explicit rerun settlement spends no new attempt file:Exec@279,file:Read@387,file:Read@732 8885d486d4daea05411f8c5ab0afa5b340542bb624bbfe82518d0b1f5063d171 |
| -internal/activator/servingretry.go ServingRetries.Wrap authority C,J4 rev3=PROPOSED-seed; current serving retry identity, stop settlement and bounded reservation; capacity checked before side effects and immediately before reservation; explicit rerun settlement spends no new attempt file:Prepare@128,file:Prepare@4369,file:Prepare@4499,file:Read@2932,file:Read@3575,file:Read@5098,file:Read@6063,file:Read@6728,whole:Route@1212,whole:Route@1572,whole:Route@1670,whole:Route@1970,whole:Route@2382,whole:Route@4517,whole:Route@5465,whole:Route@6594,whole:Route@876,whole:Route@954 dc27bad808ca7cab72e3e0b6ea229186f954f982db44e5d6da3d0fcad1ac6942 |
| +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 |
| internal/activator/swaphold.go Service.wakeFailureEvent diagnostic C,J4 rev3=PROPOSED-seed; incarnation-keyed missing-files diagnostics and indexed retry results; throttles output only and grants no retry, stop or launch debf64b1e73210eb4a6c88dd82f76e8be2e3e0a4f43e272341efe227703f9ca0 |
| +internal/host/agent.go runAgent authority B,C,D,F,J4 rev3=PROPOSED-seed; agent startup and claim-gated seed endpoint; shared shutdown deadline and handler join before shared closure file:Exec@13674,file:Open@731,registry:App@10065,whole:Route@10057,whole:Route@22981 95e74886a35fbe7d522c47e94b531df15e8a1bacc5417f5540bbff04d9039b4e |
| internal/host/claimassembly.go ClaimAssembly.Build authority C,G,J4 rev3=PROPOSED-seed; captured claim source, writer and runtime resources; construct fresh private serving owner without copying mutex-bearing live state file:Prepare@3640,file:Prepare@3657,file:Prepare@4369,file:Read@4299 42fc9bdc95130d62b0c5001821a924499dbe8cbf0d7e72d78e2f367978d0c8a6 |
| internal/host/claimassembly.go NewProductionRootClaimStop authority C,J4 rev3=PROPOSED-seed; verified root assembly, actor and managed roots; binds private serving-park runtime and retained session-zero stop evidence; agent serving construction remains refused 7debdfe43ca6f17e2229c8aa8b566ee22112d86a595a66fb0cb0213253854aa8 |
| internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,G rev3=PROPOSED-seed; production serving retirement, enrollment and readiness bindings; early metadata and capacity guards before retry reservation file:Read@4150,file:Read@9908,file:ReadFile@3694,registry:Host@9935,whole:Route@10023,whole:Route@10772 60f3c8f0a32435e0341912069f33238793659b7059a2a5661308eaae8b08c6c7 |
| -internal/host/claimfilesseed.go filesSeedExecutor.host authority C,J4 rev3=PROPOSED-seed; local serving claims, registry, authoritative files evidence and ordinary inputs under one host exclusion; serial candidates, exact rerun settlement, one admitted attempt and private C8 without launch file:Exec@476,file:Exec@661,file:Open@1102,file:Read@2129,file:Read@3805,registry:Backing@974,registry:Replica@815,registry:Seed@956,registry:Tunnel@1032,registry:Unit@995,whole:Route@1383,whole:Route@2453,whole:Route@2735,whole:Route@3480,whole:Route@4588,whole:Route@4683,whole:Route@767 62519bbce3a714081c742820e2a0824faa3b56f282c0a81da34870c29b9116fd |
| +internal/host/claimfilesseed.go filesSeedExecutor.host authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join file:Exec@476,file:Exec@661,file:Open@1102,file:Read@2129,file:Read@3787,registry:Backing@974,registry:Replica@815,registry:Seed@956,registry:Tunnel@1032,registry:Unit@995,whole:Route@1383,whole:Route@2453,whole:Route@2735,whole:Route@3480,whole:Route@4570,whole:Route@4665,whole:Route@767 d49f4bbd7c5abe81d09b1c2b396b4546f3ea97f37c0e8f5e3952fbdb187fedb4 |
| internal/host/claimfilesseed.go filesSeedExecutor.run authority C,J4 rev3=PROPOSED-seed; local serving claims, registry, authoritative files evidence and ordinary inputs under one host exclusion; serial candidates, exact rerun settlement, one admitted attempt and private C8 without launch 017c0b7c89f6bfa337e294624b0edaeafc2d360369b6a3ddbf417045b71fc71f |
| internal/host/claimfilesseed.go filesSeedRetryReport diagnostic C,J4 rev3=PROPOSED-seed; incarnation-keyed missing-files diagnostics and indexed retry results; throttles output only and grants no retry, stop or launch c9f069dbde2eb1bfb7d98f8fcf1d949111d729d3dffa0dacc2a9591edb263193 |
| internal/host/claimfilesseed.go type filesSeedExecutor authority C,J4 rev3=PROPOSED-seed; local serving claims, registry, authoritative files evidence and ordinary inputs under one host exclusion; serial candidates, exact rerun settlement, one admitted attempt and private C8 without launch 47b12d2f11ee4b5490b5ec5f6ffcf50100481fe063d5a53d960f3adce081e11b |
| internal/host/claimfilesseed.go type filesSeedIncompleteError authority C,J4 rev3=PROPOSED-seed; local serving claims, registry, authoritative files evidence and ordinary inputs under one host exclusion; serial candidates, exact rerun settlement, one admitted attempt and private C8 without launch b4ad686b56fceed86a7bab5119a7b3b7eb2fd45530a2603880686ae951cc3cf0 |
| internal/host/claimfilesseed.go type filesSeedPending authority C,J4 rev3=PROPOSED-seed; local serving claims, registry, authoritative files evidence and ordinary inputs under one host exclusion; serial candidates, exact rerun settlement, one admitted attempt and private C8 without launch ba35a4a7eb695b04e1cb2319719179021e05ac7c54b62ce773833228c29318df |
| -internal/host/claimfilesseed_command.go RunSystemFiles authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 2c67584a8d1453449596e0e882bb37e3b8474e3f155b5353b3d8247458dec364 |
| +internal/host/claimfilesseed_command.go RunSystemFiles authority B,C,D,F,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join c73d8c53e1ef7fbdc8a28c239994b0d61b31c293e8be8bc9d962f50d25abfd90 |
| internal/host/claimfilesseed_command.go filesSeedExitCode diagnostic C,J4 rev3=PROPOSED-seed; incarnation-keyed missing-files diagnostics and indexed retry results; throttles output only and grants no retry, stop or launch e5d08e5fed16cf291b169de7dc0cdba359e745042c61bcde1b6f3973fb64016b |
| internal/host/claimfilesseed_command.go parseFilesSeed authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins f5629089a0025fcc866d539c6c3769f40d289cb3c37915e97262856ff1e2c285 |
| -internal/host/claimfilesseed_command.go runFilesSeedOneShot authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins a05aa93c6c58d091c3cec4af88f4d5533b5e6874b2933b9df7bb9de99d326b98 |
| -internal/host/claimfilesseed_command.go runSystemFiles authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 2ed024effca69be211670af2e160365b9cf2e2029986de6f63dc8e29715bf4fa |
| +internal/host/claimfilesseed_command.go runFilesSeedOneShot authority B,C,D,F,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join d0ab732d5575dc26c22dc04548332bb81eb08017d32a438da0a96c4a012ab776 |
| +internal/host/claimfilesseed_command.go runSystemFiles authority B,C,D,F,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join 3e7f6a76df209e8cd0a34b085d52f71d90cc77f84d8d6b158761b5b0efce9fa3 |
| internal/host/claimfilesseed_command.go var errFilesSeedDelegation authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins d992c13d29ae3fd9ee114979aec3ee69302b62e4c8b1f75cdc798bba3fb31add |
| internal/host/claimfilesseed_delegation_linux.go filesSeedEdgePID authority C,J4 rev3=PROPOSED-seed; installed edge MainPID, stable controller identity and kernel cgroup identity, owner and delegated controls; root rejects non-controller and unprepared delegation; non-Linux refuses file:CommandContext@141 a722d32c26a6a991d6f55aaf35b27b53005296f2529e5fbbb620cefef5478a58 |
| internal/host/claimfilesseed_delegation_linux.go filesSeedManager authority C,J4 rev3=PROPOSED-seed; installed edge MainPID, stable controller identity and kernel cgroup identity, owner and delegated controls; root rejects non-controller and unprepared delegation; non-Linux refuses file:ReadFile@176 2bd4e8d2f5fd72b5a8eaa6415f32b9d92bfd6dd2d96df4c6ad3e5c93f2f1bd53 |
| +internal/host/claimfilesseed_delegation_linux.go filesSeedPeerObservation authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery file:ReadFile@609,file:ReadFile@935 1251e5d5ca4cddf1fa63c0fddd65d8d0be8c69bdb02157372ebac96b241b5175 |
| internal/host/claimfilesseed_delegation_linux.go verifyFilesSeedDelegation authority C,J4 rev3=PROPOSED-seed; installed edge MainPID, stable controller identity and kernel cgroup identity, owner and delegated controls; root rejects non-controller and unprepared delegation; non-Linux refuses file:Fstat@1025,file:Open@857,file:ReadFile@1210,file:ReadFile@1543 dbdc836a7b54da126de53dd375d0fa6541fbd0973e0ba705bf16bc88bbb1f874 |
| internal/host/claimfilesseed_delegation_other.go filesSeedEdgePID authority C,J4 rev3=PROPOSED-seed; installed edge MainPID, stable controller identity and kernel cgroup identity, owner and delegated controls; root rejects non-controller and unprepared delegation; non-Linux refuses 074a97695b0ce76343f9d7cb632b9c54cd69568ec38b70507e15b5ec61a2d33b |
| internal/host/claimfilesseed_delegation_other.go filesSeedManager authority C,J4 rev3=PROPOSED-seed; installed edge MainPID, stable controller identity and kernel cgroup identity, owner and delegated controls; root rejects non-controller and unprepared delegation; non-Linux refuses 140c41b41bf95ab631c3aea59c444fce7aa8408cd7e7469655c11f714098c469 |
| internal/host/claimfilesseed_delegation_other.go verifyFilesSeedDelegation authority C,J4 rev3=PROPOSED-seed; installed edge MainPID, stable controller identity and kernel cgroup identity, owner and delegated controls; root rejects non-controller and unprepared delegation; non-Linux refuses b3c849c2a61ed819d5392a03ad88b030653c772f036567109e5f634c336a8ebb |
| internal/host/claimfilesseed_guard.go movesFiles.checkServingFilesEvidence authority C,J4 rev3=PROPOSED-seed; safe nested layout and bounded manifest metadata; evidence absence refuses before retries; only genuine empty authority can omit instance directory whole:Route@265 4c75f947c4061022996dca4af4a54a10ad974e5a7b1fbe69c3f628d5e5decf84 |
| internal/host/claimfilesseed_guard.go servingFilesPrerequisites authority C,J4 rev3=PROPOSED-seed; bounded current-version file evidence and exact claim reread before retry wrappers; typed missing-evidence refusal carries incarnation for rate-limited diagnostics file:Read@761,whole:Route@548 52abed55f7182a0f3166b9dcea1097c49e68f17bd83a13d924bee9d7a1ec5ea7 |
| +internal/host/claimfilesseed_killed_linux.go LinuxStopKernel.recoverUnlaunchedAbsent authority C,D,J4 rev3=PROPOSED-seed; root recovery and lifetime; per-host failures retained and reported, controller recovery first, pressure cleanup independently budgeted file:Lstat@1566,file:Read@1222,file:Read@590,file:Read@740 fbd4fa26890d04e8a5f34caebd10b1ea171374ccf677a20c63ad6d09057042b7 |
| internal/host/claimfilesseed_retry.go filesSeedOperation.prerequisites authority C,J4 rev3=PROPOSED-seed; exact owned host exclusion, serving claim and nested layout; bypass only missing evidence for one eligible private seed; rerun settlement uses ordinary root stop and spends no new attempt registry:Host@256,whole:Route@596,whole:Route@798 44a9d61481d9499b1e1305595a43d94cd364da6abe1f91c337f4deb2c48d2fbc |
| internal/host/claimfilesseed_retry.go settleFilesSeedRerun authority C,J4 rev3=PROPOSED-seed; exact owned host exclusion, serving claim and nested layout; bypass only missing evidence for one eligible private seed; rerun settlement uses ordinary root stop and spends no new attempt registry:Host@241,registry:Host@323,registry:Host@721,whole:Route@485 24337d0b6a864850a4f4203757ed40d634a770abff9dc4c96cd9c746ba8ced2e |
| internal/host/claimfilesseed_runtime.go newFilesSeedRuntime authority C,J4 rev3=PROPOSED-seed; fresh private serving owner and captured ordinary preparation/stop closures; reread same-writer claim after root certification before C8 origin proof; ambiguous cleanup retains exclusion and retry debt whole:Route@1955 3d91da897f65e34f09d636bac9a19b25d641df17d10b37c69a724cf122b9d27d |
| -internal/host/claimfilesseed_runtime.go prepareFilesSeed authority C,J4 rev3=PROPOSED-seed; fresh private serving owner and captured ordinary preparation/stop closures; reread same-writer claim after root certification before C8 origin proof; ambiguous cleanup retains exclusion and retry debt file:Prepare@1717,file:Prepare@400,file:Read@321,file:Read@955,registry:Host@2145,registry:Host@243,whole:Route@1494,whole:Route@1607,whole:Route@1738 3022ec3df58ccb809b6b68b67dd0b2a0b4ad3252168bcf98ad2c9a2e303fab3f |
| +internal/host/claimfilesseed_runtime.go prepareFilesSeed authority C,J4 rev3=PROPOSED-seed; bounded request lifetime, shared cleanup deadline and exclusion; no resource closure before handler join file:Prepare@1699,file:Prepare@400,file:Read@321,file:Read@937,registry:Host@2127,registry:Host@243,whole:Route@1476,whole:Route@1589,whole:Route@1720 47c7e84c14565eae81304c125ddcd30680c5e06aaaa29d0c7c571effde19cbb7 |
| internal/host/claimfilesseed_runtime.go type filesSeedRuntimeFactory authority C,J4 rev3=PROPOSED-seed; fresh private serving owner and captured ordinary preparation/stop closures; reread same-writer claim after root certification before C8 origin proof; ambiguous cleanup retains exclusion and retry debt 950b8bdf6e212ca9a3ad996a5851215fe61302bb045ac6d47570d9b5867ef5a1 |
| internal/host/claimfilesseed_select.go checkFilesSeedLayout authority C,J4 rev3=PROPOSED-seed; fresh claim and route, safe nested layout, complete local retained-admission chain and exact manifest versions; all entries checked for restore cap and real origin; missing source is never empty registry:DBPath@174,registry:DBPath@301,registry:Host@221,registry:Host@292,registry:Host@414,registry:Host@438,registry:Host@593 cb0de1645ceeb6726ca342bec8487a807b0da6f200a277c41be87e3bc62de4cd |
| internal/host/claimfilesseed_select.go filesSeedCandidates authority C,J4 rev3=PROPOSED-seed; claim keys and current executor before registry access, then exact ordinary release/config/baseline inputs; reject missing, foreign or unsupported selection file:Exec@1002,file:Read@313,file:Read@891 bde287a3a8f164607ba1ead5e788807ef1e2352215ebddaabed64243597574e7 |
| @@ -60,16 +72,34 @@ internal/host/claimfilesseed_stage.go filesSeedOperation.stage authority C,J4 re |
| internal/host/claimfilesseed_stage.go newFilesSeedProvider authority C,J4 rev3=PROPOSED-seed; fresh private provider, real admitted tuple and origins; guarded safe source descriptor and wake snapshot; create conflicts compare bounded bytes at exact current version before commit file:Open@364 391a4dd6ae1ee7b12c49d4cf2cbb4007716634af75591565dc4c3f754d4cbf35 |
| internal/host/claimfilesseed_stage.go type filesSeedCheckingStore authority C,J4 rev3=PROPOSED-seed; fresh private provider, real admitted tuple and origins; guarded safe source descriptor and wake snapshot; create conflicts compare bounded bytes at exact current version before commit 41f415ce4f78804c1191a2dfc450e355f44b979f9cdeb210a482c35b42591f4f |
| internal/host/claimfilesseed_stage.go type filesSeedOperation authority C,J4 rev3=PROPOSED-seed; fresh private provider, real admitted tuple and origins; guarded safe source descriptor and wake snapshot; create conflicts compare bounded bytes at exact current version before commit a34058915d17d03fd83847b5ebdaf38104ee763a3629b09e236ad30e50781046 |
| +internal/host/claimfilesseed_transport_linux.go const filesSeedSocket authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery a87b27768dfe8a1e52bd69a384d36c4ffa91039a3c365cfe79a37cc7c22dd1c6 |
| +internal/host/claimfilesseed_transport_linux.go filesSeedClient authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery file:Lstat@118,file:Lstat@715 c795363fa2fbb10cc5110e1c7d280d44033de523dbd18f7d17a3c8e662836594 |
| +internal/host/claimfilesseed_transport_linux.go filesSeedServer.close authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 5c865a9041dd465a19f5cbb46047d5d416921316f07c009f3e0c5b206379558a |
| +internal/host/claimfilesseed_transport_linux.go filesSeedStream.Write authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 5d364cad4269accbc72f0be08820ad464c1ca0595d03b7f44d0afd6bd78cf708 |
| +internal/host/claimfilesseed_transport_linux.go removeStaleFilesSeedSocket authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery file:Lstat@106,file:Lstat@593 5c8b1f5a9b4a1faa6a8f3d86e566c5ba32923e9a165af68d8ef2db699692bd18 |
| +internal/host/claimfilesseed_transport_linux.go serveFilesSeed authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery eea07a5e4aaee1fb9459485651067bcd9a307a6a55f704d7e3e2b86f71132221 |
| +internal/host/claimfilesseed_transport_linux.go startAgentFilesSeed authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 2cd42d699920d5b0075746824fed41c7bd679d029b87b5d5f7a7f24f790986b7 |
| +internal/host/claimfilesseed_transport_linux.go startFilesSeedSocket authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery file:Lstat@428,file:Lstat@527 cc84a3d4a4b2c1c9616957fd128c129426fbf570142e3fe4c70347393748bd0b |
| +internal/host/claimfilesseed_transport_linux.go type filesSeedReply authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery d6eaf67e486cb06bdf42526fe236c5c43da9bad71a97f3f05f19d943e00a415b |
| +internal/host/claimfilesseed_transport_linux.go type filesSeedRequest authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 9be6d7a745e27a89cb6faabb28899578bef1602ba8b8dcf7f39efd97274eb42c |
| +internal/host/claimfilesseed_transport_linux.go type filesSeedServer authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 8a899cbc4d18bf65e2c53ee68d36fd05412c99bfc9d621b5e8ee7dd530124bbd |
| +internal/host/claimfilesseed_transport_linux.go type filesSeedStream authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery d51d90e4e9cd1ae397db0941798f719897c26c4e6ed84b289543eba363fe0a96 |
| +internal/host/claimfilesseed_transport_other.go filesSeedClient authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 6e57a07086521dd818f025dfdbc964695ea10c32a5ad6e9d98498ab0032c0090 |
| +internal/host/claimfilesseed_transport_other.go filesSeedServer.close authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 9340754f2e5e90a99a8b15d5cec4c8d27a070252e55e8664c323ecc658ad02d9 |
| +internal/host/claimfilesseed_transport_other.go startAgentFilesSeed authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 71ef4ddedbcc4d35bb79ce23b53f21ad6801d7cff9814b6ccdefbd5816164594 |
| +internal/host/claimfilesseed_transport_other.go type filesSeedServer authority C,J4 rev3=PROPOSED-seed; Unix peer credentials, pidfd liveness, repeated process/controller/cgroup identity, trusted socket; root lifecycle and bounded recovery 258bd08914bac2d2014a35d4c0a1c9e41f5299cb6ef5fd1532f2d266924c07de |
| internal/host/claimhandover_transport.go ClaimRootHTTP.serveColdHandover authority C,J4 rev3=PROPOSED-seed; authenticated existing handover transport; serving-specific transfer and repeated peer identity check under the host lock; operation handover remains separate 9ea7b9ea4a4962ad92a5f6ce19a24fcffca73e40aae9318a2d8c79ef25351e6a |
| internal/host/claimhandover_transport.go coldAdmissionHook authority C,J4 rev3=PROPOSED-seed; serving predecessor receipt or session-zero identity selector; root supplies the retained no-process result 70974148b3f47e4cb2057e4000c472cc4b372707fb1ff64b5e62582dc92b70c2 |
| internal/host/claimjournal_transport.go ClaimRootHTTP.serveJournal authority C,J4 rev3=PROPOSED-seed; typed journal envelope; routes v4 serving-park selectors before generic journal replay; existing operations retain their dispatch file:Read@3311,whole:Route@4465 51276984eeb10a9480b9ff1a92ed8f16e9a141134cb3913ec75ff18c03570868 |
| +internal/host/claimlaunch_production.go ClaimRootWithLaunch.Start authority C,J4 rev3=PROPOSED-seed; root recovery and lifetime; per-host failures retained and reported, controller recovery first, pressure cleanup independently budgeted 01a2a00a414aca8c5e0c9bc7b143b0c4690b242d2bc0530e75e8fe709f5c9a01 |
| internal/host/claimlaunch_production.go claimLaunchFor authority C,D,J4 rev3=PROPOSED-seed; authenticated capability peer and production root delegation verifier; capability validates only and creates no writer or launch data-literal@414,file:CommandContext@605 b6c4f60ed5783496e89c45c9c612cbd8758b6a05c19a706c5eb5640dfea2505d |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.Serve authority C,J4 rev3=PROPOSED-seed; authenticated capability peer and production root delegation verifier; capability validates only and creates no writer or launch ed7766ea6321a0a91e7be6a131363f05d06d3af55928ee3175f5b91f343f4868 |
| internal/host/claimlaunch_root.go type ClaimLaunchRoot authority C,J4 rev3=PROPOSED-seed; authenticated capability peer and production root delegation verifier; capability validates only and creates no writer or launch f6ba324d4632eb0abed1c97b16e29ed83b165533bfcd82d91e70273d665a7adc |
| internal/host/claimowners.go checkClaimServingSources authority C rev3=PROPOSED-seed; in-process serving launch, marker and files provider availability before serving selection ed3ad706b8142cd514c04e7e6eeb37a61f881a0e160ffbcb78898d0f1d419351 |
| internal/host/claimowners.go claimServingOwners.serving authority C,G,J4 rev3=PROPOSED-seed; claim executor and serving shape plus complete serving sources; missing launch, marker or files source refuses file:Exec@450 6d15b7a206526a4df95b4eb2cc5ac5973a7360947ef3eca7a1df8f3e04719719 |
| internal/host/claimproduction.go ProductionClaimOwnerProvider.OpenClaimOwners authority C,G rev3=PROPOSED-seed; verified roster, cloud identity, floors and configured provider dependencies through existing helpers; no moves-provider Open or Close with moves disabled f1e9aa15fb6142f839bb76dafa574cad7122d6053cf2099d852ad97a528f1127 |
| -internal/host/claimretire_transport.go ClaimRootHTTP.RecoverPending authority C,J4 rev3=PROPOSED-seed; startup serving handover recovery before ordinary cold and root recovery; old destructive serving intents refuse 627b40daecf11e1a6a6c9f92aeb3ebcf226131da6dfa584ed85559c8b9b5ff30 |
| +internal/host/claimretire_transport.go ClaimRootHTTP.RecoverPending authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated cfbfeff484b45ace11a3465cf0628ddcc6c4353112071dd602b321f46bc565bf |
| +internal/host/claimretire_transport.go ClaimRootHTTP.Start authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated adb9230b314bf8bd173513df73b705be64a0ae93db67ea4784d881a061825e7d |
| internal/host/claimservingartifact_linux.go checkServingArtifactAbsence authority C,J4 rev3=PROPOSED-seed; source directory opened without symlinks; descriptor statx mount/device/inode and no-follow lookup of database, marker, WAL and SHM file:Fstatat@680,file:Statx@234 ab45d3f0651d25e8a6d1c48def6cc222c35dc152aca477ebd1555a67c9b89881 |
| internal/host/claimservingartifact_other.go checkServingArtifactAbsence authority C,J4 rev3=PROPOSED-seed; non-Linux refusal; no source inspection or restore permission 27a7173119b5af75be262e9fcc56fddadb84a539f69fe5882f8c6613cfbc88c9 |
| internal/host/claimservingboot_linux.go servingParkBootID authority C,J4 rev3=PROPOSED-seed; bounded kernel boot-ID read from /proc; no caller-supplied path or identity file:Open@54,file:ReadAll@162 af8e5641ef60fcd307dec013df6eba3e50a29d2cb811c5b2cddf10c5c9404e30 |
| @@ -108,11 +138,12 @@ internal/host/claimservinglegacyroot_linux.go sameServingLegacySource authority |
| internal/host/claimservinglegacyroot_linux.go servingParkRoot.parkLegacyServingDatabase authority C,J4 rev3=PROPOSED-seed; fresh serving claim, root installation, consumed legacy identity and exact stop/empty evidence; collected consumption refuses recapture, while consumption saved before the first intent still resumes file:Read@3525 c192eda0fc53bc5211b02123ed7745e8e2b1b2fc53ad5fb3b0d11fe95fd9672c |
| internal/host/claimservinglegacyroot_linux.go servingParkRoot.retainLegacyNoProcess authority C,J4 rev3=PROPOSED-seed; root coordinator result before publication; namespace and host guards, census, roster, fresh original claim and immutable retained stop; repeated preadmission witnesses tolerate only Gen, ETag and retry metadata changes file:Read@1210 5460530ae4367720515089e9fda3105aa1032b8282f4923bbf7f8af9600f4f63 |
| internal/host/claimservinglegacyroot_linux.go servingParkRoot.validateLegacyRootEvidence authority C,J4 rev3=PROPOSED-seed; retained no-process or stopped-admission evidence; exact predecessor fields with canonical recorded directory revision bounded by the current actor aa0dfe36c042ab65fd826e4b83eb81bfb9d03e6a54daafa828d5164492329d0d |
| -internal/host/claimservingmaintenance.go servingParkLedger.maintainParks authority C,J4 rev3=PROPOSED-seed; private root retirement, slot and reboot-binding records under namespace exclusion; remove preparations before retirement and orphan bindings only after proven retirement absence; never delete artifacts through this sweep 9ae45d7c5896a5c673489dd34ed94510aaf7281b8f73c7f5a831346f0f2ee586 |
| -internal/host/claimservingmaintenance.go servingParkRuntime.Start authority C,J4 rev3=PROPOSED-seed; root park records, slot ownership and deadlines; bounded candidate set, oldest complete parks, verified deletion under pressure or expiry; cancelled on shutdown fd8fb4702b9dcdf016343689e40e050006ec90d1af128cde283c388b79da458d |
| +internal/host/claimservingmaintenance.go maintainServingParkPass authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated 5c038c33394b21f73af912c206e3249b262d093c463a090e7311c40cc7227be8 |
| +internal/host/claimservingmaintenance.go servingParkLedger.maintainParks authority C,J4 rev3=PROPOSED-seed; census-ruling-FS-R6; identity-verified deletion of complete retained parks on expiry or pressure, oldest first 9ae45d7c5896a5c673489dd34ed94510aaf7281b8f73c7f5a831346f0f2ee586 |
| +internal/host/claimservingmaintenance.go servingParkRuntime.Start authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated 3b5b9f6192e2ffddbb214d1cfa78a0afbc1087d21abcd48aae1a407b31bd15b2 |
| internal/host/claimservingmaintenance_linux.go claimServingRoom authority C,J4 rev3=PROPOSED-seed; descriptor-opened data filesystem capacity, conservative DiskFloor; root recovery and bounded cleanup; carlos capacity path has no root-only state dependency ce0cb25c53adfd3dba7344a98049f44fc3f0b3e633b22ad37299dea81503e403 |
| internal/host/claimservingmaintenance_linux.go servingParkFS.room authority C,J4 rev3=PROPOSED-seed; descriptor-opened data filesystem capacity, conservative DiskFloor; root recovery and bounded cleanup; carlos capacity path has no root-only state dependency 5725a03b76664476dda6c2cb380339c26eff9967a4c04cf4e57fb9c68727872b |
| -internal/host/claimservingmaintenance_linux.go servingParkRoot.maintainServingParks authority C,J4 rev3=PROPOSED-seed; descriptor-opened data filesystem capacity, conservative DiskFloor; root recovery and bounded cleanup; carlos capacity path has no root-only state dependency 0c611463f1aa7c6a75348e460621305d54da6b619bf34394d430ba525d9608b7 |
| +internal/host/claimservingmaintenance_linux.go servingParkRoot.maintainServingParks authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated 97f6b94285efc9676038f012c9e1cb3b5454e3881d22f3cb8ac636cc0d6df69e |
| internal/host/claimservingmaintenance_linux.go servingRoomFile authority C,J4 rev3=PROPOSED-seed; descriptor-opened data filesystem capacity, conservative DiskFloor; root recovery and bounded cleanup; carlos capacity path has no root-only state dependency file:Fstatfs@78 9a76893f007a92f8a418660cc41fd9551d38b47523911ae1838bf110adb69d9a |
| internal/host/claimservingnamespace_linux.go servingParkFS.historicalNamespaceParent authority C,J4 rev3=PROPOSED-seed; private namespace records, topology and boot bindings; capacity refusal before new namespace capture file:Read@117 bdb9f4c2b649160fcb79eb4772f37242be2ebace16fd4c32009a943ce5b483b9 |
| internal/host/claimservingnamespace_linux.go servingParkFS.namespaceParent authority A,C,J4 rev3=PROPOSED-seed; private namespace records, topology and boot bindings; capacity refusal before new namespace capture bae834ae17f4c3d45bceb9e7c3fd08785454a51825d72b0cf26a0f2c996c1f91 |
| @@ -144,7 +175,7 @@ internal/host/claimservingpark_other.go newServingParkAuthority authority C,J4 r |
| internal/host/claimservingpark_other.go newServingParkFS authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority; production boot identity reader is fixed at construction; persistent file-handle reader fixed at production construction 69baa4c747e687578e73b8369f2df9987fe6fb5836b78796cc41c776ba12c802 |
| internal/host/claimservingpark_other.go servingParkFS.Capture authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority; source boot and persistent directory handle captured before mutation and rechecked after all artifact reads a5560b338356792386a5a1b2e27cb08ca0d5aa024cd03b567a78dfd986ce7871 |
| internal/host/claimservingpark_other.go servingParkFS.CheckParked authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 3a8cb4d8052bbdea8cf7a300944de72c68012e6efa252c293fc1dc06a3a2150e |
| -internal/host/claimservingpark_other.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; recorded event and artifact identities; validates complete child set before journaled unlink, then removes only the recorded empty event with durable intent and progress 8c169e8428b913d2f05b502ce971be49d5febaad95d8ae811aae21c81f3434ce |
| +internal/host/claimservingpark_other.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; census-ruling-FS-R6; non-Linux refusal only; returns ErrUnsupported without deletion 8c169e8428b913d2f05b502ce971be49d5febaad95d8ae811aae21c81f3434ce |
| internal/host/claimservingpark_other.go servingParkFS.Move authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 9d215b168da646166104db5624727038f793d2ae0c40eb71f23883f12b6b4b13 |
| internal/host/claimservingpark_other.go servingParkFS.Prepare authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority; new intents record the private event filesystem handle before the first rename; old records gain no persistent evidence file:Prepare@22 645770372357707f635351a377e943e78f1c160222385e41f42b80d57adedc29 |
| internal/host/claimservingpark_records.go sameServingRootStops authority C,J4 rev3=PROPOSED-seed; byte-identical root stop sets including targets, evidence timestamps and original receipt bytes c9e0803635546eecc3a7d07760cb69fc915182f96b0d1581ed655de3382edd88 |
| @@ -158,7 +189,6 @@ internal/host/claimservingpark_records.go servingParkLedger.parkLocked authority |
| internal/host/claimservingpark_records.go servingParkLedger.parkWithEvidenceLocked authority C,J4 rev3=PROPOSED-seed; root-authenticated source, prior slot and immutable intent; a missing same-incarnation retirement must be newer than the last successor; preserve park ownership and replay refusal after collection; old-format replay preserves original consumption and source evidence without inferred handles file:Prepare@3772,file:Read@1774 c0cdd64d93a729abd8cf2a48606579516824c35181f977631de4a1a395a0b8c7 |
| internal/host/claimservingpark_records.go servingParkLedger.read authority C,J4 rev3=PROPOSED-seed; root-private retirement bytes and separate mount projection; forward reads and inverse progress writes validate both representations f029f0fb8d31bdccaf86a5e336ffc1643eb2487fc7adb87852a9d21ad287e732 |
| internal/host/claimservingpark_records.go servingParkLedger.readHistorical authority C,J4 rev3=PROPOSED-seed; root-private retirement bytes and separate mount projection; forward reads and inverse progress writes validate both representations file:Read@259 7af371e0f68db2dbf035c4dd75c9f8312ea3733d1bbfbdd4139088b2e1805bb3 |
| -internal/host/claimservingpark_records.go servingParkLedger.reapLocked authority C,J4 rev3=PROPOSED-seed; root completion, retention disposition and fixed seven-day deadline; rollback or pending work refuses expiry aec4f50b45d1d955fac9814fe9e0d403f5f3f7f939c71f8f6b8ac15e9173b846 |
| internal/host/claimservingpark_records.go servingParkLedger.replaceLocked authority C,J4 rev3=PROPOSED-seed; completed incumbent record under its record lock; resumes its recorded deletion cause before slot replacement 3e67316e959c9a901c32a2a61fda4e6ea5e92fe51bdb7fdb5f5c53d9d5960aad |
| internal/host/claimservingpark_records.go servingParkLedger.save authority C,J4 rev3=PROPOSED-seed; root-private retirement bytes and separate mount projection; forward reads and inverse progress writes validate both representations 187f2c360d38e10a9f22b20f4c02a021c66b9308cad404e536ef5d0f0a860674 |
| internal/host/claimservingpark_records.go servingParkLedger.slot authority C,J4 rev3=PROPOSED-seed; private box/host slot and pending retirement identities across incarnation changes; last-retirement identity supports discarded/artifact-free completion replay file:Read@179 239b806b2e2bfdd568f5d6b749d299f0bc7b118d675e872429432591a89b7b68 |
| @@ -174,12 +204,12 @@ internal/host/claimservingpark_records.go validateServingParkRecord authority C, |
| internal/host/claimservingpark_recovery.go const servingParkRecoveryLimit authority C,J4 rev3=PROPOSED-seed; fixed root-side processing limit of 128 retirement records per recovery page 6b949581334f55a7bae1b4579e56ac74810fc3042fdf568fbfa8e6b3c5a2ef11 |
| internal/host/claimservingpark_recovery.go servingParkLedger.recoverCompleted authority C,J4 rev3=PROPOSED-seed; private completed retirement, exact filesystem deletion and slot references; resume removal, clear completed pending reference and collect only obsolete tombstones after durable replay deadline a2a277c862ec65e8abaf8b93b7eba77a79e538a76d061fb080f37f6c0c6d48f4 |
| internal/host/claimservingpark_recovery.go servingParkLedger.recoveryIDs authority C,J4 rev3=PROPOSED-seed; private rootstate names; sorted bounded retirement page and continuation cursor; malformed record IDs retained for reporting 5b450cd0fb6ae6694bca2f9f75fe972c0d65681981a4caddde7e5c7269108782 |
| -internal/host/claimservingpark_recovery.go servingParkRuntime.recoverPending authority C,J4 rev3=PROPOSED-seed; service lifetime cancellation and join; bounded startup results rendered only with indexed host/record/fixed-reason text 0aa3ef7edd0ac2c12b4221907ccd5927e7e3e03a4c2c0b77b669bc10cc49c241 |
| +internal/host/claimservingpark_recovery.go servingParkRuntime.recoverPending authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated ac8d81393bd0eea077e0f8b29a402f3c13a0d2627b228be5271a4c6c7dab493b |
| internal/host/claimservingpark_recovery.go type servingParkRecoveryPage authority C,J4 rev3=PROPOSED-seed; private bounded recovery results, cursor and incomplete indicator; never a transport selector c2fb4445ed50b5237b72e8a5fff9b1b2f6c9dd1397473eeb2c78a6b8c2804b27 |
| internal/host/claimservingpark_recovery.go type servingParkRecoveryResult authority C,J4 rev3=PROPOSED-seed; root-derived host and record ID with local recovery outcome 4dd13c07e97470a6c5aa4fa98d326de8947422018f0d30d5c5af32d753cfcc39 |
| internal/host/claimservingpark_recovery.go var errServingParkRetagPending authority C,J4 rev3=PROPOSED-seed; fixed pending-retag refusal; startup cannot write the independent claim writer tags 8fa87ebef4defae4fc1733528a76e0cc260711c2f5a664308e5368d69286d45b |
| internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingPark authority C,J4 rev3=PROPOSED-seed; retained intent, exact current successor, independently rechecked installation, enrollment, receipt and local launch history; resumes original park and completes only existing paired tags; no retry settlement c02a2727bb45c39df280800ade0aaae6dfba18b268e48bea5fa2f7fcb7a2af23 |
| -internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingParks authority C,J4 rev3=PROPOSED-seed; startup validates raw persistent namespace and commits boot binding; incomplete records still require current installation and stop/launch authority 87c40006c0aeb45e507a81c715f658f83100c07002d8fc5c5d53a1d87e70c196 |
| +internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingParks authority C,J4 rev3=PROPOSED-seed; census-ruling-FS-R6; root startup and minute worker recover immutable incomplete parks and reboot bindings; per-record refusal retained 87c40006c0aeb45e507a81c715f658f83100c07002d8fc5c5d53a1d87e70c196 |
| internal/host/claimservingpark_recovery_linux.go servingParkRoot.validateServingRecoveryAuthority authority C,J4 rev3=PROPOSED-seed; fresh root history, original and continuation stop receipts, permanent legacy consumption, launch disposition and unlaunched active successor; extracted unchanged checks shared with same-boot startup 60ff31e49f8bafdf5a9c57b87ec8901e9ad3bafa6cc9e15d541a5659a83cf846 |
| internal/host/claimservingpark_transport.go ClaimRootHTTP.serveServingPark authority C,J4 rev3=PROPOSED-seed; authenticated edge connection and census actor; only identity selectors reach root authority; completed replay rechecks census and enrollment; pressure unavailable 6d5783a57cc3a7ee3c8303b9b205e5f5ccf0fd606f1e73e3bf4e69092f5085f4 |
| internal/host/claimservingpark_transport.go newServingParkRuntime authority C,J4 rev3=PROPOSED-seed; private state and platform-specific root authority; constructs bounded service lifetime without starting workers c432ba5861dbb9b10bf102216cdeb5eb9b2cea21b547779a981a91a071d1dfc8 |
| @@ -250,12 +280,13 @@ internal/host/claimservingtopology_linux.go type servingParkRebootObservation au |
| internal/host/claimservingtopology_linux.go type servingParkTopology authority C,J4 rev3=PROPOSED-seed; persistent parent/root evidence attached only when root creates and records a new namespace 16a7fce2850bf204ddd67aa10876979be08a5d500a21ea3072e2fc5c1c369586 |
| internal/host/claimstop.go ClaimStopCoordinator.witnessNoProcess authority C,G,J4 rev3=PROPOSED-seed; existing peer, claim, root inventory and positive kernel no-process checks; local adopted holder requires new retained-evidence binding; original observation is retained before receipt publication file:Exec@1845,file:Read@2541,file:Read@668 4a0df51297d2f6ed2e9c284ee11c26b73747bf84e1da5214d00f190eece5eb38 |
| internal/host/claimstop.go type ClaimStopCoordinator authority C,G,J4 rev3=PROPOSED-seed; root-only coordinator dependency for retaining its own session-zero observation; nil preserves existing refusal for adopted legacy holders b7114aa7340cdba82fef42d62b784eae16da7acc48407ae6edba6453a4889f9a |
| -internal/host/claimstop_run.go RunInstanceControl authority C,D,G rev3=PROPOSED-seed; instance-control lifecycle; start cancellable root park maintenance and join it before closing private state 6adf5d9e0bbc87b7e010fb445bf624290fe63b620fb8b42941724e8ad7407bd0 |
| -internal/host/claimwakeassembly.go ClaimWakeAssembly.Build authority C,J4 rev3=PROPOSED-seed; fresh private serving owner and captured ordinary preparation/stop closures; reread same-writer claim after root certification before C8 origin proof; ambiguous cleanup retains exclusion and retry debt file:Command@10308,file:Prepare@7029,file:Read@1348,file:Read@1442,registry:Backing@2672,registry:Host@12922,registry:Host@3354,registry:Host@3957,registry:Host@4002,registry:Host@4709,registry:Host@5430,registry:Host@6477,registry:Host@7185,registry:Host@7858,registry:Host@9166,registry:Seed@2655,registry:Tunnel@2727,registry:Unit@2692,whole:Route@10446,whole:Route@14642,whole:Route@14714,whole:Route@2899,whole:Route@5830 2613ac52b918f0b9a08551ceb05a8f0151ced0d22b10a449a96fae76af863d62 |
| +internal/host/claimstop_run.go RunInstanceControl authority C,D,G rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated 393fdc63baeed67092c572fb003e9ed224d1ce37c556060033dffdb4bfcedcce |
| +internal/host/claimwakeassembly.go ClaimWakeAssembly.Build authority C,G,J1,J4 rev3=PROPOSED-seed; fresh private serving owner and captured ordinary preparation/stop closures; reread same-writer claim after root certification before C8 origin proof; ambiguous cleanup retains exclusion and retry debt file:Command@10308,file:Prepare@7029,file:Read@1348,file:Read@1442,registry:Backing@2672,registry:Host@12922,registry:Host@3354,registry:Host@3957,registry:Host@4002,registry:Host@4709,registry:Host@5430,registry:Host@6477,registry:Host@7185,registry:Host@7858,registry:Host@9166,registry:Seed@2655,registry:Tunnel@2727,registry:Unit@2692,whole:Route@10446,whole:Route@14642,whole:Route@14714,whole:Route@2899,whole:Route@5830 2613ac52b918f0b9a08551ceb05a8f0151ced0d22b10a449a96fae76af863d62 |
| internal/host/claimwakeassembly.go type ClaimWakeAssembly authority C,J4 rev3=PROPOSED-seed; fresh private serving owner and captured ordinary preparation/stop closures; reread same-writer claim after root certification before C8 origin proof; ambiguous cleanup retains exclusion and retry debt c13a15027ae22cf7a030b9cdedf13de4ae0f093f64b3e4a35c0614446964c94c |
| internal/host/claimwakestop.go ClaimAssembly.collectCapturedStop authority C,G,J4 rev3=PROPOSED-seed; fresh claim and verified local stop identity; session-zero legacy holder may name only the local writer box, with no admissions; root independently proves and retains no-process evidence file:Exec@2994,file:Read@605 d4cd80663dfae5f9737a74e319d3423f4ef853e1843666b02b4cd2341e869d72 |
| internal/host/movesfiles.go movesFiles.Establish authority C,H3 rev3=PROPOSED-seed; ordinary origin proof and single C8 commit; private seed checks exact admitted writer and stages shared snapshot; no armed live provider 172fe8888da88c249b7990bd9fb4bd2821bd402fb96ab52af13c58500f31d50f |
| -internal/host/relocationflush.go ReleaseFlushFiles.Flush authority C,J4 rev3=PROPOSED-seed; existing release-flush authority and safe source descriptor; shared wake/files staging preserves single manifest commit and unstable-file refusal file:Open@772 7cc12267c16d9d1ae1116c9d10528f97d3983c77fb6486e0aac73142d8999c4b |
| +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 |
| +internal/host/rootexecution_recovery.go recoverRootExecution authority D rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated 44d102ddc175fb2df17832a64b34063cc17e6abf4fe8779dd3843344336dd5c0 |
| internal/host/rootexecution_runtime.go claimExecutionRuntime.Close authority C,J4 rev3=PROPOSED-seed; existing execution monitor lifetime plus serving-park lifetime; both share caller deadline before authority stores close 08352d84ef3a8cd55924338abc3d33229f2216a608c0daef707aec8c472e2a8e |
| internal/hostbox/coldhandover.go ColdHandover.Run authority C,J4 rev3=PROPOSED-seed; production operation-only gate refuses destructive serving handover, including old pending journals; existing move effects unchanged file:Exec@1027,file:Lstat@9838,file:OpenRoot@9760,file:Read@1379,registry:Addr@3654,registry:Addrs@3672,registry:Backing@3691,registry:Canary@3748,registry:ClaimIncarnation@3532,registry:ClaimIncarnation@4034,registry:ClaimOwner@10588,registry:ClaimOwner@4116,registry:ClaimOwner@4136,registry:ClaimOwner@6011,registry:ClaimOwner@7439,registry:ClaimOwnership@10180,registry:ClaimOwnership@3502,registry:ClaimOwnership@4480,registry:ClaimOwnership@4698,registry:ClaimOwnership@4731,registry:ClaimRowID@10478,registry:ClaimRowID@10559,registry:ClaimRowID@4154,registry:ClaimRowID@4762,registry:ClaimRowID@7464,registry:ClaimStamp@3577,registry:ClaimStamp@4081,registry:Host@7401,registry:Kind@3610,registry:Name@4816,registry:Name@7420,registry:Platform@3732,registry:Registration@4180,registry:Registration@7489,registry:Replica@3762,registry:Route@10180,registry:Route@4480,registry:Route@4731,registry:Route@5262,registry:Route@6011,registry:Timers@4679,registry:Timers@4698,registry:Timers@4762,registry:Timers@4816,registry:Timers@7276,registry:Tunnel@3712,registry:Unit@3636,whole:ClaimOwnership@10194,whole:ClaimOwnership@3506,whole:ClaimOwnership@4492,whole:ClaimOwnership@4714,whole:ClaimOwnership@4743,whole:ColdRows@10182,whole:ColdRows@11294,whole:ColdRows@11304,whole:ColdRows@4977,whole:ColdRows@4985,whole:ColdRows@4993,whole:ColdRows@5000,whole:ColdRows@5264,whole:ColdRows@6013,whole:ColdRows@7278,whole:Route@10188,whole:Route@3487,whole:Route@3847,whole:Route@4279,whole:Route@4287,whole:Route@4338,whole:Route@4346,whole:Route@4486,whole:Route@4737,whole:Route@5271,whole:Route@6020,whole:Timer@4018,whole:Timer@4292,whole:Timer@4300,whole:Timer@4351,whole:Timer@4389,whole:Timer@4685,whole:Timer@4704,whole:Timer@4768,whole:Timer@4822,whole:Timer@7285 050ae1e6818687e19e7a0cd443c09e837e54bdc00f4ea1feb6430cfedd2b5343 |
| internal/hostbox/coldhandover.go type ColdHandover authority C,J4 rev3=PROPOSED-seed; operation-only production cold-handover policy; no source-preserving serving authority from operation cleanup 874b2b74ea349dd93655d4e11f4d9e97752c3f216593ebb922d819caba7981c4 |
| @@ -267,7 +298,7 @@ internal/hostbox/journalservice.go validateJournalRequest authority C,J4 rev3=PR |
| internal/hostbox/restore.go ColdRestore.Install authority C,J2,J4 rev3=PROPOSED-seed; selected database versions, local generation marker and restored SQLite data; legacy callback outside Work and enrollment callback before any local write file:CreateTemp@2200,file:Open@3189 7054cf1c887f4b6b4e20b62fd94e17949770c675cbe7e8efe2accf7f20b1c7ba |
| internal/hostbox/restore.go ColdRestore.InstallEmpty authority C rev3=PROPOSED-seed; existing empty-bootstrap proof and local marker; legacy callback outside Work and enrollment callback before purge or marker creation f8c0ea4eaf3fa692abe52dd3b0799c9239bcb0d761c69644cc4be78d51d74c04 |
| internal/hostbox/restore.go loadPinned authority C,J2,J4 rev3=PROPOSED-seed; exact selected LTX versions and original trailer/pin proof; validated bytes normalized for Litestream while retaining original database checksum for final comparison file:ReadAll@662 1e2b22c7c501d31cd1f2cb3be0fe419fa7e25afde87c6ecb43ca85edb7dbc3a4 |
| -internal/hostbox/servinghandover.go ServingHandover.Recover authority C,J4 rev3=PROPOSED-seed; trusted serving transfer journals and pending root barriers; replay exact original transfer before prelaunch 7ec3bd1b929ca3b3fd7d20c53437740d54086fbb2ae1fc8c545dbca3e388bab1 |
| +internal/hostbox/servinghandover.go ServingHandover.Recover authority C,J4 rev3=PROPOSED-seed; root lifecycle and retained recovery; independent crashed-controller recovery, per-journal errors retained, no agent evidence elevated 6b90bd31f43571b0203059a49909b80fd35d7fbd42d1d8fe63685c928c7de3e9 |
| internal/hostbox/servinghandover.go ServingHandover.Run authority C,J4 rev3=PROPOSED-seed; root installation/inventory snapshot, exact stopped predecessor, canonical nested row, no-follow state identity, successor launch absence and private journal; fenced inventory/row transfer with retained old evidence registry:Account@2154,registry:Addr@2373,registry:Addrs@2391,registry:App@2198,registry:Backing@2410,registry:Canary@2467,registry:ClaimOwner@5480,registry:ClaimOwnership@2841,registry:ClaimOwnership@3057,registry:ClaimOwnership@3090,registry:ClaimRowID@3120,registry:ClaimRowID@5551,registry:ClaimRowID@5725,registry:DBPath@2286,registry:Kind@2128,registry:Name@3174,registry:Name@5690,registry:Platform@2451,registry:Replica@2481,registry:Route@2841,registry:Route@3090,registry:Route@5480,registry:Route@5551,registry:Socket@2234,registry:Timers@3039,registry:Timers@3057,registry:Timers@3120,registry:Timers@3174,registry:Timers@5633,registry:Tunnel@2431,registry:Unit@2355,whole:ClaimOwnership@2853,whole:ClaimOwnership@3073,whole:ClaimOwnership@3102,whole:ColdRows@3514,whole:ColdRows@3522,whole:ColdRows@3530,whole:ColdRows@3537,whole:ColdRows@5482,whole:ColdRows@5553,whole:ColdRows@5635,whole:ColdRows@6216,whole:ColdRows@6226,whole:Route@2114,whole:Route@2643,whole:Route@2651,whole:Route@2701,whole:Route@2709,whole:Route@2847,whole:Route@3096,whole:Route@5488,whole:Route@5559,whole:Timer@2656,whole:Timer@2664,whole:Timer@2714,whole:Timer@2752,whole:Timer@3045,whole:Timer@3063,whole:Timer@3126,whole:Timer@3180,whole:Timer@5641 01b9e603a5f2a2476017777a7d02fafc76424d491201d6757170d66997fa481a |
| internal/hostbox/servinghandover.go ServingHandover.at authority C,J4 rev3=PROPOSED-seed; in-process interruption seam; nil in production 0178e5fcfb2cc194fb4f33f0f230b52849ddeb87a4b7c4e6cfb640e8b2f00b8a |
| internal/hostbox/servinghandover.go ServingHandover.validate authority C,J4 rev3=PROPOSED-seed; fresh serving claim, unchanged root-recorded inventory, exact recorded state-directory inode/type, complete route/timer set and production authority callback file:Exec@258,registry:ClaimIncarnation@2164,registry:ClaimOwner@1379,registry:ClaimOwner@2238,registry:ClaimOwnership@2132,registry:ClaimRowID@2273,registry:ClaimStamp@2207,registry:Name@2118,registry:Registration@2310,registry:Route@1033,registry:Route@1379,registry:Timers@2064,registry:Timers@2507,whole:ClaimOwnership@2138,whole:ColdRows@1035,whole:ColdRows@1381,whole:ColdRows@2066,whole:ColdRows@2509,whole:Route@1042,whole:Route@1388,whole:Timer@2073,whole:Timer@2516 2985e74f1c0aed6a6470e4ee4f03e794e32a97806542a51045a21e76ba7bce03 |
| @@ -293,8 +324,8 @@ internal/hostguard/writerstop.go Guard.CompletedServingStops authority C rev3=PR |
| system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins d1150dfb2baa129605aa219052e89ffc790069d56f4a4227c63ca9f40855a81b |
| system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins ee25513ed72f9b8338d95682c4c76b4ced26ec1f060528fc5804da9afbfbfd83 |
| @coverage . bf3d70b6a2c5d769a3c580c9d5511231499b788d9c08a542895de8381e5a39c9 |
| -@coverage internal/activator 232f9386027ac6cab0932da39ea44733ec20f1692010e26f5e4df6214d314440 |
| -@coverage internal/host 9547dc56ff5626b8dc4d3d28cefb65e5b65af46ec0083c2e7a4800147fd5cbef |
| -@coverage internal/hostbox 3865bfeb63bcb671b919d1cfcf97b70405a0378ff6be4444648d12a678e67411 |
| +@coverage internal/activator 41f0ad01ac54bae63f089ef8ec5bbfa9155bb76720d72b2d88563481de40fe5c |
| +@coverage internal/host 455146512e142affa2ba23b8cf3c49ad2028e78901c23e5ceaac96a1edddbad4 |
| +@coverage internal/hostbox db890260533bdace6ad9a7c23dc1538cb919962894f005fc7c5a59a18e969429 |
| @coverage internal/hostfiles a38dbcd9789ba4acd237e88c834f2b2bfca9847b0d43c32b0116dc8996d7a142 |
| @coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6 |
diff --git a/system_test.go b/system_test.go| index e9569f172..5ab7d71d4 100644 |
| --- a/system_test.go |
| +++ b/system_test.go |
| @@ -2,6 +2,8 @@ package main |
| import ( |
| "bytes" |
| + "errors" |
| + "os/exec" |
| "strings" |
| "testing" |
| "time" |
| @@ -274,3 +276,17 @@ func TestPrintSystemLayoutStatus(t *testing.T) { |
| t.Errorf("fleet: %q", b.String()) |
| } |
| } |
| + |
| +func TestSystemFilesSeedCommandEntryRefusesBeforeEffects(t *testing.T) { |
| + bin := buildCarlos(t) |
| + for _, flags := range []string{"", "1"} { |
| + cmd := exec.Command(bin, "system", "files", "seed", "--host", "seed.example") |
| + cmd.Dir = t.TempDir() |
| + cmd.Env = carlosTestEnv(t, "CARLOS_HOST_CLAIM="+flags, "CARLOS_HOST_CLAIM_ORIGINS="+flags, "CARLOS_MOVES_PHASE1=", "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR="+t.TempDir(), "AWS_EC2_METADATA_DISABLED=true") |
| + out, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 1 || !strings.Contains(string(out), "Files seed failed:") { |
| + t.Fatalf("dispatch: %v %s", err, out) |
| + } |
| + } |
| +} |