WIP files seed (#250 C8) checkpoint 1 (not for merge): claimed serving built independently of moves, restore hooks, receipt groundwork
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+646
−45
internal/host/claimconstruction.go+16 −10internal/host/claimfilesseed_assembly_linux_test.go+199 −0internal/host/claimfilesseed_enrollment_test.go+40 −0internal/host/claimowners.go+11 −1internal/host/claimproduction.go+6 −13internal/host/claimwakeassembly.go+39 −15internal/host/plan9_i2_movesoff_test.go+4 −6internal/hostbox/restore.go+26 −0internal/hostbox/restore_enrollment_test.go+109 −0internal/hostguard/servingstop_test.go+103 −0internal/hostguard/writerstop.go+33 −0internal/provision/plan14_legacy_identity_test.go+39 −0internal/provision/plan14_root_reads_test.go+6 −0internal/provision/testdata/plan14-root-reads-seed.tsv+15 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 331.423s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.134s
ok github.com/carlosframework/platform/cmd/moveprobe 92.273s
ok github.com/carlosframework/platform/cmd/zddprobe 0.733s
ok github.com/carlosframework/platform/internal/activator 235.896s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.020s
ok github.com/carlosframework/platform/internal/appconfig 2.335s
ok github.com/carlosframework/platform/internal/apperr 0.082s
ok github.com/carlosframework/platform/internal/applog 0.144s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.999s
? 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.009s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.698s
ok github.com/carlosframework/platform/internal/changelog 0.297s
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 361.803s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.244s
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.114s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.253s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.121s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.013s
ok github.com/carlosframework/platform/internal/geoip 0.031s
ok github.com/carlosframework/platform/internal/host 128.862s
ok github.com/carlosframework/platform/internal/hostactors 0.245s
ok github.com/carlosframework/platform/internal/hostbox 2.129s
ok github.com/carlosframework/platform/internal/hostclaim 0.531s
ok github.com/carlosframework/platform/internal/hostevidence 0.010s
ok github.com/carlosframework/platform/internal/hostexec 4.864s
ok github.com/carlosframework/platform/internal/hostfence 0.034s
ok github.com/carlosframework/platform/internal/hostfiles 10.605s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.013s
ok github.com/carlosframework/platform/internal/hostops 1.395s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.010s
ok github.com/carlosframework/platform/internal/hostquarantine 0.009s
ok github.com/carlosframework/platform/internal/hostrelocate 0.446s
ok github.com/carlosframework/platform/internal/hostresolve 0.140s
ok github.com/carlosframework/platform/internal/hostwrite 0.009s
ok github.com/carlosframework/platform/internal/infraview 0.009s
ok github.com/carlosframework/platform/internal/instancectl 1.820s
ok github.com/carlosframework/platform/internal/instanceexec 2.709s
ok github.com/carlosframework/platform/internal/instancefs 0.009s
ok github.com/carlosframework/platform/internal/instancekey 0.011s
ok github.com/carlosframework/platform/internal/instanceupdates 0.016s
ok github.com/carlosframework/platform/internal/layout 1.434s
ok github.com/carlosframework/platform/internal/lease 9.446s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.014s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.171s
ok github.com/carlosframework/platform/internal/maintenance 0.093s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.504s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.071s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.060s
ok github.com/carlosframework/platform/internal/moves/archive 0.763s
ok github.com/carlosframework/platform/internal/objstore 0.164s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.215s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.205s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.035s
ok github.com/carlosframework/platform/internal/relay 7.235s
ok github.com/carlosframework/platform/internal/release 19.730s
ok github.com/carlosframework/platform/internal/rollout 3.764s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.306s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.473s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.239s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.194s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.570s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/14 05:57:23 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE4FCCF4A15DDF, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go| index b347bacfa..3989ada12 100644 |
| --- a/internal/host/claimconstruction.go |
| +++ b/internal/host/claimconstruction.go |
| @@ -58,7 +58,8 @@ type ClaimOwnerBindings struct { |
| Local hostbox.LocalAuthority |
| Controllers StopControllerReader |
| Data lease.VersionedStore |
| - // Legacy owners are used unchanged unless both feature flags enable Group D. |
| + // Claims-off callers keep the legacy owners. Claimed serving has its own |
| + // bindings; the moves flag extends that dispatch with operation owners. |
| Inputs ClaimWakeInputs |
| AdmissionEvidence func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error |
| PublicationEvidence func(context.Context, hostclaim.Observed) error |
| @@ -162,9 +163,10 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| if owners.Local == nil || owners.Controllers == nil || owners.Data == nil || owners.InputPolicy == nil || owners.MovesPolicy == nil || owners.Evidence == nil || owners.LeaseSet == nil || owners.View == nil || owners.Authorize == nil { |
| return nil, hostwrite.ErrUnavailable |
| } |
| - if movesC1 { |
| - if owners.Serving.Launch == nil || owners.Serving.Markers == nil { |
| - return nil, hostwrite.ErrUnavailable |
| + servingEnabled := hostwrite.Enabled(claimEntryEnv(ctx)) |
| + if servingEnabled { |
| + if err := checkClaimServingSources(owners.Serving); err != nil { |
| + owners.wakeUnavailable = errors.Join(owners.wakeUnavailable, err) |
| } |
| } else if owners.Inputs == nil || owners.AdmissionEvidence == nil || owners.PublicationEvidence == nil { |
| return nil, hostwrite.ErrUnavailable |
| @@ -280,17 +282,21 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| var serving *claimServingOwners |
| var publisher ClaimFencedPublication |
| var groupD claimGroupD |
| - if movesC1 { |
| + if servingEnabled { |
| serving = &claimServingOwners{HomeRegion: actor.HomeRegion, Data: hostbox.RequestStore{Store: owners.Data, Check: func(ctx context.Context) error { _, err := owners.Identity.Verify(ctx); return err }}, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, NewID: newID, Now: time.Now, Sources: owners.Serving} |
| - groupD, err = claimGroupDWiring(claimEntryEnv(ctx), serving.owners(), owners.Moves, owners.Relocation) |
| - if err != nil { |
| - return nil, err |
| + dispatch := ClaimOwnerDispatch{Serving: serving.owners()} |
| + if movesC1 { |
| + groupD, err = claimGroupDWiring(claimEntryEnv(ctx), serving.owners(), owners.Moves, owners.Relocation) |
| + if err != nil { |
| + return nil, err |
| + } |
| + dispatch = groupD.Dispatch |
| } |
| - dispatch := groupD.Dispatch |
| inputs, admissionEvidence, publicationEvidence = dispatch, dispatch.AdmissionEvidence, dispatch.PublicationEvidence |
| publisher = dispatch |
| } |
| wake := &ClaimWakeAssembly{Manager: r.Manager, Inputs: inputs, LeaseRegion: actor.Cloud.Region, Publication: publisher} |
| + wake.RequireServingEnrollment = servingEnabled |
| wake.Producers = func(ctx context.Context, o hostclaim.Observed) (*ClaimProducers, error) { |
| if o.Claim == nil || o.Claim.Holder == nil || controller == "" { |
| return nil, hostwrite.ErrUnavailable |
| @@ -353,7 +359,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| digest := sha256.Sum256(raw) |
| return hex.EncodeToString(digest[:]), nil |
| } |
| - if movesC1 { |
| + if servingEnabled { |
| wake.Retries.Inputs = serving.launchDigest |
| } |
| // ClaimAssembly.Build binds Retries.StopAttempt to the attempt's persisted |
diff --git a/internal/host/claimfilesseed_assembly_linux_test.go b/internal/host/claimfilesseed_assembly_linux_test.go| new file mode 100644 |
| index 000000000..6d3adc7f5 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_assembly_linux_test.go |
| @@ -0,0 +1,199 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestClaimServingAssemblyWithoutMoves(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| + t.Setenv("CARLOS_MOVES_PHASE1", "") |
| + for _, ha := range []string{"", "1"} { |
| + t.Run("region-ha="+ha, func(t *testing.T) { |
| + t.Setenv("CARLOS_REGION_HA", ha) |
| + store, resources, owners, _ := fix4JournalFixture(t) |
| + ctx, err := claimEntryContext(t.Context(), os.Getenv, func(context.Context) (release.Store, error) { return store, nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + actor, err := owners.Identity.Verify(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + data := leasetest.NewVersionedBucket() |
| + data.AddBucket(actor.Bucket) |
| + rt := registry.Route{Host: filesHost, Kind: "instance", Hibernate: true, DBPath: provision.NestedDBPath(resources.DataDir, filesHost), Socket: provision.NestedSocket(filesHost)} |
| + dir := filepath.Dir(rt.DBPath) |
| + if err := os.MkdirAll(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, filesHost+".env"), []byte("K=1\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + source := &movesFilesSource{DataDir: resources.DataDir, Routes: testClaimRoutes{rows: map[string]registry.Route{filesHost: rt}}, newFiles: func(context.Context, string, string) (release.Store, error) { |
| + return casBucketStore{b: data, bucket: actor.Bucket}, nil |
| + }} |
| + files, err := source.Open(ctx, store, data, actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + d5 := plan9D5Fixture(t) |
| + owners.Data = data |
| + owners.Serving = d5.owners.Sources |
| + owners.Serving.Files = files |
| + owners.Serving.Markers = plan9D5Markers{data} |
| + assembly, err := OpenProductionClaimAssembly(ctx, store, resources, owners) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer assembly.Close(ctx) |
| + if assembly.wakeUnavailable != nil { |
| + t.Fatal(assembly.wakeUnavailable) |
| + } |
| + dispatch, ok := assembly.Wake.Inputs.(ClaimOwnerDispatch) |
| + if !ok || dispatch.Moves != nil || dispatch.Relocation != nil || assembly.Wake.Operations != nil || assembly.Wake.ProbeReady != nil { |
| + t.Fatalf("serving-only assembly: %#v", assembly.Wake) |
| + } |
| + if digest, err := assembly.Wake.Retries.Inputs(ctx, rt); err != nil || digest == "" { |
| + t.Fatalf("digest %q: %v", digest, err) |
| + } |
| + o, err := hostclaim.Read(ctx, store, filesHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + filesHost + ".json", Account: "bab", App: "jam", Host: filesHost, Region: actor.Region, CreatedAt: filesCreated} |
| + o, err = hostclaim.Update(ctx, store, o, hostclaim.BeginCreate(n, []byte(`{"host":"shop.example.com","account":"bab","app":"jam","created_at":"2026-10-01T00:00:00Z"}`), "create", "console", time.Now())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Update(ctx, store, o, hostclaim.CompleteCreate("create", actor.Box)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in, err := dispatch.Select(ctx, rt, o, recoveryPolicy) |
| + if err != nil || !in.Empty { |
| + t.Fatalf("empty selection: %+v %v", in, err) |
| + } |
| + writer := hostbox.Writer{Claims: store, Box: actor.Box, Region: actor.Region, AdmissionEvidence: assembly.AdmissionEvidence} |
| + admitted, err := writer.Admit(ctx, o, in.Admission, in.Baseline) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + work := &filesFixture{t: t} |
| + restore := hostbox.ColdRestore{Generation: in.Admission.NewGeneration, Work: work, Identity: in.Identity} |
| + if err := restore.InstallEmpty(ctx, rt.DBPath, func(ctx context.Context) error { return dispatch.EmptyAuthority(ctx, admitted, in) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := dispatch.Materialize(ctx, admitted, in, work); err != nil { |
| + t.Fatal(err) |
| + } |
| + key := movesFilesPrefix(admitted.Claim.Holder.Location, in.Admission.NewGeneration) + "manifest.json" |
| + if data.LatestID(actor.Bucket, key) == "" { |
| + t.Fatal("production C8 did not commit") |
| + } |
| + for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} { |
| + if err := plan9D5Call(dispatch, plan9D5Kind(kind)); err == nil { |
| + t.Fatalf("moves-off accepted %s", kind) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestClaimServingMissingSourcesRefusesBeforeSelection(t *testing.T) { |
| + for _, field := range []string{"launch", "markers", "files"} { |
| + t.Run(field, func(t *testing.T) { |
| + f := plan9D5Fixture(t) |
| + switch field { |
| + case "launch": |
| + f.owners.Sources.Launch = nil |
| + case "markers": |
| + f.owners.Sources.Markers = nil |
| + case "files": |
| + f.owners.Sources.Files = nil |
| + } |
| + var dependency *ClaimDependencyError |
| + if err := checkClaimServingSources(f.owners.Sources); !errors.As(err, &dependency) { |
| + t.Fatalf("missing %s: %v", field, err) |
| + } |
| + _, err := f.owners.Select(t.Context(), registry.Route{}, plan9D5Observed(hostclaim.Holder{}), recoveryPolicy) |
| + if !errors.As(err, &dependency) { |
| + t.Fatalf("selection with missing %s: %v", field, err) |
| + } |
| + if len(f.files) != 0 || f.seq != 0 { |
| + t.Fatal("missing dependency had effects") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestClaimServingAssemblyFlagMatrix(t *testing.T) { |
| + for _, claims := range []string{"", "1"} { |
| + for _, moves := range []string{"", "1"} { |
| + for _, partial := range []bool{false, true} { |
| + name := "claims=" + claims + "/moves=" + moves |
| + if partial { |
| + name += "/partial" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", claims) |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| + t.Setenv("CARLOS_MOVES_PHASE1", moves) |
| + t.Setenv("CARLOS_REGION_HA", "1") |
| + store, resources, owners, _ := fix4JournalFixture(t) |
| + ctx, err := claimEntryContext(t.Context(), os.Getenv, func(context.Context) (release.Store, error) { return store, nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f := plan9D5Fixture(t) |
| + owners.Serving = f.owners.Sources |
| + var calls []string |
| + owners.Moves = ClaimMovesOwners{ClaimKindOwners: plan9D5Owner{"moves", &calls}.owners(), Operations: &plan9D2Attempts{}} |
| + if partial { |
| + owners.Moves.PublicationEvidence = nil |
| + } |
| + assembly, err := OpenProductionClaimAssembly(ctx, store, resources, owners) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer assembly.Close(ctx) |
| + dispatch, bound := assembly.Wake.Inputs.(ClaimOwnerDispatch) |
| + if bound != (claims != "") { |
| + t.Fatalf("serving binding %v, claims %q", bound, claims) |
| + } |
| + wantMoves := claims != "" && moves != "" && !partial |
| + if bound && (dispatch.Moves != nil) != wantMoves { |
| + t.Fatalf("moves binding %v, want %v", dispatch.Moves != nil, wantMoves) |
| + } |
| + if (assembly.Wake.Operations != nil) != wantMoves { |
| + t.Fatal("operation gate changed") |
| + } |
| + wantMissing := claims != "" && moves != "" && partial |
| + if (assembly.wakeUnavailable != nil) != wantMissing { |
| + t.Fatalf("partial owner refusal %v, want %v", assembly.wakeUnavailable, wantMissing) |
| + } |
| + if bound && dispatch.Relocation != nil { |
| + t.Fatal("region HA bypassed absent relocation owners") |
| + } |
| + if len(calls) != 0 || len(f.files) != 0 || f.seq != 0 { |
| + t.Fatal("assembly ran a host operation") |
| + } |
| + }) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/host/claimfilesseed_enrollment_test.go b/internal/host/claimfilesseed_enrollment_test.go| new file mode 100644 |
| index 000000000..7564ac136 |
| --- /dev/null |
| +++ b/internal/host/claimfilesseed_enrollment_test.go |
| @@ -0,0 +1,40 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +func TestClaimServingAssemblyRequiresRetirementBeforeRuntime(t *testing.T) { |
| + for _, missing := range []string{"legacy", "enrollment", "recovery"} { |
| + t.Run(missing, func(t *testing.T) { |
| + called := false |
| + w := &ClaimWakeAssembly{RequireServingEnrollment: true, |
| + BeforeLegacyInstall: func(context.Context, hostclaim.Observed, ClaimLaunchInputs, hostwrite.Work) error { |
| + called = true |
| + return nil |
| + }, |
| + BeforeInstallLocked: func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error { called = true; return nil }, |
| + RecoverArtifacts: func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error { called = true; return nil }, |
| + } |
| + switch missing { |
| + case "legacy": |
| + w.BeforeLegacyInstall = nil |
| + case "enrollment": |
| + w.BeforeInstallLocked = nil |
| + case "recovery": |
| + w.RecoverArtifacts = nil |
| + } |
| + runtime, err := w.Build(t.Context(), hostbox.WorkSource{}, hostbox.Writer{}) |
| + var dep *ClaimDependencyError |
| + if runtime != nil || !errors.As(err, &dep) || called { |
| + t.Fatalf("missing %s: runtime=%v err=%v effects=%v", missing, runtime, err, called) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go| index 75bf335ae..589bc241c 100644 |
| --- a/internal/host/claimowners.go |
| +++ b/internal/host/claimowners.go |
| @@ -328,14 +328,24 @@ type claimServingOwners struct { |
| published map[string]ClaimPublicationReceipt |
| } |
| +func checkClaimServingSources(s ClaimServingSources) error { |
| + if s.Launch == nil || s.Markers == nil || s.Files == nil { |
| + return &ClaimDependencyError{Contracts: []string{"claimed serving launch, marker and C8 files owners"}} |
| + } |
| + return nil |
| +} |
| + |
| func (s *claimServingOwners) owners() ClaimKindOwners { |
| return ClaimKindOwners{Inputs: s, AdmissionEvidence: s.admissionEvidence, PublicationEvidence: s.publicationEvidence} |
| } |
| func (s *claimServingOwners) serving(o hostclaim.Observed) error { |
| - if s == nil || s.Data == nil || s.Bucket == "" || s.Region == "" || s.Box == "" || s.NewID == nil || s.Now == nil || s.Sources.Launch == nil || s.Sources.Markers == nil { |
| + if s == nil || s.Data == nil || s.Bucket == "" || s.Region == "" || s.Box == "" || s.NewID == nil || s.Now == nil { |
| return hostwrite.ErrUnavailable |
| } |
| + if err := checkClaimServingSources(s.Sources); err != nil { |
| + return err |
| + } |
| if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.ID != "" || o.Claim.Op.Phase != "" || o.Claim.Op.Exec != s.Box { |
| return hostbox.ErrChanged |
| } |
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go| index 6d112723c..60d0710cd 100644 |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| @@ -181,20 +181,16 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r |
| tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: out.Identity.Verify, Local: out.Identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now} |
| out.Serving.Launch = claimServingLaunchSource(backend, tokens) |
| out.Serving.Markers = marker |
| - if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) { |
| + moveWakeEnabled := hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) |
| + if moveWakeEnabled { |
| out.moveRead = &claimMoveReadAccess{check: claimBoundIdentity(out.Identity.Verify, actor), actor: actor, route: r.Registry.Get, launch: claimMoveLaunchSource(backend)} |
| } |
| claimMoveSources.Lock() |
| external := claimMoveSources.Source |
| parks := claimMoveSources.Parks |
| claimMoveSources.Unlock() |
| - if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) { |
| + if moveWakeEnabled { |
| openProductionClaimMoveWake(ctx, store, actor, external, &out, claimBoundIdentity(out.Identity.Verify, actor)) |
| - } else if external != nil { |
| - out.Moves, out.Serving.Files, err = external.Open(ctx, store, out.Data, actor, out.moveRead) |
| - if err != nil { |
| - return out, err |
| - } |
| } |
| // The C8 files manifest provider (moves Task 6; platform-1e ruling of |
| // 2026-10-12): registered under CARLOS_HOST_CLAIM alone, apart from the |
| @@ -228,7 +224,7 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r |
| if closeFiles != nil { |
| err = errors.Join(err, closeFiles(ctx)) |
| } |
| - if external != nil { |
| + if external != nil && moveWakeEnabled { |
| return errors.Join(err, external.Close(ctx)) |
| } |
| return err |
| @@ -292,11 +288,8 @@ func claimInputPolicy(route func(string) (*registry.Route, error), store release |
| } |
| } |
| -// bindClaimLegacyOwners fills the legacy owner fields the constructor uses |
| -// with either flag off. They keep main's typed refusal, before any stop, lease |
| -// or session. D5's serving owners are built by the constructor from |
| -// out.Serving, and only under movesC1 (claim Plan 9 D5 gating), so a |
| -// registered moves source cannot reach them with moves off. |
| +// bindClaimLegacyOwners fills the claims-off fallback. Claimed serving uses |
| +// out.Serving independently of moves and never dispatches through these fields. |
| func bindClaimLegacyOwners(out *ClaimOwnerBindings) { |
| legacy := claimLegacyRefusal{} |
| out.Inputs = legacy |
diff --git a/internal/host/claimwakeassembly.go b/internal/host/claimwakeassembly.go| index b5eb48b44..5f250030f 100644 |
| --- a/internal/host/claimwakeassembly.go |
| +++ b/internal/host/claimwakeassembly.go |
| @@ -95,21 +95,24 @@ type claimSyncer interface { |
| // ClaimWakeAssembly owns cold restore, lease transfer and failure cleanup. |
| // Root supplies Stop and Launch; moves supplies only its input/publication API. |
| type ClaimWakeAssembly struct { |
| - recoveryIntent func(context.Context, hostclaim.Observed, hostclaim.Admission) (hostclaim.Admission, error) |
| - recoveryPrior func(context.Context, hostclaim.Observed) (hostclaim.Observed, bool, error) |
| - recoveryPrelaunched func(context.Context, hostclaim.Observed) error |
| - retained []*retainedClaimWork |
| - AfterAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error |
| - BeforeAdmission func(context.Context, hostclaim.Observed) error |
| - Roles *ClaimRoleAssembly |
| - LeaseRegion string |
| - Producers func(context.Context, hostclaim.Observed) (*ClaimProducers, error) |
| - Inputs ClaimWakeInputs |
| - Manager *cgroup.Manager |
| - Retries activator.ServingRetries |
| - Stop func(context.Context, hostclaim.Observed) error |
| - RetireArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error |
| - RecoverArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error |
| + recoveryIntent func(context.Context, hostclaim.Observed, hostclaim.Admission) (hostclaim.Admission, error) |
| + recoveryPrior func(context.Context, hostclaim.Observed) (hostclaim.Observed, bool, error) |
| + recoveryPrelaunched func(context.Context, hostclaim.Observed) error |
| + retained []*retainedClaimWork |
| + AfterAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error |
| + BeforeAdmission func(context.Context, hostclaim.Observed) error |
| + Roles *ClaimRoleAssembly |
| + LeaseRegion string |
| + Producers func(context.Context, hostclaim.Observed) (*ClaimProducers, error) |
| + Inputs ClaimWakeInputs |
| + Manager *cgroup.Manager |
| + Retries activator.ServingRetries |
| + Stop func(context.Context, hostclaim.Observed) error |
| + RetireArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error |
| + RecoverArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error |
| + RequireServingEnrollment bool |
| + BeforeLegacyInstall func(context.Context, hostclaim.Observed, ClaimLaunchInputs, hostwrite.Work) error |
| + BeforeInstallLocked func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error |
| // Prelaunch journals the admitted session's recoverable record before |
| // restore or materialization; Launch later completes that same record. |
| PrepareState func(context.Context, hostclaim.Observed, hostwrite.Work, ClaimLaunchInputs) error |
| @@ -177,6 +180,9 @@ type claimWakeAttempt struct { |
| } |
| func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource, writer hostbox.Writer) (*activator.ClaimRuntime, error) { |
| + if w != nil && w.RequireServingEnrollment && (w.BeforeLegacyInstall == nil || w.BeforeInstallLocked == nil || w.RecoverArtifacts == nil) { |
| + return nil, &ClaimDependencyError{Contracts: []string{"serving artifact enrollment and root retirement"}} |
| + } |
| if w == nil || w.LeaseRegion == "" || w.AfterAdmission == nil || w.Inputs == nil || w.Manager == nil || w.Stop == nil || w.RetireArtifacts == nil || w.Retain == nil || w.ObserveLoss == nil || w.Producers == nil { |
| return nil, hostwrite.ErrUnavailable |
| } |
| @@ -406,6 +412,24 @@ func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource |
| _, in := a.snapshot() |
| selected = claimLeaseWork{Work: selected, Alive: a.ctx, Check: a.leaseErr} |
| restore := hostbox.ColdRestore{Reader: writer.Data, Bucket: writer.Bucket, Generation: o.Claim.Holder.Generation, Work: selected, Identity: in.Identity, RetireArtifacts: func(ctx context.Context, g string) error { return w.RetireArtifacts(ctx, o, in, g) }} |
| + if o.Claim.Op.Kind == hostclaim.KindServing { |
| + if w.BeforeLegacyInstall != nil { |
| + restore.BeforeLegacyInstall = func(ctx context.Context, path, generation string) error { |
| + if path != in.DBPath || generation != o.Claim.Holder.Generation { |
| + return hostbox.ErrChanged |
| + } |
| + return w.BeforeLegacyInstall(ctx, o, in, selected) |
| + } |
| + } |
| + if w.BeforeInstallLocked != nil { |
| + restore.BeforeInstallLocked = func(ctx context.Context, path, generation string) error { |
| + if path != in.DBPath || generation != o.Claim.Holder.Generation { |
| + return hostbox.ErrChanged |
| + } |
| + return w.BeforeInstallLocked(ctx, o, in) |
| + } |
| + } |
| + } |
| s := activator.ClaimStart{Live: a.ctx, Work: selected, Identity: in.Identity, Manager: w.Manager, Host: rt.Host, Limit: in.Limit, Version: in.Version, Staged: in.Staged} |
| s.Restore = func(call context.Context) error { |
| if err := a.admitsWriters(); err != nil { |
diff --git a/internal/host/plan9_i2_movesoff_test.go b/internal/host/plan9_i2_movesoff_test.go| index d381c4210..dc29c85ac 100644 |
| --- a/internal/host/plan9_i2_movesoff_test.go |
| +++ b/internal/host/plan9_i2_movesoff_test.go |
| @@ -13,12 +13,10 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| -// I-2: with CARLOS_MOVES_PHASE1 off the constructor uses the provider's |
| -// legacy owner fields. A registered moves source supplies the serving sources |
| -// (Files included), but D5's serving owners are reachable only through |
| -// owners.Serving under movesC1. The legacy fields keep main's typed refusal, |
| -// so a moves-off serving wake consumes no stop, lease or session. |
| -func TestPlan9I2MovesOffKeepsLegacyRefusalWithRegisteredSource(t *testing.T) { |
| +// The claims-off fallback keeps the legacy refusal even when serving |
| +// sources exist. Claims-on dispatch is covered by the assembly flag matrix. |
| +func TestClaimOffKeepsLegacyRefusalWithRegisteredSource(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| served := 0 |
| d5 := plan9D5Fixture(t) |
| sources := d5.owners.Sources |
diff --git a/internal/hostbox/restore.go b/internal/hostbox/restore.go| index 13a921f5e..b0b024668 100644 |
| --- a/internal/hostbox/restore.go |
| +++ b/internal/hostbox/restore.go |
| @@ -33,6 +33,12 @@ type ColdRestore struct { |
| Work hostwrite.Work |
| Identity hostwrite.Identity |
| RetireArtifacts RetireArtifacts |
| + // BeforeLegacyInstall parks an unmarked source outside Work.With. The |
| + // caller must bind it for serving, including a genuinely empty creator. |
| + BeforeLegacyInstall func(context.Context, string, string) error |
| + // BeforeInstallLocked validates enrollment after the marker recheck. An |
| + // error prevents purge, temporary database creation and marker writes. |
| + BeforeInstallLocked func(context.Context, string, string) error |
| } |
| // Install purges the old local state and restores plan into dbPath. All local |
| @@ -64,6 +70,11 @@ func (r ColdRestore) Install(ctx context.Context, dbPath string, plan *genselect |
| return fmt.Errorf("retire generation %q: %w", capturedGeneration, err) |
| } |
| } |
| + if capturedGeneration == "" && r.BeforeLegacyInstall != nil { |
| + if err := r.BeforeLegacyInstall(ctx, dbPath, r.Generation); err != nil { |
| + return err |
| + } |
| + } |
| return r.Work.With(ctx, r.Identity, func() error { |
| currentGeneration, err := readGeneration(dbPath + ".gen") |
| if err != nil { |
| @@ -80,6 +91,11 @@ func (r ColdRestore) Install(ctx context.Context, dbPath string, plan *genselect |
| } else if currentGeneration != capturedGeneration { |
| return ErrChanged |
| } |
| + if r.BeforeInstallLocked != nil { |
| + if err := r.BeforeInstallLocked(ctx, dbPath, r.Generation); err != nil { |
| + return err |
| + } |
| + } |
| if err := purgeDB(dbPath); err != nil { |
| return err |
| } |
| @@ -169,6 +185,11 @@ func (r ColdRestore) InstallEmpty(ctx context.Context, dbPath string, authority |
| return err |
| } |
| } |
| + if captured == "" && r.BeforeLegacyInstall != nil { |
| + if err := r.BeforeLegacyInstall(ctx, dbPath, r.Generation); err != nil { |
| + return err |
| + } |
| + } |
| return r.Work.With(ctx, r.Identity, func() error { |
| current, err := readGeneration(dbPath + ".gen") |
| if err != nil { |
| @@ -181,6 +202,11 @@ func (r ColdRestore) InstallEmpty(ctx context.Context, dbPath string, authority |
| } else if current != captured { |
| return ErrChanged |
| } |
| + if r.BeforeInstallLocked != nil { |
| + if err := r.BeforeInstallLocked(ctx, dbPath, r.Generation); err != nil { |
| + return err |
| + } |
| + } |
| if err := purgeDB(dbPath); err != nil { |
| return err |
| } |
diff --git a/internal/hostbox/restore_enrollment_test.go b/internal/hostbox/restore_enrollment_test.go| new file mode 100644 |
| index 000000000..62e2f5630 |
| --- /dev/null |
| +++ b/internal/hostbox/restore_enrollment_test.go |
| @@ -0,0 +1,109 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| +) |
| + |
| +func TestColdRestoreEnrollmentPrecedesEveryMutation(t *testing.T) { |
| + for _, empty := range []bool{false, true} { |
| + for _, refuse := range []string{"", "legacy", "enroll"} { |
| + t.Run(map[bool]string{false: "baseline", true: "empty"}[empty]+"/"+refuse, func(t *testing.T) { |
| + db := filepath.Join(t.TempDir(), "legacy.db") |
| + legacy := []byte("legacy database") |
| + if err := os.WriteFile(db, legacy, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + work := &restoreWork{} |
| + key, body, crc := snapshotFixture(t, "production-restore") |
| + rd := &restoreReader{data: map[string][]byte{key + "@v1": body}} |
| + plan := &genselect.Plan{Generation: "baseline", MaxTXID: 1, Pin: &genselect.Pin{SnapshotCRC64: crc}, Files: []genselect.File{{Key: key, VersionID: "v1", Level: 9, MinTXID: 1, MaxTXID: 1, Size: int64(len(body))}}} |
| + var calls []string |
| + failure := errors.New("injected enrollment refusal") |
| + r := ColdRestore{Reader: rd, Bucket: "bucket", Generation: "new", Work: work, |
| + BeforeLegacyInstall: func(_ context.Context, path, generation string) error { |
| + if work.active || path != db || generation != "new" { |
| + t.Fatal("legacy callback boundary") |
| + } |
| + calls = append(calls, "legacy") |
| + if refuse == "legacy" { |
| + return failure |
| + } |
| + if string(mustRead(t, db)) != string(legacy) { |
| + t.Fatal("legacy source changed before parking") |
| + } |
| + // Explicit parking seam; root identity/retention belongs to the host tests. |
| + return os.Rename(db, db+".parked") |
| + }, |
| + BeforeInstallLocked: func(_ context.Context, path, generation string) error { |
| + if !work.active || path != db || generation != "new" { |
| + t.Fatal("enrollment callback boundary") |
| + } |
| + calls = append(calls, "enroll") |
| + if _, err := os.Stat(db); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("database appeared before enrollment") |
| + } |
| + if _, err := os.Stat(db + ".gen"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("marker appeared before enrollment") |
| + } |
| + if refuse == "enroll" { |
| + return failure |
| + } |
| + return nil |
| + }} |
| + var err error |
| + if empty { |
| + err = r.InstallEmpty(t.Context(), db, func(context.Context) error { return nil }) |
| + } else { |
| + err = r.Install(t.Context(), db, plan) |
| + } |
| + if refuse != "" { |
| + if !errors.Is(err, failure) { |
| + t.Fatalf("refusal: %v", err) |
| + } |
| + if _, err := os.Stat(db + ".gen"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("refused enrollment wrote marker") |
| + } |
| + if refuse == "legacy" { |
| + if string(mustRead(t, db)) != string(legacy) { |
| + t.Fatal("refusal destroyed legacy database") |
| + } |
| + } else if string(mustRead(t, db+".parked")) != string(legacy) { |
| + t.Fatal("refusal destroyed park") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(calls) != 2 || calls[0] != "legacy" || calls[1] != "enroll" { |
| + t.Fatalf("callbacks %v", calls) |
| + } |
| + if string(mustRead(t, db+".gen")) != "new\n" { |
| + t.Fatal("missing successor marker") |
| + } |
| + if empty { |
| + if _, err := os.Stat(db); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("empty restore wrote a database") |
| + } |
| + return |
| + } |
| + check, err := sql.Open("sqlite", db) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer check.Close() |
| + var value string |
| + if err := check.QueryRow("SELECT value FROM fixture").Scan(&value); err != nil || value != "production-restore" { |
| + t.Fatalf("restored %q: %v", value, err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/hostguard/servingstop_test.go b/internal/hostguard/servingstop_test.go| new file mode 100644 |
| index 000000000..fb6d5cd47 |
| --- /dev/null |
| +++ b/internal/hostguard/servingstop_test.go |
| @@ -0,0 +1,103 @@ |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func TestCompletedServingStops(t *testing.T) { |
| + for _, tc := range []string{"exact", "pending", "wrong-session", "operation", "phase", "obligation", "wrong-box", "mixed-sessions", "view"} { |
| + t.Run(tc, func(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: target.Box} |
| + target.Op, target.Phase = "", "" |
| + target.Region = "uk" |
| + f.obs.Claim.Holder.WriterBox = target.Box |
| + f.obs.Claim.Holder.Generation = "g7" |
| + f.obs.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: target.Session, Box: target.Box, Region: target.Region, Generation: "g7"}} |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc != "pending" { |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), time.Now(), func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tc == "operation" || tc == "phase" || tc == "obligation" || tc == "mixed-sessions" { |
| + // Negative fixtures deliberately put unusable evidence in the journal. |
| + err := f.g.locked(t.Context(), target.Host, func(d *recordDir) error { |
| + r, err := d.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + switch tc { |
| + case "operation": |
| + r.FenceJournal[0].Target.Op = "op" |
| + case "phase": |
| + r.FenceJournal[0].Target.Phase = "phase" |
| + case "obligation": |
| + r.FenceJournal[0].Target.ObligationID = WriterStopObligation(target.Session) |
| + case "mixed-sessions": |
| + other := r.FenceJournal[0] |
| + other.Target.Session++ |
| + other.Target.FenceID = "second" |
| + r.FenceJournal = append(r.FenceJournal, other) |
| + } |
| + return d.write(r) |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tc == "view" { |
| + r, err := readRecord(f.g.DataDir, target.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.g.FenceView = func(context.Context, string, Captured) (FenceView, error) { |
| + return FenceView{Schema: 1, Host: target.Host, Captured: captured, Installation: RootInstallation{Host: target.Host, Migration: 1}, Receipts: r.FenceJournal}, nil |
| + } |
| + } |
| + session, box := target.Session, target.Box |
| + if tc == "wrong-session" { |
| + session++ |
| + } |
| + if tc == "wrong-box" { |
| + box = "other" |
| + } |
| + got, err := f.g.CompletedServingStops(t.Context(), target.Host, captured, session, box) |
| + if tc == "exact" || tc == "view" { |
| + if err != nil || len(got) != 1 || got[0].Target != target || string(got[0].Bytes) != "receipt" { |
| + t.Fatalf("receipts %+v: %v", got, err) |
| + } |
| + if _, err := f.g.CompletedWriterStops(t.Context(), target.Host, captured, "", box); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatalf("operation reader accepted serving: %v", err) |
| + } |
| + } else if !errors.Is(err, ErrNoFenceReceipt) || len(got) != 0 { |
| + t.Fatalf("invalid evidence accepted: %+v %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestCompletedServingStopsRejectsZeroIdentity(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + for _, tc := range []struct { |
| + host string |
| + c Captured |
| + session uint64 |
| + box string |
| + }{ |
| + {"", captured, 7, "uk"}, {target.Host, Captured{}, 7, "uk"}, {target.Host, captured, 0, "uk"}, {target.Host, captured, 7, ""}, |
| + } { |
| + if _, err := f.g.CompletedServingStops(t.Context(), tc.host, tc.c, tc.session, tc.box); !errors.Is(err, ErrInvalidInput) { |
| + t.Fatalf("%+v: %v", tc, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostguard/writerstop.go b/internal/hostguard/writerstop.go| index 59ceb0891..5d586188e 100644 |
| --- a/internal/hostguard/writerstop.go |
| +++ b/internal/hostguard/writerstop.go |
| @@ -111,6 +111,39 @@ func (g Guard) CompletedWriterStops(ctx context.Context, host string, captured C |
| return out, err |
| } |
| +// CompletedServingStops selects completed stops for one serving installation. |
| +// Operation receipts cannot stand in for a serving stop. |
| +func (g Guard) CompletedServingStops(ctx context.Context, host string, captured Captured, session uint64, box string) ([]FenceReceipt, error) { |
| + if ctx == nil || host == "" || captured.Incarnation == "" || captured.Stamp == 0 || session == 0 || box == "" { |
| + return nil, ErrInvalidInput |
| + } |
| + var out []FenceReceipt |
| + err := g.fenceLocked(ctx, host, captured, func(d *recordDir) error { |
| + r, err := d.read(host) |
| + if err != nil { |
| + return err |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + t := receipt.Target |
| + if t.Host != host || t.Incarnation != captured.Incarnation || t.Stamp != captured.Stamp || t.Box != box || t.Op != "" || t.Phase != "" || t.ObligationID != "" { |
| + continue |
| + } |
| + if t.Session != session { |
| + return ErrNoFenceReceipt |
| + } |
| + out = append(out, receipt) |
| + } |
| + if len(out) == 0 { |
| + return ErrNoFenceReceipt |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return out, nil |
| +} |
| + |
| func RecoveryWriter(c *hostclaim.Claim, box string) (hostclaim.AdmissionRecord, bool) { |
| if c == nil || (c.Op.Kind != hostclaim.KindRestoring && c.Op.Kind != hostclaim.KindReverting) || c.Op.Intent["source"] != box { |
| return hostclaim.AdmissionRecord{}, false |
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go| index 532c38815..474b97ecc 100644 |
| --- a/internal/provision/plan14_legacy_identity_test.go |
| +++ b/internal/provision/plan14_legacy_identity_test.go |
| @@ -317,3 +317,42 @@ func TestPlan14Zddr4OverlayPreservesLegacySeal(t *testing.T) { |
| t.Fatal("unapproved legacy fingerprint accepted") |
| } |
| } |
| + |
| +func TestPlan14SeedOverlayPreservesLegacySeal(t *testing.T) { |
| + root := censusRoot(t) |
| + inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| + for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv"} { |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + const seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d" |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + before := len(inv.Legacy) |
| + 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 errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + for _, key := range []string{ |
| + "internal/host/claimconstruction.go:OpenProductionClaimAssembly", |
| + "internal/host/claimproduction.go:ProductionClaimOwnerProvider.OpenClaimOwners", |
| + "internal/host/claimowners.go:checkClaimServingSources", |
| + "internal/host/claimowners.go:claimServingOwners.serving", |
| + "internal/host/claimwakeassembly.go:ClaimWakeAssembly.Build", |
| + "internal/hostbox/restore.go:ColdRestore.Install", |
| + "internal/hostbox/restore.go:ColdRestore.InstallEmpty", |
| + "internal/hostguard/writerstop.go:Guard.CompletedServingStops", |
| + } { |
| + e, ok := inv.Entries[key] |
| + if !ok || e.Class != "authority" || !strings.HasPrefix(e.Basis, "rev3=PROPOSED-seed;") || !rootReadKeptTask(e.Task) { |
| + t.Fatalf("unproposed or unowned seed authority %s: %+v", key, e) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index fe8dbe6b7..bb47df419 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -668,6 +668,12 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| // activator rows and four new ones (generation socket directories) add |
| // fourteen frozen declarations. |
| seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d" |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-seed.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| 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| new file mode 100644 |
| index 000000000..6fd9e187d |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -0,0 +1,15 @@ |
| +# Files seed checkpoint overlay; pending Paul’s census ruling. |
| +# No root parking or deletion authority is implemented in this checkpoint. |
| +# Load after zddr4 and its explicit seal check. No legacy fingerprints are added or removed. |
| +# Every classification below is rev3=PROPOSED-seed, not an approved disposition. |
| +internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,G rev3=PROPOSED-seed; box process identity and provider bindings; serving dispatch independent of moves; serving enrollment required before a runtime can be built file:Read@4150,file:Read@9550,file:ReadFile@3694,registry:Host@9577,whole:Route@9665 088f34c7fe1b4f9bb7a3187d40d60bff0fd811ebf1e1888ce3d4781dc4de80d7 |
| +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/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/claimwakeassembly.go ClaimWakeAssembly.Build authority C,G,J1,J4 rev3=PROPOSED-seed; claim/route selection and launch inputs; serving callbacks bind exact database and admitted generation; unbound retirement hooks refuse construction file:Command@9968,file:Prepare@7029,file:Read@1348,file:Read@1442,registry:Backing@2672,registry:Host@12582,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@10106,whole:Route@2899,whole:Route@5830 60104a1c8f22af5a2a9f6aad71d6c0360b193eba93efc3baab6cfddc8145cf12 |
| +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/hostguard/writerstop.go Guard.CompletedServingStops authority C rev3=PROPOSED-seed; root fence journal through the existing Guard backend; exact host, incarnation, stamp, session and box, with empty operation, phase and obligation; no mixed-session or pending-only evidence 79260151b0fe4f86c59cbe65d727cdceb56028bdfdb208fc9f02cebba1473a98 |
| +@coverage internal/host fde50d7114d67d82f5cebef8e36c26020e2b29f5987b0b6bd818fe7c17b5476c |
| +@coverage internal/hostbox fa8a9baca13bbb0f7fbc2f47e24e2e5187a728372b247359a3f61d455e7e4cd5 |
| +@coverage internal/hostguard 51408b42f01be94a232e395494e5d154cc57736e32a6519ac6c03e9ff3d091e6 |