Plan 14 integrated: ordinary retirement with moves off, eight more production defects (not for merge)
Moves-only dependency for ordinary generation retirement; stopped-writer dependency during cleanup; file pins protecting a whole generation; deletion/parking participation refusal and retired-stamp cleanup; empty ordinary parking phase; lease release cancelling its own delete; repeated same-controller drain fence; cross-bind-mount typed retirement EXDEV.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 files changed,
+1122
−89
internal/host/claimmoveparks.go+3 −3internal/host/claimordinaryparks.go+177 −0internal/host/claimordinaryparks_linux_test.go+280 −0internal/host/claimproduction.go+17 −2internal/host/claimretirerecovery_test.go+66 −9internal/host/claimstop_producers.go+4 −2internal/host/plan14_delete_participation_test.go+116 −0internal/host/plan14_generation_parked_linux_test.go+30 −0internal/host/plan14_lifecycle_retire_root_linux_test.go+43 −0internal/host/plan14_lifecycle_root_linux_test.go+76 −48internal/host/plan14_lifecycle_trace_root_linux_test.go+62 −0internal/host/rootexecution_authority.go+43 −3internal/host/rootexecution_settle.go+14 −3internal/host/rootgeneration_parked.go+11 −1internal/hostbox/renewing.go+1 −1internal/hostbox/renewing_test.go+52 −0internal/hostbox/typedpark_linux.go+6 −12internal/hostbox/typedpark_linux_test.go+33 −0internal/hostbox/typedpark_path.go+37 −0internal/hostclaim/delete.go+3 −0internal/hostclaim/delete_test.go+10 −0internal/park/remote.go+3 −0internal/park/sweep.go+4 −0internal/provision/plan14_p3_census_test.go+9 −0internal/provision/testdata/plan14-root-reads-p3.tsv+22 −5
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 325.867s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.657s
ok github.com/carlosframework/platform/cmd/moveprobe 93.578s
ok github.com/carlosframework/platform/cmd/zddprobe 0.727s
ok github.com/carlosframework/platform/internal/activator 251.654s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.519s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.129s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.501s
? 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.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.707s
ok github.com/carlosframework/platform/internal/changelog 0.331s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 402.985s
? 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.143s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.440s
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 27.473s
? 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.136s
ok github.com/carlosframework/platform/internal/gate 1.662s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.019s
ok github.com/carlosframework/platform/internal/geoip 0.031s
ok github.com/carlosframework/platform/internal/historyreadback 0.193s
ok github.com/carlosframework/platform/internal/host 185.017s
ok github.com/carlosframework/platform/internal/hostactors 0.627s
ok github.com/carlosframework/platform/internal/hostbox 3.470s
ok github.com/carlosframework/platform/internal/hostclaim 0.818s
ok github.com/carlosframework/platform/internal/hostevidence 0.008s
ok github.com/carlosframework/platform/internal/hostexec 6.505s
ok github.com/carlosframework/platform/internal/hostfence 0.050s
ok github.com/carlosframework/platform/internal/hostfiles 10.961s
ok github.com/carlosframework/platform/internal/hostfloor 0.022s
ok github.com/carlosframework/platform/internal/hostguard 1.088s
ok github.com/carlosframework/platform/internal/hostops 2.186s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostrelocate 0.724s
ok github.com/carlosframework/platform/internal/hostresolve 0.220s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 5.275s
ok github.com/carlosframework/platform/internal/instanceexec 2.850s
ok github.com/carlosframework/platform/internal/instancefs 0.025s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.024s
ok github.com/carlosframework/platform/internal/layout 2.042s
ok github.com/carlosframework/platform/internal/lease 9.860s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.022s
ok github.com/carlosframework/platform/internal/ledger 0.028s
ok github.com/carlosframework/platform/internal/mail 0.293s
ok github.com/carlosframework/platform/internal/maintenance 0.093s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.634s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.088s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 22.603s
ok github.com/carlosframework/platform/internal/moves/archive 1.053s
ok github.com/carlosframework/platform/internal/objstore 0.252s
ok github.com/carlosframework/platform/internal/ops 0.269s
ok github.com/carlosframework/platform/internal/park 0.319s
ok github.com/carlosframework/platform/internal/privatefile 0.010s
ok github.com/carlosframework/platform/internal/provision 42.111s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 6.075s
ok github.com/carlosframework/platform/internal/relay 7.241s
ok github.com/carlosframework/platform/internal/release 24.335s
ok github.com/carlosframework/platform/internal/rollout 4.204s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.061s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.656s
ok github.com/carlosframework/platform/internal/s3compat 0.008s
ok github.com/carlosframework/platform/internal/safefile 0.009s
ok github.com/carlosframework/platform/internal/schedule 2.772s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 17.173s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.516s
ok github.com/carlosframework/platform/internal/status 0.052s
ok github.com/carlosframework/platform/internal/steering 0.010s
ok github.com/carlosframework/platform/internal/stopevidence 0.342s
ok github.com/carlosframework/platform/internal/svcrole 0.005s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.670s
=== 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: 48562338 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.03s)
--- 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.02s)
--- 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.01s)
--- 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.037s
=== RUN TestT4StorageProbeMinio
2026/10/16 21:20:24 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF1F54990A7C96, 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.025s
minio contract: PASS
diff --git a/internal/host/claimmoveparks.go b/internal/host/claimmoveparks.go| index e93c9c6dc..cf3c9997c 100644 |
| --- a/internal/host/claimmoveparks.go |
| +++ b/internal/host/claimmoveparks.go |
| @@ -140,12 +140,12 @@ func (p productionClaimRegional) VerifyMovesSweep(ctx context.Context, o hostcla |
| if _, err := p.Identity.Verify(ctx); err != nil { |
| return park.MoveSweepProof{}, err |
| } |
| + if p.Parks == nil { |
| + return park.MoveSweepProof{}, park.ErrUnsafe |
| + } |
| if o.Claim != nil && o.Claim.Free() { |
| return claimFreeMoveProof(o) |
| } |
| - if p.Parks == nil { |
| - return park.MoveSweepProof{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}} |
| - } |
| verifier, ok := p.Parks.Evidence.(park.MoveSweepVerifier) |
| if !ok { |
| return park.MoveSweepProof{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}} |
diff --git a/internal/host/claimordinaryparks.go b/internal/host/claimordinaryparks.go| new file mode 100644 |
| index 000000000..4c079fc81 |
| --- /dev/null |
| +++ b/internal/host/claimordinaryparks.go |
| @@ -0,0 +1,177 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "maps" |
| + "reflect" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/park" |
| +) |
| + |
| +func (p productionClaimRegional) ordinaryHome(ctx context.Context, o hostclaim.Observed) (hostbox.Actor, string, error) { |
| + a, err := p.Identity.Verify(ctx) |
| + if err != nil { |
| + return a, "", err |
| + } |
| + if o.Absent || o.ETag == "" || o.Claim == nil || o.Host != o.Claim.Host || o.Claim.Validate() != nil || p.Local == nil || p.Local.assembly == nil { |
| + return a, "", park.ErrUnsafe |
| + } |
| + home, err := park.VerifiedHome(a.FleetHomeRegion, a.HomeRegion, a.Region, o.Claim.Op.Intent["home_region"]) |
| + if err != nil || a.FleetHomeRegion == "" || a.Region != a.FleetHomeRegion { |
| + return a, "", park.ErrUnsafe |
| + } |
| + switch o.Claim.Op.Kind { |
| + case hostclaim.KindServing, hostclaim.KindDeleting, hostclaim.KindParking: |
| + default: |
| + return a, "", park.ErrUnsafe |
| + } |
| + if h := o.Claim.Holder; h != nil { |
| + if h.Location != "instances/"+o.Host+"/"+h.Incarnation || (o.Claim.Op.Exec != "" && o.Claim.Op.Exec != a.Box) { |
| + return a, "", park.ErrUnsafe |
| + } |
| + for _, admission := range h.Admissions { |
| + if admission.Box != a.Box || !park.SameRegion(home, admission.Region, a.Region) || (admission.PredecessorRegion != "" && !park.SameRegion(home, admission.PredecessorRegion, a.Region)) { |
| + return a, "", park.ErrUnsafe |
| + } |
| + } |
| + } |
| + return a, home, nil |
| +} |
| + |
| +func (p productionClaimRegional) ordinaryView(ctx context.Context, o hostclaim.Observed) (park.View, error) { |
| + a, home, err := p.ordinaryHome(ctx, o) |
| + if err != nil { |
| + return park.View{}, err |
| + } |
| + v := park.View{HomeRegion: home, ClaimETag: o.ETag, Region: park.PlacementRegion(a.Region), AuthoritativeRegion: movesNoLiveRegion, |
| + ArchivedIncarnations: claimArchivedLocations(o.Claim), RefusedLocations: map[string]error{"moves/" + o.Host: park.ErrUnsafe}} |
| + h := o.Claim.Holder |
| + if h == nil { |
| + return v, nil |
| + } |
| + v.GenerationOf = func(r park.Ref) (string, bool) { return landingGenerationOf(h.Location, r.Key) } |
| + if o.Claim.Op.Kind == hostclaim.KindParking { |
| + return v, nil |
| + } |
| + v.AuthoritativeRegion = a.Region |
| + data := hostbox.RequestStore{Store: p.Local.assembly.Data, Check: claimBoundIdentity(p.Identity.Verify, a)} |
| + listing, err := data.ListVersions(ctx, a.Bucket, h.Location+"/") |
| + if err != nil { |
| + return park.View{}, err |
| + } |
| + if err = readMarker(ctx, data, a.Bucket, o.Host, h.Location, *h, listing, &v); err != nil { |
| + return park.View{}, err |
| + } |
| + databasePins := maps.Clone(v.Pinned) |
| + if err = pinFilesLiveSet(ctx, data, a.Bucket, h.Location, listing, &v, liveGenerations(o.Claim, v)...); err != nil { |
| + return park.View{}, err |
| + } |
| + v.FilePins = map[park.Ref]bool{} |
| + for ref := range v.Pinned { |
| + if !databasePins[ref] && strings.HasPrefix(ref.Key, h.Location+"/files/") { |
| + v.FilePins[ref] = true |
| + } |
| + } |
| + return v, nil |
| +} |
| + |
| +func (p productionClaimRegional) VerifyObligation(ctx context.Context, o hostclaim.Observed, ob hostclaim.Obligation) (hostclaim.ParkScope, error) { |
| + if p.Parks != nil { |
| + if evidence, ok := p.Parks.Evidence.(hostbox.RegionalObligationEvidence); ok { |
| + return evidence.VerifyObligation(ctx, o, ob) |
| + } |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + a, home, err := p.ordinaryHome(ctx, o) |
| + if err != nil { |
| + return hostclaim.ParkScope{}, err |
| + } |
| + c, h := o.Claim, o.Claim.Holder |
| + if c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || h == nil || ob.Kind != "park-generation" || ob.Owner != "region:"+ob.Region || !park.SameRegion(home, ob.Region, a.Region) || ob.Box != "" || ob.ParkTarget != "" || len(ob.EvidenceRefs) != 0 || ob.Generation == "" || ob.Generation == h.Generation { |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + session, err := strconv.ParseUint(strings.TrimPrefix(ob.Op, "session:"), 10, 64) |
| + if err != nil || ob.Op != "session:"+strconv.FormatUint(session, 10) || session == 0 || session > h.Session { |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + found := false |
| + for _, live := range c.Obligations { |
| + live.Park = ob.Park |
| + if reflect.DeepEqual(live, ob) { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + return hostclaim.ParkScope{}, park.ErrChanged |
| + } |
| + guard := p.Local.assembly.Guard |
| + if guard.Root == nil && guard.FenceView == nil { |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + var at time.Time |
| + for _, admission := range h.Admissions { |
| + if admission.Generation != ob.Generation { |
| + continue |
| + } |
| + if admission.Mode != "cold" || admission.Session >= session { |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + receipts, err := guard.CompletedServingStops(ctx, o.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: admission.Session}, admission.Session, a.Box) |
| + if err != nil { |
| + return hostclaim.ParkScope{}, err |
| + } |
| + for _, receipt := range receipts { |
| + if !park.SameRegion(home, receipt.Target.Region, a.Region) || receipt.Target.Enrollment != a.Enrollment || receipt.EvidenceAt.IsZero() || verifyRetirementReceipt(ctx, receipt) != nil { |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + if ob.Park != nil { |
| + if receipt.EvidenceAt.Equal(ob.Park.EvidenceAt) { |
| + at = receipt.EvidenceAt |
| + } |
| + } else if receipt.EvidenceAt.After(at) { |
| + at = receipt.EvidenceAt |
| + } |
| + } |
| + } |
| + if at.IsZero() { |
| + return hostclaim.ParkScope{}, park.ErrUnsafe |
| + } |
| + scope := hostclaim.ParkScope{Location: h.Location, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: hostbox.RequestLimit} |
| + if ob.Park != nil && !reflect.DeepEqual(*ob.Park, scope) { |
| + return hostclaim.ParkScope{}, park.ErrChanged |
| + } |
| + return scope, scope.Validate(o.Host) |
| +} |
| + |
| +func (p productionClaimRegional) authorizeOrdinaryRetirement(ctx context.Context, o hostclaim.Observed, ob hostclaim.Obligation) error { |
| + a, _, err := p.ordinaryHome(ctx, o) |
| + if err != nil { |
| + return err |
| + } |
| + if o.Claim.Op.Kind != hostclaim.KindParking || o.Claim.Holder == nil || ob.Kind != "park-replica" || p.Local.assembly.RetirementAuthority == nil { |
| + return park.ErrUnsafe |
| + } |
| + if ob.ParkTarget != o.Claim.Holder.Location { |
| + if ob.ParkTarget != "moves/"+o.Host { |
| + return park.ErrUnsafe |
| + } |
| + data := hostbox.RequestStore{Store: p.Local.assembly.Data, Check: claimBoundIdentity(p.Identity.Verify, a)} |
| + versions, err := data.ListVersions(ctx, a.Bucket, ob.ParkTarget+"/") |
| + if err != nil { |
| + return err |
| + } |
| + for _, version := range versions { |
| + if version.Key != ob.ParkTarget+"/lock.json" || version.VersionID == "" { |
| + return park.ErrUnsafe |
| + } |
| + } |
| + } |
| + return (hostops.RetirementEvidence{Authority: *p.Local.assembly.RetirementAuthority}).VerifyBound(ctx, o, ob) |
| +} |
diff --git a/internal/host/claimordinaryparks_linux_test.go b/internal/host/claimordinaryparks_linux_test.go| new file mode 100644 |
| index 000000000..1916b1885 |
| --- /dev/null |
| +++ b/internal/host/claimordinaryparks_linux_test.go |
| @@ -0,0 +1,280 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/park" |
| +) |
| + |
| +func TestPlan14OrdinaryGenerationParks(t *testing.T) { |
| + for _, tc := range []string{"valid", "real-leases", "moves", "foreign-region", "foreign-box", "current-generation", "unknown-generation", "missing-receipt", "altered-receipt", "foreign-enrollment", "unbound-root", "changed-time", "changed-location", "unknown-obligation"} { |
| + t.Run(tc, func(t *testing.T) { |
| + f, _, _, in := servingRetirementFixture(t) |
| + first := servingAdmit(t, f, &in, "G") |
| + receipt := servingStop(t, f, first) |
| + current := servingAdmit(t, f, &in, "G2") |
| + current, err := hostclaim.Update(t.Context(), f.claims, current, hostclaim.PublishGeneration("", "", current.Claim.Holder.Session, "G2", "ie", f.newID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var ob hostclaim.Obligation |
| + for _, x := range current.Claim.Obligations { |
| + if x.Generation == "G" { |
| + ob = x |
| + } |
| + } |
| + if ob.ID == "" { |
| + t.Fatal("missing publication obligation") |
| + } |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box-1", Enrollment: "enrollment", Revision: 7, Region: "ie", FleetHomeRegion: "ie"}) |
| + identity.HomeRegion = "ie" |
| + if tc == "foreign-enrollment" { |
| + receipt.Target.Enrollment = "another-enrollment" |
| + var proof StopReceipt |
| + if err := json.Unmarshal(receipt.Bytes, &proof); err != nil { |
| + t.Fatal(err) |
| + } |
| + proof.Enrollment = receipt.Target.Enrollment |
| + receipt.Bytes = mustMarshalStopReceipt(proof) |
| + } |
| + guard := f.guard |
| + guard.FenceView = func(_ context.Context, host string, c hostguard.Captured) (hostguard.FenceView, error) { |
| + receipts := []hostguard.FenceReceipt{receipt} |
| + if tc == "missing-receipt" { |
| + receipts = nil |
| + } |
| + if tc == "altered-receipt" { |
| + receipts[0].Bytes = []byte("forged") |
| + } |
| + return hostguard.FenceView{Schema: 1, Host: host, Captured: c, Installation: hostguard.RootInstallation{Host: host, Migration: 1}, Receipts: receipts}, nil |
| + } |
| + data := leasetest.NewVersionedBucket() |
| + data.AddBucket("data") |
| + p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{assembly: &ClaimAssembly{Guard: guard, Data: data}}} |
| + switch tc { |
| + case "moves": |
| + current.Claim.Op.Kind = hostclaim.KindMoving |
| + case "foreign-region": |
| + ob.Region, ob.Owner = "uk", "region:uk" |
| + case "foreign-box": |
| + current.Claim.Holder.Admissions[0].Box = "other" |
| + case "current-generation": |
| + ob.Generation = "G2" |
| + case "unknown-generation": |
| + ob.Generation = "unknown" |
| + case "unknown-obligation": |
| + ob.ID = "unknown" |
| + case "unbound-root": |
| + p.Local.assembly.Guard.FenceView = nil |
| + case "changed-time", "changed-location": |
| + scope, err := p.VerifyObligation(t.Context(), current, ob) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc == "changed-time" { |
| + scope.EvidenceAt = scope.EvidenceAt.Add(1) |
| + } else { |
| + scope.Location += "/other" |
| + } |
| + ob.Park = &scope |
| + |
| + } |
| + |
| + if tc == "foreign-region" || tc == "current-generation" || tc == "unknown-generation" { |
| + for i := range current.Claim.Obligations { |
| + if current.Claim.Obligations[i].ID == ob.ID { |
| + current.Claim.Obligations[i] = ob |
| + } |
| + } |
| + } |
| + scope, err := p.VerifyObligation(t.Context(), current, ob) |
| + if tc == "valid" || tc == "real-leases" { |
| + if err != nil || scope.Location != current.Claim.Holder.Location || !scope.EvidenceAt.Equal(receipt.EvidenceAt) || scope.RequestLimit != hostbox.RequestLimit { |
| + t.Fatal(scope, err) |
| + } |
| + ob.Park = &scope |
| + p.Local.Identity, p.Local.lifetime = identity, t.Context() |
| + p.Local.Resources.Mode = "agent" |
| + p.Local.executionCall = func(context.Context, execlease.ExecutionRequest) (execlease.ExecutionResponse, error) { |
| + return execlease.ExecutionResponse{}, errors.New("stopped current writer cannot execute") |
| + } |
| + if err := p.Authorize(t.Context(), current, ob); err != nil { |
| + t.Fatal("cleanup required writer execution permission", err) |
| + } |
| + if _, err = p.VerifyObligation(t.Context(), current, ob); err != nil { |
| + t.Fatal("bound replay", err) |
| + } |
| + if _, err = p.VerifyMovesSweep(t.Context(), current, nil); err == nil { |
| + t.Fatal("moves sweep enabled") |
| + } |
| + freed := current |
| + claim := *current.Claim |
| + freed.Claim = &claim |
| + claim.RetiredIncarnations = []string{claim.Holder.Incarnation} |
| + claim.Holder, claim.Op = nil, hostclaim.Op{} |
| + claim.QuietAfter = map[string]time.Time{"ie": receipt.EvidenceAt} |
| + if _, err = p.VerifyMovesSweep(t.Context(), freed, nil); err == nil { |
| + t.Fatal("free claim enabled moves sweep") |
| + } |
| + location := current.Claim.Holder.Location |
| + oldSnapshot := location + "/g-G/0009/snapshot.ltx" |
| + oldVersion := data.Put("data", oldSnapshot, []byte("old snapshot")) |
| + data.Put("data", location+"/g-G2/0009/snapshot.ltx", []byte("current snapshot")) |
| + keptFile := location + "/files/g-G/objects/0000000000000001-aa" |
| + keptVersion := data.Put("data", keptFile, []byte("kept")) |
| + filesManifest(t, func(k string, b []byte) { data.Put("data", k, b) }, location, "G2", "kept.txt="+keptFile) |
| + marker, err := json.Marshal(map[string]any{"host": current.Host, "incarnation": current.Claim.Holder.Incarnation, "op": "G2"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + data.Put("data", location+"/generation.json", marker) |
| + engine := park.Engine{HomeRegion: "ie", Store: data, Claims: f.claims, Bucket: "data", Region: "ie", Leases: evLeases{}, Now: func() time.Time { return f.now.Add(park.SignatureAge + hostbox.RequestLimit + time.Second) }, NewID: f.newID, View: p.View, Authorize: p.Authorize} |
| + if tc == "real-leases" { |
| + handler, _ := leasetest.NewS3Server(data) |
| + server := httptest.NewServer(handler) |
| + defer server.Close() |
| + for key, value := range map[string]string{"CARLOS_S3_ENDPOINT": server.URL, "AWS_ENDPOINT_URL_S3": server.URL, "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent"} { |
| + t.Setenv(key, value) |
| + } |
| + engine.Leases = hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: "eu-west-1"}, Resolve: p.LeaseSet} |
| + } |
| + work := park.Work{Host: current.Host, ID: ob.ID, Op: ob.Op} |
| + if err := engine.Bind(t.Context(), work, scope); err != nil { |
| + t.Fatal("bind generation", err) |
| + } |
| + pinnedMarker, err := json.Marshal(map[string]any{"host": current.Host, "incarnation": current.Claim.Holder.Incarnation, "op": "G2", "snapshot_key": oldSnapshot, "snapshot_version": oldVersion}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + data.Put("data", location+"/generation.json", pinnedMarker) |
| + if err := engine.Run(t.Context(), work); !errors.Is(err, park.ErrUnsafe) { |
| + t.Fatal("database pin did not protect its generation", err) |
| + } |
| + if _, err := data.GetVersion(t.Context(), "data", oldSnapshot, oldVersion); err != nil { |
| + t.Fatal("pinned database snapshot was deleted", err) |
| + } |
| + data.Put("data", location+"/generation.json", marker) |
| + if err := engine.Run(t.Context(), work); err != nil { |
| + t.Fatal("retire generation", err) |
| + } |
| + left, err := data.ListVersions(t.Context(), "data", location+"/g-G/") |
| + if err != nil || len(left) != 0 { |
| + t.Fatal("predecessor remains", left, err) |
| + } |
| + left, err = data.ListVersions(t.Context(), "data", location+"/g-G2/") |
| + if err != nil || len(left) != 1 { |
| + t.Fatal("current generation changed", left, err) |
| + } |
| + if body, err := data.GetVersion(t.Context(), "data", keptFile, keptVersion); err != nil || string(body) != "kept" { |
| + t.Fatal("referenced file was retired with its database generation", err) |
| + } |
| + |
| + } else if err == nil { |
| + t.Fatal("accepted", tc) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14OrdinaryRetirementRequiresProof(t *testing.T) { |
| + for _, target := range []string{"holder", "empty-staging", "staging-residue", "foreign"} { |
| + t.Run(target, func(t *testing.T) { |
| + f, _, _, in := servingRetirementFixture(t) |
| + o := servingAdmit(t, f, &in, "G") |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box-1", Enrollment: "enrollment", Revision: 7, Region: "ie", FleetHomeRegion: "ie"}) |
| + identity.HomeRegion = "ie" |
| + data := leasetest.NewVersionedBucket() |
| + data.AddBucket("data") |
| + ob := hostclaim.Obligation{ID: "park", Op: "delete", Kind: "park-replica", Owner: "region:ie", Region: "ie", Retirement: "retire", ParkTarget: o.Claim.Holder.Location} |
| + want := hostops.ErrIdentity |
| + switch target { |
| + case "empty-staging", "staging-residue": |
| + ob.ParkTarget = "moves/" + o.Host |
| + data.Put("data", ob.ParkTarget+"/lock.json", []byte("lock")) |
| + if target == "staging-residue" { |
| + data.Put("data", ob.ParkTarget+"/other/g-G/object", []byte("deferred move")) |
| + want = park.ErrUnsafe |
| + } |
| + case "foreign": |
| + ob.ParkTarget += "/another" |
| + want = park.ErrUnsafe |
| + } |
| + o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: "delete"} |
| + o.Claim.Obligations = []hostclaim.Obligation{ob} |
| + if err := o.Claim.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{assembly: &ClaimAssembly{Data: data, RetirementAuthority: &hostops.ProductionAuthority{}}}} |
| + if err := p.authorizeOrdinaryRetirement(t.Context(), o, ob); !errors.Is(err, want) { |
| + t.Fatal("unsigned retirement or deferred target accepted", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14OrdinaryParkView(t *testing.T) { |
| + f, _, _, in := servingRetirementFixture(t) |
| + o := servingAdmit(t, f, &in, "G") |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box-1", Enrollment: "enrollment", Revision: 7, Region: "ie", FleetHomeRegion: "ie"}) |
| + identity.HomeRegion = "ie" |
| + data := leasetest.NewVersionedBucket() |
| + data.AddBucket("data") |
| + p := productionClaimRegional{Identity: identity, Local: &productionClaimLocal{assembly: &ClaimAssembly{Data: data}}} |
| + v, err := p.View(t.Context(), o) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + live, err := park.LiveGenerations(o.Claim, v, o.Claim.Holder.Location) |
| + if err != nil || !live["G"] || !live[hostclaim.RootGeneration] || v.RefusedLocations["moves/"+o.Host] == nil { |
| + t.Fatal(live, err) |
| + } |
| + |
| + p.Identity.HomeRegion = "" |
| + v, err = p.View(t.Context(), o) |
| + if err != nil || v.HomeRegion != "" || v.AuthoritativeRegion != "ie" { |
| + t.Fatal("named home placement", v, err) |
| + } |
| + p.Identity.HomeRegion = "ie" |
| + key := o.Claim.Holder.Location + "/g-G/0009/snapshot.ltx" |
| + version := data.Put("data", key, []byte("retained snapshot")) |
| + marker, err := json.Marshal(map[string]any{"host": o.Host, "incarnation": o.Claim.Holder.Incarnation, "op": "G", "snapshot_key": key, "snapshot_version": version}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + data.Put("data", o.Claim.Holder.Location+"/generation.json", marker) |
| + data.Put("data", key, []byte("newer bytes")) |
| + v, err = p.View(t.Context(), o) |
| + if err != nil || !v.Pinned[park.Ref{Key: key, VersionID: version}] || v.MarkerGeneration != "G" { |
| + t.Fatal("lost exact snapshot pin", v, err) |
| + } |
| + unavailable := errors.New("version read unavailable") |
| + data.FailOn = func(op, key string) error { |
| + if op == "get" { |
| + return unavailable |
| + } |
| + return nil |
| + } |
| + if _, err := p.View(t.Context(), o); !errors.Is(err, unavailable) { |
| + t.Fatal("reduced live set on failed read", err) |
| + } |
| + data.FailOn = nil |
| + for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRelocating, hostclaim.KindRestoring} { |
| + o.Claim.Op.Kind = kind |
| + if _, err := p.View(t.Context(), o); err == nil { |
| + t.Fatal("move view enabled", kind) |
| + } |
| + } |
| +} |
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go| index 876dba8dc..c87c21f76 100644 |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| @@ -404,7 +404,10 @@ type productionClaimRegional struct { |
| } |
| func (p productionClaimRegional) View(ctx context.Context, o hostclaim.Observed) (park.View, error) { |
| - if p.Parks == nil || p.Parks.View == nil { |
| + if p.Parks == nil { |
| + return p.ordinaryView(ctx, o) |
| + } |
| + if p.Parks.View == nil { |
| return park.View{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 exact-version generation and files-manifest view"}} |
| } |
| actor, err := p.Identity.Verify(ctx) |
| @@ -454,7 +457,13 @@ func (p productionClaimRegional) Authorize(ctx context.Context, o hostclaim.Obse |
| if err != nil { |
| return err |
| } |
| - if o.Claim == nil || ob.Op != o.Claim.Op.ID || (ob.Owner != "box:"+a.Box && (ob.Owner != "region:"+ob.Region || !park.SameRegion(a.HomeRegion, ob.Region, a.Region))) || (ob.Box != "" && ob.Box != a.Box) || (ob.Region != "" && !park.SameRegion(a.HomeRegion, ob.Region, a.Region)) { |
| + ordinary := p.Parks == nil && ob.Kind == "park-generation" && strings.HasPrefix(ob.Op, "session:") |
| + if ordinary { |
| + if _, err := p.VerifyObligation(ctx, o, ob); err != nil { |
| + return err |
| + } |
| + } |
| + if o.Claim == nil || (!ordinary && ob.Op != o.Claim.Op.ID) || (ob.Owner != "box:"+a.Box && (ob.Owner != "region:"+ob.Region || !park.SameRegion(a.HomeRegion, ob.Region, a.Region))) || (ob.Box != "" && ob.Box != a.Box) || (ob.Region != "" && !park.SameRegion(a.HomeRegion, ob.Region, a.Region)) { |
| return hostbox.ErrChanged |
| } |
| found := false |
| @@ -466,6 +475,12 @@ func (p productionClaimRegional) Authorize(ctx context.Context, o hostclaim.Obse |
| if !found { |
| return hostbox.ErrChanged |
| } |
| + if ordinary { |
| + return nil |
| + } |
| + if p.Parks == nil && ob.Owner == "region:"+ob.Region && p.Local != nil && p.Local.assembly != nil && (p.Local.assembly.Guard.Root != nil || p.Local.assembly.Guard.FenceView != nil) { |
| + return p.authorizeOrdinaryRetirement(ctx, o, ob) |
| + } |
| return p.Local.Prepare(ctx, o) |
| } |
diff --git a/internal/host/claimretirerecovery_test.go b/internal/host/claimretirerecovery_test.go| index 4372042b9..353fd1b3b 100644 |
| --- a/internal/host/claimretirerecovery_test.go |
| +++ b/internal/host/claimretirerecovery_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -29,15 +30,7 @@ func retirementRecoveryRow(t *testing.T, f *recoveryFixture) registry.Route { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - // Local stop receipts bind operation, phase and obligation together. The |
| - // deletion fixture predates phased parking, so give this park its phase. |
| - o, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { |
| - c.Op.Phase = "park" |
| - return nil |
| - }) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| + |
| h := o.Claim.Holder |
| return registry.Route{Host: o.Host, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: h.Incarnation, ClaimStamp: h.Stamp, ClaimOwner: "installation", ClaimRowID: "route-1"}} |
| } |
| @@ -282,3 +275,67 @@ func TestClaimRetirementRefusesMissingOrAmbiguousLaunchJournal(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +func TestClaimRetirementAfterServingStopSameController(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + controller := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + admitted := controller.admit(t, f) |
| + if err := controller.wake.Stop(t.Context(), admitted); err != nil { |
| + t.Fatal("serving stop", err) |
| + } |
| + row := retirementRecoveryRow(t, f) |
| + target, err := controller.assembly.collectRetirementStop(t.Context(), row) |
| + if err != nil { |
| + t.Fatal("retirement after serving stop", err) |
| + } |
| + if target.Op != "delete-1" || target.Phase != "park" || target.ObligationID == "" || target.Session != admitted.Claim.Holder.Session { |
| + t.Fatalf("retirement tuple: %+v", target) |
| + } |
| +} |
| + |
| +func TestClaimRetirementCannotReplaceUnfinishedDrain(t *testing.T) { |
| + target := hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "en", BootID: "boot", ControllerID: "controller"} |
| + p, err := NewClaimProducers(target, "start", func(context.Context, hostguard.FenceTarget, string) error { return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + finish := make(chan struct{}) |
| + defer func() { |
| + select { |
| + case <-finish: |
| + default: |
| + close(finish) |
| + } |
| + }() |
| + if err = p.Launch(t.Context(), target, "writer", func(ctx context.Context) error { <-ctx.Done(); <-finish; return ctx.Err() }); err != nil { |
| + t.Fatal(err) |
| + } |
| + req := StopRequest{Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: 1, WriterSession: 1, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID} |
| + short, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) |
| + _, err = p.DrainClaim(short, req, "serving-fence") |
| + cancel() |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal("unfinished drain", err) |
| + } |
| + short, cancel = context.WithTimeout(t.Context(), 20*time.Millisecond) |
| + _, err = p.DrainClaim(short, req, "retirement-fence") |
| + cancel() |
| + if !errors.Is(err, ErrClaimStopIdentity) { |
| + t.Fatal("replaced unfinished drain", err) |
| + } |
| + close(finish) |
| + if _, err = p.DrainClaim(t.Context(), req, "serving-fence"); err != nil { |
| + t.Fatal(err) |
| + } |
| + ack, err := p.DrainClaim(t.Context(), req, "retirement-fence") |
| + if err != nil || ack.FenceID != "retirement-fence" { |
| + t.Fatal("completed drain retirement", ack, err) |
| + } |
| + req.WriterSession++ |
| + if _, err = p.DrainClaim(t.Context(), req, "foreign-fence"); !errors.Is(err, ErrClaimStopIdentity) { |
| + t.Fatal("foreign session rebound", err) |
| + } |
| + if err = p.Launch(t.Context(), target, "new-writer", func(context.Context) error { return nil }); err == nil { |
| + t.Fatal("retirement reopened producer admission") |
| + } |
| +} |
diff --git a/internal/host/claimstop_producers.go b/internal/host/claimstop_producers.go| index 854b67a6e..c9e3730bc 100644 |
| --- a/internal/host/claimstop_producers.go |
| +++ b/internal/host/claimstop_producers.go |
| @@ -25,6 +25,7 @@ type ClaimProducers struct { |
| target hostguard.FenceTarget |
| processStart string |
| fence string |
| + drained bool |
| // writersStopped closes admission to every producer but the retained |
| // ones (StopWriters); the full drain still cancels those. |
| writersStopped bool |
| @@ -74,11 +75,11 @@ func (p *ClaimProducers) DrainClaim(ctx context.Context, r StopRequest, fence st |
| return DrainAcknowledgement{}, ErrClaimStopUnavailable |
| } |
| p.mu.Lock() |
| - if p.target.Host != r.Host || p.target.Incarnation != r.Incarnation || p.target.Stamp != r.InstallationStamp || p.target.Session != r.WriterSession || p.target.Box != r.Box || p.target.Enrollment != r.Enrollment || p.target.BootID != r.BootID || p.target.ControllerID != r.ControllerID || (p.fence != "" && p.fence != fence) { |
| + if p.target.Host != r.Host || p.target.Incarnation != r.Incarnation || p.target.Stamp != r.InstallationStamp || p.target.Session != r.WriterSession || p.target.Box != r.Box || p.target.Enrollment != r.Enrollment || p.target.BootID != r.BootID || p.target.ControllerID != r.ControllerID || (p.fence != "" && p.fence != fence && !p.drained) { |
| p.mu.Unlock() |
| return DrainAcknowledgement{}, ErrClaimStopIdentity |
| } |
| - p.fence = fence |
| + p.fence, p.drained = fence, false |
| if p.closeWriters != nil { |
| if err := p.closeWriters(ctx, p.target); err != nil { |
| p.mu.Unlock() |
| @@ -110,6 +111,7 @@ func (p *ClaimProducers) DrainClaim(ctx context.Context, r StopRequest, fence st |
| for id := range p.workers { |
| ids = append(ids, id) |
| } |
| + p.drained = true |
| return DrainClaimAcknowledgement(fence, r.ControllerID, p.processStart, ids, p.completed), nil |
| } |
diff --git a/internal/host/plan14_delete_participation_test.go b/internal/host/plan14_delete_participation_test.go| new file mode 100644 |
| index 000000000..1a92a2f6d |
| --- /dev/null |
| +++ b/internal/host/plan14_delete_participation_test.go |
| @@ -0,0 +1,116 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +func TestPlan14OrdinaryDeleteParticipation(t *testing.T) { |
| + for _, mode := range []string{"live", "stopped", "parking", "retired", "serving stopped", "pending", "record present", "delete absent", "etag", "key", "enrollment", "foreign home", "session", "stamp", "op", "box", "inventory"} { |
| + t.Run(mode, func(t *testing.T) { |
| + f, root, iq := plan14InstallRegistration(t, hostclaim.KindServing, "") |
| + if _, err := root.commitRootInventory(t.Context(), iq); err != nil { |
| + t.Fatal(err) |
| + } |
| + actor, _ := root.InventoryActor(t.Context()) |
| + actor.FleetHomeRegion, actor.HomeRegion = "ie", "ie" |
| + root.InventoryActor = func(context.Context) (hostbox.Actor, error) { return actor, nil } |
| + o, err := hostclaim.Update(t.Context(), f.claims, f.read(), hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: f.read().Claim.Holder.Session, ObservedGeneration: f.read().Claim.Holder.Generation, Box: actor.Box, Region: "ie", Mode: "cold", NewGeneration: "delete-generation", KeepStamp: true})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + target := iq.Target |
| + target.Session, target.ClaimETag, target.ClaimGen = o.Claim.Holder.Session, o.ETag, o.Claim.Gen |
| + target.FenceID, target.Region, target.BootID, target.ControllerID, target.DirectoryRevision = "delete-stop", "ie", "boot", "controller", "1" |
| + if mode != "live" { |
| + if target, err = root.Stop.Coordinator.Guard.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mode != "pending" { |
| + at := time.Now().UTC() |
| + receipt := StopReceipt{V: 1, Kind: "stopped", ID: "delete-stop-receipt", FenceID: target.FenceID, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Region: target.Region, ClaimETag: target.ClaimETag, ClaimGen: target.ClaimGen, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, DirectoryRevision: target.DirectoryRevision, EvidenceAt: at} |
| + raw, _ := json.Marshal(receipt) |
| + if err = root.Stop.Coordinator.Guard.CompleteFence(t.Context(), target, raw, at, func(ctx context.Context, t hostguard.FenceTarget, b []byte, at time.Time) error { |
| + return verifyRetirementReceipt(ctx, hostguard.FenceReceipt{Target: t, Bytes: b, EvidenceAt: at}) |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + if mode != "serving stopped" { |
| + o, err = hostclaim.Update(t.Context(), f.claims, f.read(), hostclaim.BeginDelete("delete", "console", iq.RecordETag, time.Now(), []string{"ie"}, []string{actor.Box}, "")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if mode == "parking" || mode == "retired" || mode == "record present" { |
| + o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.ToScopedParking("delete", []string{"ie"}, []string{actor.Box}, f.newID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if mode == "parking" || mode == "retired" || mode == "delete absent" { |
| + if err = f.claims.Delete(t.Context(), iq.RecordKey); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if mode == "retired" { |
| + if err = root.Stop.Coordinator.Guard.RetireInstallationWith(t.Context(), o.Host, hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}, func(*hostclaim.Claim) error { return nil }, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if mode == "etag" || mode == "key" { |
| + o, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { c.Op.Intent[mode] = "forged"; return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + q := execlease.ExecutionRequest{Schema: 1, ID: "delete-participation", Operation: "executionBegin", Host: o.Host, Incarnation: target.Incarnation, Stamp: target.Stamp, Session: target.Session, Op: o.Claim.Op.ID} |
| + switch mode { |
| + case "enrollment": |
| + actor.Enrollment = "foreign" |
| + case "foreign home": |
| + actor.FleetHomeRegion = "uk" |
| + case "session": |
| + q.Session++ |
| + case "stamp": |
| + q.Stamp++ |
| + case "op": |
| + q.Op = "foreign" |
| + case "box": |
| + actor.Box = "foreign" |
| + case "inventory": |
| + if err = root.Journals.Inventory.WriteStopInventory(t.Context(), iq.Inventory); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + a, err := root.executionAuthority(t.Context(), q, execlease.ControllerProcess{}) |
| + want := mode == "live" || mode == "stopped" || mode == "parking" || mode == "retired" || mode == "delete absent" |
| + if want { |
| + if err != nil || !a.ParticipationOnly || a.Installation == "" || a.RecordRevision != iq.RecordETag { |
| + t.Fatalf("participation: %+v %v", a, err) |
| + } |
| + } else if err == nil { |
| + t.Fatalf("unsafe participation: %+v", a) |
| + } |
| + if mode == "retired" { |
| + root.Stop.Coordinator.Kernel = f.kernel |
| + f.kernel.noProcess = true |
| + if err := root.cleanupExecution(t.Context(), execlease.ExecutionRecord{Request: q, Authority: a}); err != nil || f.kernel.noProcessCalls != 1 { |
| + t.Fatalf("retired participation cleanup: %v, kernel calls=%d", err, f.kernel.noProcessCalls) |
| + } |
| + a.Enrollment = "foreign" |
| + if err := root.cleanupExecution(t.Context(), execlease.ExecutionRecord{Request: q, Authority: a}); err == nil || f.kernel.noProcessCalls != 1 { |
| + t.Fatalf("foreign retired cleanup: %v, kernel calls=%d", err, f.kernel.noProcessCalls) |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan14_generation_parked_linux_test.go b/internal/host/plan14_generation_parked_linux_test.go| index 46f9c608f..ba159b096 100644 |
| --- a/internal/host/plan14_generation_parked_linux_test.go |
| +++ b/internal/host/plan14_generation_parked_linux_test.go |
| @@ -455,3 +455,33 @@ func TestPlan14GenerationParkedKeepsReservation(t *testing.T) { |
| t.Fatal("payload retirement changed generation authority", got, err) |
| } |
| } |
| + |
| +func TestPlan14GenerationParkedNestedMountLocation(t *testing.T) { |
| + snapshot, _, root := generationParkedFixture(t) |
| + entry := &snapshot.Payloads[0].Inventory.Entries[2] |
| + entry.Identity.MountPoint = filepath.Join(root, "instances") |
| + in := snapshot.Payloads[0].Inventory |
| + inventory := hostbox.RetirementInventory{Host: in.Host, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.InstallationStamp}, Ownership: "owner"} |
| + for _, e := range in.Entries { |
| + inventory.Artifacts = append(inventory.Artifacts, hostbox.Artifact{Kind: hostbox.ArtifactKind(e.Kind), Root: e.ManagedRoot, Name: e.RelativePath, Owner: e.Ownership, RowID: e.RowIdentity, Identity: e.Identity, Dev: e.Device, Ino: e.Inode, Type: e.Type}) |
| + } |
| + j, err := hostbox.NewRetirementJournal("retire", inventory) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r := snapshot.Installations[0].Receipts[0] |
| + j.Target, j.StopReceipt, j.Complete = &r.Target, r.Bytes, true |
| + for i := range j.Steps { |
| + j.Steps[i].Done = true |
| + } |
| + snapshot.Retirements = []hostbox.RetirementJournal{j} |
| + physical, err := generationPhysicalPayloads(t.Context(), snapshot) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := physical[0].Inventory.Entries[2] |
| + want := filepath.Join("instances", ".parked", in.Incarnation, in.Host, j.ID, "data", entry.RelativePath) |
| + if got.RelativePath != want || got.Identity != entry.Identity { |
| + t.Fatalf("parked payload=%+v, want %s with unchanged identity", got, want) |
| + } |
| +} |
diff --git a/internal/host/plan14_lifecycle_retire_root_linux_test.go b/internal/host/plan14_lifecycle_retire_root_linux_test.go| index 198b32659..0d1080b81 100644 |
| --- a/internal/host/plan14_lifecycle_retire_root_linux_test.go |
| +++ b/internal/host/plan14_lifecycle_retire_root_linux_test.go |
| @@ -5,6 +5,7 @@ package host |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "encoding/json" |
| "errors" |
| "os" |
| "path/filepath" |
| @@ -15,10 +16,12 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| @@ -83,6 +86,46 @@ func lifecycleVerifyRetiredPins(t *testing.T, store release.Store, key ed25519.P |
| if !errors.Is(err, os.ErrNotExist) { |
| t.Fatal("deleted record retained", err) |
| } |
| + settleDeadline := time.Now().Add(time.Minute) |
| + for { |
| + raw, e := os.ReadFile(filepath.Join(rootparticipation.Namespace, lifecycleHost+".json")) |
| + lifecycleCheck(t, "final participation record", e) |
| + var record execlease.ExecutionRecord |
| + lifecycleCheck(t, "final participation decode", json.Unmarshal(raw, &record)) |
| + if record.Status == "settled" { |
| + break |
| + } |
| + if time.Now().After(settleDeadline) { |
| + t.Fatalf("retired participation remains %s", record.Status) |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + journals, err := hostbox.SavedRetirementJournals(claimRetireJournalDir("", "")) |
| + lifecycleCheck(t, "final retirement journals", err) |
| + if len(journals) == 0 { |
| + t.Fatal("retirement has no durable journal") |
| + } |
| + for _, j := range journals { |
| + if j.Inventory.Host != lifecycleHost || !j.Complete { |
| + t.Fatal("retirement journal incomplete", j.ID) |
| + } |
| + for _, step := range j.Steps { |
| + if !step.Done { |
| + t.Fatal("retirement artifact incomplete", step.Artifact) |
| + } |
| + if step.Artifact.Kind == hostbox.ArtifactPath { |
| + a := step.Artifact |
| + if _, e := os.Lstat(filepath.Join("/data", a.Name)); !errors.Is(e, os.ErrNotExist) { |
| + t.Fatal("retired source remains", e) |
| + } |
| + parked := filepath.Join(".parked", j.Inventory.Captured.Incarnation, lifecycleHost, j.ID, a.Root, a.Name) |
| + id, e := hostbox.CaptureInventoryIdentity(a.Identity.MountPoint, parked) |
| + if e != nil || !a.Identity.Same(id) { |
| + t.Fatal("parked artifact lost its recorded mount or identity", e) |
| + } |
| + } |
| + } |
| + } |
| source := productionRootGenerationPins() |
| lock, err := rootstate.Open(rootReleasePrivate, false, false) |
| lifecycleCheck(t, "release lock", err) |
diff --git a/internal/host/plan14_lifecycle_root_linux_test.go b/internal/host/plan14_lifecycle_root_linux_test.go| index a1b22b8ad..9464808ca 100644 |
| --- a/internal/host/plan14_lifecycle_root_linux_test.go |
| +++ b/internal/host/plan14_lifecycle_root_linux_test.go |
| @@ -38,6 +38,7 @@ import ( |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -118,7 +119,8 @@ type lifecycleOwners struct{ t *testing.T } |
| func (p lifecycleOwners) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| - identity := handoverRecoveryIdentity(p.t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Region: "ie", ControlBucket: "deployment"}) |
| + identity := handoverRecoveryIdentity(p.t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Region: "ie", FleetHomeRegion: "ie", ControlBucket: "deployment"}) |
| + identity.HomeRegion = "ie" |
| actor, err := identity.Verify(ctx) |
| if err != nil { |
| return ClaimOwnerBindings{}, err |
| @@ -155,7 +157,7 @@ func (p lifecycleOwners) OpenClaimOwners(ctx context.Context, store release.Stor |
| func lifecycleEnvironment(t *testing.T, endpoint string) { |
| t.Helper() |
| - for k, v := range map[string]string{"CARLOS_HOST_CLAIM": "1", "CARLOS_HOST_CLAIM_ORIGINS": "1", "CARLOS_MOVES_PHASE1": "", "CARLOS_REGION_HA": "", "CARLOS_DATA_DIR": "/data", "CARLOS_OPT_DIR": "/opt/carlos", "CARLOS_DEPLOYMENT_BUCKET": "", "CARLOS_DEPLOYMENT_DIR": lifecycleHome + "/deployment", "CARLOS_REGION": "ie", "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", "CARLOS_S3_ENDPOINT": endpoint, "AWS_ENDPOINT_URL_S3": endpoint, "CARLOS_RELEASE_PUBKEY": base64.StdEncoding.EncodeToString(ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)).Public().(ed25519.PublicKey))} { |
| + for k, v := range map[string]string{"CARLOS_HOST_CLAIM": "1", "CARLOS_HOST_CLAIM_ORIGINS": "1", "CARLOS_MOVES_PHASE1": "", "CARLOS_REGION_HA": "", "CARLOS_DATA_DIR": "/data", "CARLOS_OPT_DIR": "/opt/carlos", "CARLOS_DEPLOYMENT_BUCKET": "", "CARLOS_DEPLOYMENT_DIR": lifecycleHome + "/deployment", "CARLOS_REGION": "", "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", "CARLOS_S3_ENDPOINT": endpoint, "AWS_ENDPOINT_URL_S3": endpoint, "CARLOS_RELEASE_PUBKEY": base64.StdEncoding.EncodeToString(ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)).Public().(ed25519.PublicKey))} { |
| t.Setenv(k, v) |
| } |
| } |
| @@ -233,11 +235,12 @@ func lifecycleRun(t *testing.T) { |
| lifecycleEnvironment(t, server.URL) |
| store := release.NewDirStore(lifecycleHome + "/deployment") |
| key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| - identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Region: "ie", ControlBucket: "deployment"}) |
| + identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Region: "ie", FleetHomeRegion: "ie", ControlBucket: "deployment"}) |
| + identity.HomeRegion = "ie" |
| actor, err := identity.Verify(t.Context()) |
| lifecycleCheck(t, "identity", err) |
| if !resume { |
| - roster := hostactors.Roster{V: 1, DeploymentBucket: actor.ControlBucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), CensusPublicKey: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)).Public().(ed25519.PublicKey), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| + roster := hostactors.Roster{V: 1, HomeRegion: "ie", DeploymentBucket: actor.ControlBucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), CensusPublicKey: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)).Public().(ed25519.PublicKey), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| lifecycleCheck(t, "sign roster", err) |
| _, err = store.Put(t.Context(), hostactors.Key, raw, "") |
| @@ -280,7 +283,7 @@ func lifecycleRun(t *testing.T) { |
| } |
| now := time.Now().UTC() |
| - rec := provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "ie", CreatedAt: now} |
| + rec := provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "", CreatedAt: now} |
| completeCreate := func() error { return nil } |
| if !resume { |
| completeCreate = lifecycleCreate(t, store, key, actor, pointer, rec) |
| @@ -371,6 +374,9 @@ func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() er |
| time.Sleep(100 * time.Millisecond) |
| } |
| os.Remove("/run/carlos/lifecycle-result") |
| + if os.Getenv("PLAN14_LIFECYCLE_CRASH") == "retirement" { |
| + lifecycleCheck(t, "resume retirement checkpoint", os.WriteFile("/run/carlos/lifecycle-resume-retirement", []byte("ready"), 0644)) |
| + } |
| recoverControl() |
| out, err := exec.Command("systemctl", "start", "carlos-edge.service").CombinedOutput() |
| lifecycleCheck(t, string(out), err) |
| @@ -451,6 +457,7 @@ func lifecycleAgent(t *testing.T) { |
| }() |
| providers, err := assembly.Build(ctx) |
| lifecycleCheck(t, "agent build", err) |
| + lifecycleTraceRegional(t, &providers) |
| lifecycleTraceWake(t, assembly.Wake) |
| deadline := time.Now().Add(time.Minute) |
| for { |
| @@ -462,53 +469,71 @@ func lifecycleAgent(t *testing.T) { |
| } |
| time.Sleep(20 * time.Millisecond) |
| } |
| - cfg := provision.ReconcileConfig{Region: "ie", DataDir: "/data", Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }} |
| + cfg := provision.ReconcileConfig{Region: "", DataDir: "/data", Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }} |
| providers.Provision(&cfg) |
| - lifecycleCheck(t, "provision", provision.Reconcile(ctx, store, reg, cfg)) |
| - route, err := reg.Get(lifecycleHost) |
| - lifecycleCheck(t, "route", err) |
| - if route == nil { |
| - t.Fatal("create did not install route") |
| - } |
| - lifecycleCheck(t, "request staging", os.WriteFile("/run/carlos/lifecycle-stage", []byte("ready"), 0600)) |
| - stageDeadline := time.Now().Add(time.Minute) |
| - for { |
| - if _, err := os.Stat("/run/carlos/lifecycle-staged"); err == nil { |
| - break |
| + resumingRetirement := false |
| + if _, e := os.Stat("/run/carlos/lifecycle-resume-retirement"); e == nil { |
| + obs, e := hostclaim.Read(ctx, store, lifecycleHost) |
| + lifecycleCheck(t, "retirement recovery claim", e) |
| + if obs.Claim == nil || (!obs.Claim.Free() && obs.Claim.Op.Kind != hostclaim.KindParking) { |
| + t.Fatal("retirement recovery lost parking claim") |
| } |
| - if time.Now().After(stageDeadline) { |
| - t.Fatal("stage timeout") |
| + _, _, e = store.Get(ctx, provision.RecordKey("bab", "lifecycle", lifecycleHost)) |
| + if !errors.Is(e, os.ErrNotExist) { |
| + t.Fatal("retirement recovery record still present", e) |
| } |
| - time.Sleep(20 * time.Millisecond) |
| + resumingRetirement = true |
| } |
| - wakes := 2 |
| - if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" { |
| - wakes = 1 |
| - } |
| - for wake := 0; wake < wakes; wake++ { |
| - route, err = reg.Get(lifecycleHost) |
| - lifecycleCheck(t, "fresh route for wake", err) |
| + lifecycleCheck(t, "provision", provision.Reconcile(ctx, store, reg, cfg)) |
| + if !resumingRetirement { |
| + route, err := reg.Get(lifecycleHost) |
| + lifecycleCheck(t, "route", err) |
| if route == nil { |
| - t.Fatal("route disappeared before wake") |
| + t.Fatal("create did not install route") |
| } |
| - lifecycleWake(t, ctx, providers.Wake, assembly.Wake, *route) |
| - t.Logf("completed cold wake %d", wake+1) |
| - } |
| - if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" || os.Getenv("PLAN14_LIFECYCLE_PHASE") == "crash" { |
| - parkDeadline := time.Now().Add(time.Minute) |
| + lifecycleCheck(t, "request staging", os.WriteFile("/run/carlos/lifecycle-stage", []byte("ready"), 0600)) |
| + stageDeadline := time.Now().Add(time.Minute) |
| for { |
| - obs, err := hostclaim.Read(ctx, store, lifecycleHost) |
| - lifecycleCheck(t, "pre-delete observation", err) |
| - if len(obs.Claim.Obligations) == 0 { |
| + if _, err := os.Stat("/run/carlos/lifecycle-staged"); err == nil { |
| break |
| } |
| - if time.Now().After(parkDeadline) { |
| - t.Fatal("pre-delete obligations did not drain", obs.Claim.Obligations) |
| + if time.Now().After(stageDeadline) { |
| + t.Fatal("stage timeout") |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + wakes := 2 |
| + if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" { |
| + wakes = 1 |
| + } |
| + for wake := 0; wake < wakes; wake++ { |
| + route, err = reg.Get(lifecycleHost) |
| + lifecycleCheck(t, "fresh route for wake", err) |
| + if route == nil { |
| + t.Fatal("route disappeared before wake") |
| } |
| - if err := providers.Regional.(hostbox.RegionalWorker).Run(ctx); err != nil { |
| - t.Log("pre-delete parks", err) |
| + lifecycleWake(t, ctx, providers.Wake, assembly.Wake, *route) |
| + t.Logf("completed cold wake %d", wake+1) |
| + } |
| + } |
| + if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" || os.Getenv("PLAN14_LIFECYCLE_PHASE") == "crash" { |
| + if !resumingRetirement { |
| + parkDeadline := time.Now().Add(time.Minute) |
| + for { |
| + obs, err := hostclaim.Read(ctx, store, lifecycleHost) |
| + lifecycleCheck(t, "pre-delete observation", err) |
| + if len(obs.Claim.Obligations) == 0 { |
| + break |
| + } |
| + if time.Now().After(parkDeadline) { |
| + lifecycleTraceParkState(t, providers, obs) |
| + t.Fatal("pre-delete obligations did not drain", obs.Claim.Obligations) |
| + } |
| + if err := providers.Regional.(hostbox.RegionalWorker).Run(ctx); err != nil { |
| + t.Log("pre-delete parks", err) |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| } |
| - time.Sleep(100 * time.Millisecond) |
| } |
| lifecycleCheck(t, "request deletion", os.WriteFile("/run/carlos/lifecycle-delete", []byte("ready"), 0600)) |
| deadline := time.Now().Add(time.Minute) |
| @@ -686,9 +711,10 @@ func lifecycleCreate(t *testing.T, raw release.Store, key ed25519.PrivateKey, ac |
| start, err := control.RegisterStart(ctx, rec.Account, rec.App, appID, id, hostops.Registration{Kind: "create", Host: rec.Host, Owner: hostops.RegistrationOwner{Console: "console", Enrollment: strings.Repeat("d", 32), Controller: "console-controller"}, AppID: appID, Epoch: 1, Claim: hostops.SavedObservation(before)}, time.Now()) |
| lifecycleCheck(t, "register create", err) |
| rec.Acquisition = id |
| - body, err := json.Marshal(rec) |
| - lifecycleCheck(t, "encode create", err) |
| - record := hostops.Record{Holder: hostclaim.NewHolder{Host: rec.Host, Account: rec.Account, App: rec.App, Family: "instance", Key: provision.RecordKey(rec.Account, rec.App, rec.Host), Region: rec.Region, RecordRegion: rec.Region, CreatedAt: rec.CreatedAt, Acquisition: id}, Body: body} |
| + requested := rec |
| + requested.CreatedAt = time.Time{} |
| + record, err := hostops.PrepareInstance(requested, rec.CreatedAt, park.PlacementRegion(rec.Region)) |
| + lifecycleCheck(t, "prepare create record", err) |
| incarnation, err := record.Incarnation() |
| lifecycleCheck(t, "create incarnation", err) |
| lifecycleCheck(t, "bind registration", start.Bind(ctx, rec.CreatedAt, incarnation, time.Now())) |
| @@ -709,14 +735,14 @@ func TestPlan14LifecycleCreateController(t *testing.T) { |
| store := release.NewDirStore(t.TempDir()) |
| key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| actor := hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Namespace: "platform", Region: "ie", ControlBucket: "deployment", Cloud: hostbox.CloudIdentity{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}} |
| - roster := hostactors.Roster{V: 1, DeploymentBucket: actor.ControlBucket, Revision: 1, Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| + roster := hostactors.Roster{V: 1, HomeRegion: "ie", DeploymentBucket: actor.ControlBucket, Revision: 1, Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| lifecycleCheck(t, "sign roster", err) |
| _, err = store.Put(t.Context(), hostactors.Key, raw, "") |
| lifecycleCheck(t, "write roster", err) |
| platform := release.AdoptPlatform() |
| pointer := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: "v2.0.0", ReleaseTag: "v2.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v2.0.0", platform), SHA256: strings.Repeat("a", 64)}}}} |
| - lifecycleCreate(t, store, key, actor, pointer, provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "ie", CreatedAt: time.Now().UTC()}) |
| + lifecycleCreate(t, store, key, actor, pointer, provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "", CreatedAt: time.Now().UTC()}) |
| record, err := hostops.ReadRecord(t.Context(), store, provision.RecordKey("bab", "lifecycle", lifecycleHost)) |
| lifecycleCheck(t, "read created record", err) |
| if record.Holder.Acquisition == "" { |
| @@ -747,7 +773,7 @@ func lifecycleController(t *testing.T, raw release.Store, key ed25519.PrivateKey |
| data, err := hostops.NewObservedMutationBoundary(true, records, check, nil) |
| lifecycleCheck(t, "mutation boundary", err) |
| directory.Store = data |
| - authority := &hostops.ProductionAuthority{Store: data, Control: control, Directory: directory, ConsoleID: "console", Enrollment: console.Enrollment, Namespace: actor.Namespace, Signer: signer, Now: time.Now, Evidence: lifecycleActorEvidence{report, console, signed}} |
| + authority := &hostops.ProductionAuthority{HomeRegion: "ie", Store: data, Control: control, Directory: directory, ConsoleID: "console", Enrollment: console.Enrollment, Namespace: actor.Namespace, Signer: signer, Now: time.Now, Evidence: lifecycleActorEvidence{report, console, signed}} |
| controller := hostops.Controller{Store: data, Control: control, Authority: authority, Now: time.Now, NewID: func() string { |
| var id [24]byte |
| if _, err := rand.Read(id[:]); err != nil { |
| @@ -801,7 +827,9 @@ func TestPlan14LifecycleControlProcess(t *testing.T) { |
| done := make(chan error, 1) |
| go func() { done <- control.Serve(controlCtx, listener) }() |
| defer func() { cancelControl(); lifecycleCheck(t, "control shutdown", <-done) }() |
| - lifecycleCheck(t, "root recovery", rootHTTP.RecoverPending(ctx)) |
| + if err := rootHTTP.RecoverPending(ctx); err != nil { |
| + t.Log("root recovery pending", err) |
| + } |
| rootHTTP.Start(ctx) |
| lifecycleCheck(t, "controller ready", os.WriteFile("/run/carlos-control/lifecycle-ready", []byte("ready"), 0600)) |
diff --git a/internal/host/plan14_lifecycle_trace_root_linux_test.go b/internal/host/plan14_lifecycle_trace_root_linux_test.go| index 844a43fb2..af0985d7d 100644 |
| --- a/internal/host/plan14_lifecycle_trace_root_linux_test.go |
| +++ b/internal/host/plan14_lifecycle_trace_root_linux_test.go |
| @@ -9,10 +9,15 @@ import ( |
| "io" |
| "net/http" |
| "os/exec" |
| + "strings" |
| + "sync" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/park" |
| ) |
| type lifecycleControlTrace struct { |
| @@ -77,3 +82,60 @@ func (u lifecycleUnitsTrace) StartReserved(ctx context.Context, in instancectl.C |
| } |
| return got, err |
| } |
| + |
| +type lifecycleRegionalTrace struct { |
| + hostbox.RegionalEvidence |
| + t *testing.T |
| + once sync.Once |
| +} |
| + |
| +func (r *lifecycleRegionalTrace) VerifyObligation(ctx context.Context, o hostclaim.Observed, ob hostclaim.Obligation) (hostclaim.ParkScope, error) { |
| + scope, err := r.RegionalEvidence.(hostbox.RegionalObligationEvidence).VerifyObligation(ctx, o, ob) |
| + if err != nil { |
| + r.once.Do(func() { r.t.Logf("regional evidence: %v; obligation=%+v holder=%+v", err, ob, o.Claim.Holder) }) |
| + } |
| + return scope, err |
| +} |
| + |
| +func lifecycleTraceRegional(t *testing.T, p *ClaimProviders) { |
| + worker := p.Regional.(hostbox.RegionalWorker) |
| + worker.Regional.Engine.Now = func() time.Time { return time.Now().Add(park.SignatureAge + hostbox.RequestLimit + time.Second) } |
| + worker.Evidence = &lifecycleRegionalTrace{RegionalEvidence: worker.Evidence, t: t} |
| + view, authorize := worker.Regional.Engine.View, worker.Regional.Engine.Authorize |
| + var viewOnce, authorizeOnce sync.Once |
| + worker.Regional.Engine.View = func(ctx context.Context, o hostclaim.Observed) (park.View, error) { |
| + v, err := view(ctx, o) |
| + if err != nil { |
| + viewOnce.Do(func() { t.Log("regional view", err) }) |
| + } |
| + return v, err |
| + } |
| + worker.Regional.Engine.Authorize = func(ctx context.Context, o hostclaim.Observed, ob hostclaim.Obligation) error { |
| + err := authorize(ctx, o, ob) |
| + if err != nil { |
| + authorizeOnce.Do(func() { t.Log("regional authorization", err) }) |
| + } |
| + return err |
| + } |
| + p.Regional = worker |
| +} |
| + |
| +func lifecycleTraceParkState(t *testing.T, p ClaimProviders, o hostclaim.Observed) { |
| + engine := p.Regional.(hostbox.RegionalWorker).Regional.Engine |
| + v, err := engine.View(t.Context(), o) |
| + t.Logf("park view=%+v err=%v holder=%+v", v, err, o.Claim.Holder) |
| + if err != nil || o.Claim.Holder == nil { |
| + return |
| + } |
| + live, err := park.LiveGenerations(o.Claim, v, o.Claim.Holder.Location) |
| + t.Log("park live", live, err) |
| + versions, err := engine.Store.ListVersions(t.Context(), engine.Bucket, o.Claim.Holder.Location+"/") |
| + t.Log("park listing", err) |
| + for _, version := range versions { |
| + if strings.HasSuffix(version.Key, "/lock.json") { |
| + continue |
| + } |
| + generation, known := v.GenerationOf(park.Ref{Key: version.Key, VersionID: version.VersionID}) |
| + t.Logf("park version=%+v generation=%q known=%v", version, generation, known) |
| + } |
| +} |
diff --git a/internal/host/rootexecution_authority.go b/internal/host/rootexecution_authority.go| index 66ea5eed9..b587d6f53 100644 |
| --- a/internal/host/rootexecution_authority.go |
| +++ b/internal/host/rootexecution_authority.go |
| @@ -5,6 +5,7 @@ import ( |
| "database/sql" |
| "errors" |
| "fmt" |
| + "os" |
| "reflect" |
| "slices" |
| "strconv" |
| @@ -13,6 +14,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/rootparticipation" |
| ) |
| @@ -44,13 +46,40 @@ func (h ClaimRootHTTP) executionAuthority(ctx context.Context, q execlease.Execu |
| if c.Op.Kind == hostclaim.KindServing && execlease.PreinstallationParticipant(c, actor.Box, actor.Enrollment) != nil { |
| return out, hostbox.ErrChanged |
| } |
| + retirement := c.Op.Kind == hostclaim.KindDeleting || c.Op.Kind == hostclaim.KindParking |
| + if retirement { |
| + home, homeErr := park.VerifiedHome(actor.FleetHomeRegion, actor.HomeRegion, actor.Region, c.Op.Intent["home_region"]) |
| + if homeErr != nil || actor.FleetHomeRegion == "" || actor.Region != actor.FleetHomeRegion || c.Holder.Family != "instance" || c.Holder.Location != "instances/"+c.Host+"/"+c.Holder.Incarnation || c.Op.Exec != actor.Box || c.Op.Intent["key"] != c.Holder.Key || c.Op.Intent["etag"] == "" { |
| + return out, hostbox.ErrChanged |
| + } |
| + for _, admission := range c.Holder.Admissions { |
| + if admission.Box != actor.Box || !park.SameRegion(home, admission.Region, actor.Region) || (admission.PredecessorRegion != "" && !park.SameRegion(home, admission.PredecessorRegion, actor.Region)) { |
| + return out, hostbox.ErrChanged |
| + } |
| + } |
| + } |
| _, etag, err := rootClaimRecord(ctx, h.Stop.Coordinator.Evidence, c, h.Roots["data"]) |
| + if retirement && (c.Op.Kind == hostclaim.KindParking || errors.Is(err, os.ErrNotExist)) { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return out, hostbox.ErrChanged |
| + } |
| + if _, _, absent := h.Stop.Coordinator.Evidence.Get(ctx, c.Holder.Key); !errors.Is(absent, os.ErrNotExist) { |
| + return out, hostbox.ErrChanged |
| + } |
| + etag, err = c.Op.Intent["etag"], nil |
| + } |
| if err != nil { |
| return out, err |
| } |
| + if retirement && etag != c.Op.Intent["etag"] { |
| + return out, hostbox.ErrChanged |
| + } |
| out = execlease.ExecutionAuthority{Box: actor.Box, Enrollment: actor.Enrollment, Account: c.Holder.Account, App: c.Holder.App, ClaimRevision: obs.ETag, RecordRevision: etag, DirectoryRevision: strconv.FormatUint(actor.Revision, 10), Kind: c.Op.Kind, Phase: c.Op.Phase} |
| captured := hostguard.Captured{Incarnation: q.Incarnation, Stamp: q.Stamp} |
| view, err := h.Stop.Coordinator.Guard.Root.View(ctx, q.Host, captured) |
| + if retirement && err != nil { |
| + return out, err |
| + } |
| if errors.Is(err, hostguard.ErrStampMismatch) { |
| return h.retainedExecutionSource(ctx, q, actor, c, out) |
| } |
| @@ -111,17 +140,27 @@ func (h ClaimRootHTTP) executionAuthority(ctx context.Context, q execlease.Execu |
| if in.Host != q.Host || in.Incarnation != q.Incarnation || in.Stamp != q.Stamp || in.Account != out.Account || in.App != out.App || in.Record != c.Holder.Key || in.Box != actor.Box || in.Enrollment != actor.Enrollment || in.Migration == 0 || in.InventoryRevision == 0 { |
| return out, hostbox.ErrChanged |
| } |
| - if view.Barrier != nil || slices.Contains(view.Retired, captured) { |
| + if view.Barrier != nil || (slices.Contains(view.Retired, captured) && !(retirement && c.Op.Kind == hostclaim.KindParking)) { |
| return out, hostguard.ErrFenced |
| } |
| denied := func(t hostguard.FenceTarget) bool { |
| return t.Incarnation == q.Incarnation && t.Stamp == q.Stamp && t.Session == q.Session |
| } |
| if view.Pending != nil && denied(*view.Pending) { |
| - return out, hostguard.ErrFenced |
| + completed := false |
| + if retirement { |
| + for _, receipt := range view.Receipts { |
| + if receipt.Target == *view.Pending && verifyRetirementReceipt(ctx, receipt) == nil { |
| + completed = true |
| + } |
| + } |
| + } |
| + if !completed { |
| + return out, hostguard.ErrFenced |
| + } |
| } |
| for _, receipt := range view.Receipts { |
| - if denied(receipt.Target) { |
| + if denied(receipt.Target) && !retirement { |
| return out, hostguard.ErrFenced |
| } |
| } |
| @@ -132,6 +171,7 @@ func (h ClaimRootHTTP) executionAuthority(ctx context.Context, q execlease.Execu |
| if revision != in.InventoryRevision { |
| return out, hostbox.ErrChanged |
| } |
| + out.ParticipationOnly = retirement |
| out.Migration = in.Migration |
| out.Installation = fmt.Sprintf("%s/%d/%s/%s", in.Incarnation, in.Stamp, in.Box, in.Enrollment) |
| return out, nil |
diff --git a/internal/host/rootexecution_settle.go b/internal/host/rootexecution_settle.go| index 375b769aa..b35e7c1cc 100644 |
| --- a/internal/host/rootexecution_settle.go |
| +++ b/internal/host/rootexecution_settle.go |
| @@ -43,9 +43,20 @@ func (h ClaimRootHTTP) cleanupExecution(ctx context.Context, r execlease.Executi |
| var launches []hostbox.LaunchInventory |
| captured := hostguard.Captured{Incarnation: q.Incarnation, Stamp: q.Stamp} |
| if a.ParticipationOnly && a.Installation != "" { |
| - captured, err = guard.Capture(q.Host) |
| - if err != nil { |
| - return err |
| + if a.Kind == hostclaim.KindDeleting || a.Kind == hostclaim.KindParking { |
| + view, viewErr := guard.Root.View(ctx, q.Host, captured) |
| + if viewErr != nil { |
| + return viewErr |
| + } |
| + in := view.Installation |
| + if view.Barrier != nil || in.Incarnation != captured.Incarnation || in.Stamp != captured.Stamp || in.Box != a.Box || in.Enrollment != a.Enrollment || in.Account != a.Account || in.App != a.App || in.Migration != a.Migration { |
| + return execlease.ErrLost |
| + } |
| + } else { |
| + captured, err = guard.Capture(q.Host) |
| + if err != nil { |
| + return err |
| + } |
| } |
| if captured.Incarnation != q.Incarnation || fmt.Sprintf("%s/%d/%s/%s", captured.Incarnation, captured.Stamp, a.Box, a.Enrollment) != a.Installation { |
| return execlease.ErrLost |
diff --git a/internal/host/rootgeneration_parked.go b/internal/host/rootgeneration_parked.go| index f989ccd90..30f7f3721 100644 |
| --- a/internal/host/rootgeneration_parked.go |
| +++ b/internal/host/rootgeneration_parked.go |
| @@ -177,7 +177,17 @@ func generationPhysicalPayloads(ctx context.Context, snapshot generationInventor |
| if !filepath.IsLocal(e.RelativePath) || filepath.Clean(e.RelativePath) != e.RelativePath { |
| return nil, os.ErrPermission |
| } |
| - in.Entries[n].RelativePath = filepath.Join(".parked", in.Incarnation, in.Host, id, e.ManagedRoot, e.RelativePath) |
| + roots := map[string]string{"data": dataDir()} |
| + parker := hostbox.TypedParker{CapturePersistent: true, Roots: roots, ParkedRoots: roots} |
| + parkRoot, err := parker.ParkedArtifactRoot(hostbox.Artifact{Root: e.ManagedRoot, Name: e.RelativePath, Identity: e.Identity}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + rel, err := filepath.Rel(dataDir(), parkRoot) |
| + if err != nil || !filepath.IsLocal(rel) { |
| + return nil, os.ErrPermission |
| + } |
| + in.Entries[n].RelativePath = filepath.Join(rel, ".parked", in.Incarnation, in.Host, id, e.ManagedRoot, e.RelativePath) |
| } |
| } |
| } |
diff --git a/internal/hostbox/renewing.go b/internal/hostbox/renewing.go| index 4c5ddbf0d..a5a02b022 100644 |
| --- a/internal/hostbox/renewing.go |
| +++ b/internal/hostbox/renewing.go |
| @@ -82,7 +82,7 @@ func (h *renewingHeld) Release(ctx context.Context) error { |
| h.once.Do(func() { |
| h.cancel() |
| <-h.done |
| - bounded, cancel := context.WithTimeout(ctx, RequestLimit) |
| + bounded, cancel := context.WithTimeout(context.WithoutCancel(ctx), RequestLimit) |
| defer cancel() |
| h.releaseErr = h.h.Release(bounded) |
| }) |
diff --git a/internal/hostbox/renewing_test.go b/internal/hostbox/renewing_test.go| index 0c549f3df..c8500c020 100644 |
| --- a/internal/hostbox/renewing_test.go |
| +++ b/internal/hostbox/renewing_test.go |
| @@ -3,6 +3,7 @@ package hostbox |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| "testing" |
| "time" |
| ) |
| @@ -70,3 +71,54 @@ func TestAggregateContextCancelsOnAnyConstituent(t *testing.T) { |
| t.Fatal("aggregate loss not propagated") |
| } |
| } |
| + |
| +type releaseContextLease struct { |
| + renewableFake |
| + check func(context.Context) error |
| +} |
| + |
| +func (h *releaseContextLease) Release(ctx context.Context) error { h.released++; return h.check(ctx) } |
| +func TestRenewingReleaseSurvivesItsOwnCancellation(t *testing.T) { |
| + for _, cancelled := range []bool{false, true} { |
| + t.Run(fmt.Sprint(cancelled), func(t *testing.T) { |
| + h := &releaseContextLease{renewableFake: renewableFake{lost: make(chan struct{})}} |
| + l := RenewingLeases{Region: "eu-west-1", Open: func(context.Context, string, string, string) (Renewable, error) { return h, nil }} |
| + held, err := l.Acquire(t.Context(), "bucket", "instances/x.example/inc") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithCancel(held.(interface { |
| + Context(context.Context) context.Context |
| + }).Context(t.Context())) |
| + defer cancel() |
| + if cancelled { |
| + cancel() |
| + } |
| + h.check = func(cleanup context.Context) error { |
| + if err := cleanup.Err(); err != nil { |
| + return err |
| + } |
| + deadline, ok := cleanup.Deadline() |
| + if !ok || time.Until(deadline) > RequestLimit || time.Until(deadline) <= 0 { |
| + t.Fatal("release is not request bounded") |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + case <-time.After(time.Second): |
| + t.Fatal("work context still active during release") |
| + } |
| + return cleanup.Err() |
| + } |
| + if err := held.Release(ctx); err != nil { |
| + t.Fatal("release cancelled itself", err) |
| + } |
| + held.Close() |
| + if h.released != 1 { |
| + t.Fatalf("released %d times", h.released) |
| + } |
| + if err := held.Check(t.Context()); err == nil { |
| + t.Fatal("released lease still grants work") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostbox/typedpark_linux.go b/internal/hostbox/typedpark_linux.go| index 26fee88f8..0d5af75c4 100644 |
| --- a/internal/hostbox/typedpark_linux.go |
| +++ b/internal/hostbox/typedpark_linux.go |
| @@ -95,12 +95,9 @@ func (p TypedParker) Park(ctx context.Context, id string, a Artifact) error { |
| return ErrChanged |
| } |
| dstRel := filepath.Join(".parked", p.Incarnation, p.Host, p.Retirement, a.Root, a.Name) |
| - parkedRoot := p.ParkedRoots[a.Root] |
| - if parkedRoot == "" { |
| - parkedRoot = p.ParkedRoot |
| - } |
| - if parkedRoot == "" { |
| - return ErrTypedInventory |
| + parkedRoot, err := p.ParkedArtifactRoot(a) |
| + if err != nil { |
| + return err |
| } |
| dst, err := openParent(parkedRoot, dstRel, true) |
| if err != nil { |
| @@ -128,12 +125,9 @@ func (p TypedParker) Park(ctx context.Context, id string, a Artifact) error { |
| func (p TypedParker) verifyDestination(a Artifact, srcParent int) error { |
| rel := filepath.Join(".parked", p.Incarnation, p.Host, p.Retirement, a.Root, a.Name) |
| - parkedRoot := p.ParkedRoots[a.Root] |
| - if parkedRoot == "" { |
| - parkedRoot = p.ParkedRoot |
| - } |
| - if parkedRoot == "" { |
| - return ErrTypedInventory |
| + parkedRoot, err := p.ParkedArtifactRoot(a) |
| + if err != nil { |
| + return err |
| } |
| fd, err := openParent(parkedRoot, rel, false) |
| if err != nil { |
diff --git a/internal/hostbox/typedpark_linux_test.go b/internal/hostbox/typedpark_linux_test.go| index a40c1a110..aa25353a6 100644 |
| --- a/internal/hostbox/typedpark_linux_test.go |
| +++ b/internal/hostbox/typedpark_linux_test.go |
| @@ -137,3 +137,36 @@ func TestTypedParkerRetryAfterRenameFsyncFailureSyncsBothParents(t *testing.T) { |
| t.Fatalf("sync calls=%d, want initial and replay", calls) |
| } |
| } |
| + |
| +func TestTypedParkerKeepsRecordedNestedMount(t *testing.T) { |
| + p := TypedParker{CapturePersistent: true, Roots: map[string]string{"data": "/data"}, ParkedRoots: map[string]string{"data": "/data"}} |
| + for _, tc := range []struct{ name, mount, artifact, want string }{ |
| + {"bind mount", "/data/instances", "instances/shop.example", "/data/instances"}, |
| + {"root mount", "/data", "instances/shop.example", "/data"}, |
| + {"ancestor mount", "/", "instances/shop.example", "/data"}, |
| + {"absent legacy mount", "", "instances/shop.example", "/data"}, |
| + {"mount itself", "/data/instances", "instances", ""}, |
| + {"foreign mount", "/elsewhere", "instances/shop.example", ""}, |
| + {"prefix collision", "/data/instance", "instances/shop.example", ""}, |
| + {"unclean mount", "/data/../data/instances", "instances/shop.example", ""}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + a := Artifact{Root: "data", Name: tc.artifact} |
| + a.Identity.MountPoint = tc.mount |
| + got, err := p.ParkedArtifactRoot(a) |
| + if got != tc.want || (err != nil) != (tc.want == "") { |
| + t.Fatalf("root=%q error=%v, want %q", got, err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestTypedParkerLegacyDestinationUnchanged(t *testing.T) { |
| + p := TypedParker{Roots: map[string]string{"data": "/data"}, ParkedRoots: map[string]string{"data": "/data"}} |
| + a := Artifact{Root: "data", Name: "instances/shop.example"} |
| + a.Identity.MountPoint = "/data/instances" |
| + root, err := p.ParkedArtifactRoot(a) |
| + if err != nil || root != "/data" { |
| + t.Fatalf("legacy destination=%q error=%v", root, err) |
| + } |
| +} |
diff --git a/internal/hostbox/typedpark_path.go b/internal/hostbox/typedpark_path.go| new file mode 100644 |
| index 000000000..b93e70710 |
| --- /dev/null |
| +++ b/internal/hostbox/typedpark_path.go |
| @@ -0,0 +1,37 @@ |
| +package hostbox |
| + |
| +import "path/filepath" |
| + |
| +func (p TypedParker) ParkedArtifactRoot(a Artifact) (string, error) { |
| + root := p.ParkedRoots[a.Root] |
| + if root == "" { |
| + root = p.ParkedRoot |
| + } |
| + if root == "" { |
| + return "", ErrTypedInventory |
| + } |
| + source := p.Roots[a.Root] |
| + mount := a.Identity.MountPoint |
| + if !p.CapturePersistent || root != source || mount == "" { |
| + return root, nil |
| + } |
| + if !filepath.IsAbs(mount) || filepath.Clean(mount) != mount { |
| + return "", ErrTypedInventory |
| + } |
| + rel, err := filepath.Rel(source, mount) |
| + if err != nil { |
| + return "", ErrTypedInventory |
| + } |
| + if managedName(rel) { |
| + inside, err := filepath.Rel(mount, filepath.Join(source, a.Name)) |
| + if err != nil || !managedName(inside) { |
| + return "", ErrTypedInventory |
| + } |
| + return mount, nil |
| + } |
| + ancestor, err := filepath.Rel(mount, source) |
| + if err != nil || (ancestor != "." && !managedName(ancestor)) { |
| + return "", ErrTypedInventory |
| + } |
| + return root, nil |
| +} |
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go| index e5757972f..bc547f644 100644 |
| --- a/internal/hostclaim/delete.go |
| +++ b/internal/hostclaim/delete.go |
| @@ -63,6 +63,9 @@ func ToParking(opID string, allRegions, allBoxes []string, newID func() string) |
| regions, boxes := WriteSet(c, allRegions, allBoxes) |
| appendParkObligations(c, opID, regions, boxes, newID) |
| retire(c, c.Holder.Incarnation) |
| + if c.Op.Kind == KindDeleting && c.Op.Phase == "" { |
| + c.Op.Phase = "park" |
| + } |
| c.Op.Kind = KindParking |
| return nil |
| } |
diff --git a/internal/hostclaim/delete_test.go b/internal/hostclaim/delete_test.go| index a86a345d0..213b19507 100644 |
| --- a/internal/hostclaim/delete_test.go |
| +++ b/internal/hostclaim/delete_test.go |
| @@ -201,3 +201,13 @@ func TestIncompleteAdoptionPreservesParkingTargets(t *testing.T) { |
| t.Fatalf("not free after all chores: %+v", o.Claim) |
| } |
| } |
| + |
| +func TestOrdinaryDeleteProducesPhasedParking(t *testing.T) { |
| + c := &Claim{Host: "app.example", Holder: &Holder{Incarnation: "inc", Location: "instances/app.example/inc"}, Op: Op{Kind: KindDeleting, ID: "delete"}, Touched: Touched{Complete: true, Boxes: []string{"box"}}} |
| + if err := ToParking("delete", nil, nil, func() string { return "id" })(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Kind != KindParking || c.Op.Phase != "park" || len(c.Obligations) != 1 || c.Obligations[0].Kind != "park-local" { |
| + t.Fatalf("ordinary parking stop authority: %+v", c) |
| + } |
| +} |
diff --git a/internal/park/remote.go b/internal/park/remote.go| index e2e386acf..4e63ee962 100644 |
| --- a/internal/park/remote.go |
| +++ b/internal/park/remote.go |
| @@ -380,6 +380,9 @@ func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, he |
| if o.Kind == "park-generation" && g != o.Generation { |
| continue |
| } |
| + if o.Kind == "park-generation" && !live[g] && v.Pinned[ref(x)] && v.FilePins[ref(x)] && strings.HasPrefix(x.Key, obs.Claim.Holder.Location+"/files/") { |
| + continue |
| + } |
| if live[g] || v.Pinned[ref(x)] || ref(x) == v.Marker { |
| return obs, v, nil, ErrUnsafe |
| } |
diff --git a/internal/park/sweep.go b/internal/park/sweep.go| index db8d5c7d4..a27ddf78f 100644 |
| --- a/internal/park/sweep.go |
| +++ b/internal/park/sweep.go |
| @@ -28,6 +28,7 @@ type View struct { |
| MarkerGeneration string |
| Marker Ref |
| Pinned map[Ref]bool |
| + FilePins map[Ref]bool |
| GenerationOf func(Ref) (string, bool) |
| } |
| type Alert struct{ Location, Reason string } |
| @@ -64,6 +65,9 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er |
| if !ok { |
| return nil, ErrUnsafe |
| } |
| + if v.FilePins[r] && strings.HasPrefix(r.Key, c.Holder.Location+"/files/") { |
| + continue |
| + } |
| live[generation(g)] = true |
| } |
| } |
diff --git a/internal/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go| index 72edd0b0b..6284b9293 100644 |
| --- a/internal/provision/plan14_p3_census_test.go |
| +++ b/internal/provision/plan14_p3_census_test.go |
| @@ -23,6 +23,12 @@ func plan14P3WriteCensusInventory() []censusEntry { |
| entries := plan14EWriteCensusInventory() |
| kept := make([]censusEntry, 0, len(entries)) |
| for _, e := range entries { |
| + if e.File == "internal/host/rootexecution_authority.go" && e.Unit == "ClaimRootHTTP.executionAuthority" { |
| + continue |
| + } |
| + if e.File == "internal/host/claimproduction.go" && e.Unit == "productionClaimRegional.View" { |
| + continue |
| + } |
| if e.File == "internal/host/rootgenerations_launch.go" && e.Unit == "rootGenerationSource.resolve" { |
| continue |
| } |
| @@ -44,6 +50,9 @@ func plan14P3WriteCensusInventory() []censusEntry { |
| kept = append(kept, e) |
| } |
| return append(kept, |
| + censusEntry{"internal/host/rootexecution_authority.go", "ClaimRootHTTP.executionAuthority", "key-site", "key:RecordKey,lit:instances", "eff2c5806bff1c3d4beec4f54513b71e3890a4970b4b03d1f6a200db0146109e"}, |
| + censusEntry{"internal/host/claimproduction.go", "productionClaimRegional.View", "reference", "lit:instances", "bcefe9f8fd6420e795ef95a6170fbad1030e29f2261bcc2d677f76b6f6d577ce"}, |
| + censusEntry{"internal/host/claimordinaryparks.go", "productionClaimRegional.ordinaryHome", "reference", "lit:instances", "fa96f75f8af5fd1e3ba3dff81e27ac0fb7c6029663472292d698bfddaec99fe8"}, |
| censusEntry{"internal/host/rootgeneration_inventory.go", "generationReconciler.reconcile", "key-site", "key:RecordKey", "23995980eb4aa9c02618ce649b583344fc80ee1ee193ff84fb9a46f49d33ce68"}, |
| censusEntry{"internal/host/rootgeneration_requirements.go", "generationRequiredInventory", "key-site", "key:RecordKey", "3c0eb88abc5680c67191d1e848e93ba528c66e81bcd521577098ce761fb827e0"}, |
| censusEntry{"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "20ed721cbcaccb6ab813a0d317542a43fe057544811920ba43ac6431c3e73000"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv| index a8a64020c..1a33c1993 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-p3.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-p3.tsv |
| @@ -270,9 +270,9 @@ internal/hostbox/inventory_identity.go OpenInventoryFile authority C,E2,G rev3=P |
| internal/hostbox/inventory_identity.go VerifyInventoryFile authority C,E2,G rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=requires persistent root evidence and compares filesystem handle, ownership and stable mount attachment across boots;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Stat@81 965a8506c8255b8961a316c80f8fac4014933125e1507fa6e42121e9d3b3bb1b |
| internal/hostbox/reconcile_adapter.go ReconcileAdapter.Retire authority C,E2 rev3=PROPOSED-p3;run=13;identity=agent uid carlos; final retirement effects uid 0;basis=agent initiates retirement through root; root revalidates exact retained installation and persistent artifacts before destructive effects registry:ClaimIncarnation@1309,registry:ClaimIncarnation@322,registry:ClaimIncarnation@3614,registry:ClaimIncarnation@3636,registry:ClaimOwner@1366,registry:ClaimOwner@1434,registry:ClaimOwner@2387,registry:ClaimOwner@2793,registry:ClaimOwner@3694,registry:ClaimOwner@3710,registry:ClaimOwner@3948,registry:ClaimOwnership@147,registry:ClaimRowID@3728,registry:ClaimRowID@3971,registry:ClaimStamp@1338,registry:ClaimStamp@362,registry:ClaimStamp@3660,registry:ClaimStamp@3676,registry:Host@1257,registry:Host@2292,registry:Host@2838,registry:Host@288,registry:Host@2895,registry:Host@3533,registry:Host@3916,registry:Name@3933,registry:Registration@3750,registry:Registration@3992,whole:ClaimOwnership@151,whole:Route@1069,whole:Route@1152,whole:Route@231,whole:Timer@3599 161f3654abb12c42f016704228b550d132c4e9da3d29906db4f908dc40994e4d |
| internal/hostbox/typedpark_linux.go TypedParker.CaptureArtifact authority C,J2,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=captures filesystem handle, ownership and stable mount attachment when the root caller requests protected artifact capture;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Fstatat@440 8388e25cc65bd51896aef372e76dd9dd66947b3e67bbca0b76461b10742e17a2 |
| -internal/hostbox/typedpark_linux.go TypedParker.Park authority C,J2,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=requires persistent evidence for root-protected retirement and verifies source or replay destination before rename;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Fstat@1253,file:Fstat@1322,file:Fstatat@587 3b39fee2a35d1d711dbc59fe4ec0a091924d909547ea2303885b2cc8249e132b |
| +internal/hostbox/typedpark_linux.go TypedParker.Park authority C,J2,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=requires persistent evidence for root-protected retirement and verifies source or replay destination before rename;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures;rev3=PROPOSED-p3;run=21;basis=exact root-recorded artifact parking and replay remain in the recorded nested mount; persistent identity and no-replace retained;rev3=PROPOSED-p3;run=21;basis=shared recorded-mount location for typed retirement and generation pin verification; root inventory and completed receipt required file:Fstat@1186,file:Fstat@1255,file:Fstatat@587 ef55e912ee35ffd8d87e193e45fba6a98264d3090c221b697d34b2448e652d12 |
| internal/hostbox/typedpark_linux.go TypedParker.VerifyEmptyDirectory authority C,J2,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=requires protected persistent identity before using emptiness to retire an intended directory;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Fstat@661,file:Openat@439,file:Readdirnames@840 959832873e4daf74fbfa86d6e8139cc94d506545b59ef2dd1fa5059ac3c2374b |
| -internal/hostbox/typedpark_linux.go TypedParker.verifyDestination authority C,J2,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=checks parked artifact replay using persistent evidence under descriptor-held ancestry;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Fstatat@441 03ee911c7ad44442df0189fef46af32746dbb2ee816bac2601056ae6074e410c |
| +internal/hostbox/typedpark_linux.go TypedParker.verifyDestination authority C,J2,J4 rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=checks parked artifact replay using persistent evidence under descriptor-held ancestry;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures;rev3=PROPOSED-p3;run=21;basis=exact root-recorded artifact parking and replay remain in the recorded nested mount; persistent identity and no-replace retained;rev3=PROPOSED-p3;run=21;basis=shared recorded-mount location for typed retirement and generation pin verification; root inventory and completed receipt required file:Fstatat@374 25463df3cfe25c33a211327ce452b63501ae9230701b6790cb70d083a185a4fc |
| internal/hostbox/typedpark_linux.go sameArtifactAt authority C,E2,G rev3=PROPOSED-p3;identity=root claim retirement or offline migration uid 0; shared helpers also run for legacy agent requests without root authority;basis=verifies retained typed artifacts through descriptor-relative opens and persistent identity before any retirement effect;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures a0e3b899df85823cc4549d49f6577a7eff1549b7a0e4f1b53f4a23573ee4b948 |
| internal/rollout/rootfailures.go LegacyRetryIdentity hint E3 rev3=PROPOSED-p3;identity=root adoption retry verifier uid 0, reading service-owned deny-only bytes;basis=extracts only the old service book identity and epoch for a local mirror acknowledgement; it cannot grant a root retry c3a6b3d17bbab8b0070ebf317fc54c96f6e781aba9eba97c38c81d06c6f9430d |
| internal/host/claimstop_kernel_linux.go LinuxStopKernel.recoverAfterReboot authority C,J4 rev3=PROPOSED-p3;identity=root claim/adoption controller uid 0;basis=rechecks the persisted inventory filesystem before rebasing deny-only descriptor identities into the current boot; old process and cgroup identities remain boot-scoped file:ReadFile@833 57ca5033f471f8390813576f7159f5b5188c613d4df91fb1779322710f1832e7 |
| @@ -334,7 +334,7 @@ internal/host/rootgeneration_inventory.go generationReconciler.reconcile authori |
| internal/host/rootgeneration_requirements.go generationRequiredInventory authority E2 rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=active installations require canonical deployment records; retired exact root tuples use retained artifacts and verified completed retirement journals without requiring deleted remote records; local obligations and pins remain 3c0eb88abc5680c67191d1e848e93ba528c66e81bcd521577098ce761fb827e0 |
| internal/host/rootgeneration_retirement.go generationRetirement.closure authority E2 rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=root installation and exact controller identity bind retirement; pre-intention cancellation requires no process and irreversible writer closure, followed by the existing independent kernel verifier and durable close/retirement writes 1035a55fba521c7c81631d14084684e2dd4cb94703a7604d982be880bcea0cdf |
| internal/host/rootgeneration_inventory.go type generationInventory authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner 42a3affbf3d0da492f0326545305a3d996841cd36c0e3f4373d057abc2e7374f |
| -internal/host/rootgeneration_parked.go generationPhysicalPayloads authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner 6ea8bab62e2bd9fc33e64193a47149e809423967162604f824b4a6b18bd573f1 |
| +internal/host/rootgeneration_parked.go generationPhysicalPayloads authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner;rev3=PROPOSED-p3;run=21;basis=shared recorded-mount location for typed retirement and generation pin verification; root inventory and completed receipt required;basis=recorded mount relocation only for CapturePersistent root-protected artifacts; legacy destinations unchanged 67aca6be358b8f87a98c72e03a65d2dcd240cface604b17a058d7360a883e382 |
| internal/host/rootgeneration_parked.go generationRetirementRecords authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner file:Read@728 65cc967484b693f71e4f78c418b85dcef801e82d0eed1ffd2cdda21c8ba163ef |
| internal/host/rootgeneration_rows.go generationRegistryInventory authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner registry:ClaimOwnership@2394,registry:ClaimOwnership@2773,registry:ClaimRowID@2826,registry:Host@2372,registry:Host@2751,registry:Name@2692,registry:Registration@2804,whole:ClaimOwnership@1625,whole:ClaimOwnership@1734,whole:ClaimOwnership@1748,whole:ClaimOwnership@1762,whole:ClaimOwnership@2271,whole:ClaimOwnership@2400,whole:ClaimOwnership@2418,whole:ClaimOwnership@2473,whole:ClaimOwnership@2722,whole:ClaimOwnership@2779,whole:ClaimOwnership@2797,whole:ClaimOwnership@2892,whole:ClaimOwnership@2920,whole:Route@2289,whole:Timer@2578,whole:Timer@2656 4d4e526c596c7a7fe36a071ca2452acc63f4941b225d389971fb718f4c39dd33 |
| internal/host/rootgeneration_handover.go generationReadHandovers authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner file:Read@453 226a4fbdb227eed6993a4d94c380e8aef8f681c6ad4b56abed2a30acee10e12a |
| @@ -365,8 +365,8 @@ internal/hostguard/recoveryfence.go Guard.PendingStopFences authority C,J4 rev3= |
| internal/hostguard/recoveryfence.go Guard.pendingFences authority C,J2,J4,J1 rev3=PROPOSED-p3;run=19;identity=root;basis=reads root-owned records under host lock and keeps exact barrier classification separate from ordinary stop recovery; legacy behavior retained 20be9535fd54587a54e3027f5388581ae5a64e5fcfced5a6031ffc9a4fc76a3b |
| internal/host/claimroleassembly_linux.go verifiedClaimExecutable authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=pins root-owned executable bytes and keeps the verified descriptor while supplying its absolute path to the root launcher file:Fstat@1122,file:Fstat@579,file:OpenFile@376,file:Read@949 7a7e9af520e4157e60ae93d12c3268a629a9b0bd1e83b263cdd27103cf2e227c |
| internal/host/claimroleassembly_linux.go claimExecutableName authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=duplicates the existing descriptor with CLOEXEC and its validated absolute name; never reopens a potentially replaced path e887880731a16c9e7934aef33a08169b34e7236f6d3b85a195602d35f8b69107 |
| -@coverage internal/host 470c8d923787bc0cd3c55f4e263a89ca206d992e4f282d86386fd663d6eb43e2 |
| -@coverage internal/hostbox 1a416db4f7a98faf6a39675a84e516013e13d79d6999fd1c921248b1f82e1557 |
| +@coverage internal/host 42ea2ed51b8dea0e178285250d5eec164f83ae39614b5c686e19346f1945fb85 |
| +@coverage internal/hostbox 23dce8b43c105e281a8e93f2c70c969e48e1db448632c8c72d21b0ed273cc246 |
| @coverage internal/hostguard 1b3341e3f1a3b56c46b1a85ec5e12cb08e0c26128b1b5c865a562ab5aa0994fb |
| internal/host/claimstop.go ClaimStopCoordinator.FenceAndStop authority C,J4 rev3=PROPOSED-p3;run=20;identity=root instance-control uid 0;basis=authenticated session stop retains root fence before independent unjournaled absence check; proves no journaled installation, containment or open files before bypassing nonexistent writer closure; normal drain remains for recorded launches; no-census unchanged file:Read@1610 58147f3abbf278037fc660392aeec0b6e76b667e3fe8435845ebaedc62d2ead1 |
| internal/host/claimstop_recovery.go ClaimStopCoordinator.RecoverStopped authority C,J4 rev3=PROPOSED-p3;run=20;identity=root instance-control uid 0;basis=pending exact root fence and exact installation inventory permit recovery through independent absence verification when no launch journal was created a91b528d9f9259612c767bf0a94e0ff7664294668f29f584a681a1a0f656ad90 |
| @@ -407,3 +407,20 @@ internal/host/claimstop_writers_linux.go stopNeverIntended authority C,J4 rev3=P |
| internal/host/claimservingretire.go claimServingArtifactRetirement.BeforeLegacyInstall authority C,J4 rev3=PROPOSED-p3;run=20;identity=service caller of root retirement;basis=separates empty database recovery from session-zero predecessor; root still verifies ordinary prior admission stop receipts daf192253b905a23e3f49ced325e9e431edad01676fb22a6577a56dd493010d2 |
| internal/hostguard/flock_unix.go acquire authority C,J4 rev3=PROPOSED-p3;run=20;identity=root and service host-lock callers;basis=a root census caller creating the shared lock preserves its directory group so service can reopen it after reboot; existing owner validation and no-census path unchanged file:Fstat@1356,file:Fstat@398,file:Fstatat@665,file:Openat@1024,file:Openat@897 a5e19f0fb712194d07aecbfa72f7aa91fe90b7bbc16ea18e33dadd025b5f625f |
| internal/host/claimservingpark.go servingGenerationParkDisposition authority C,J4 rev3=PROPOSED-p3;run=20;identity=root retirement uid 0;basis=validates every root stop receipt and matches the original launch controller to at least one; root never-intended tombstone with closed writers and absent process identity permits discard; ambiguous intentions refuse c1e013c260fef86a61945f7c38bc1352f339adbd3c8fb8b92b3785ea6b96de59 |
| +internal/host/claimordinaryparks.go productionClaimRegional.ordinaryHome authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=verified home roster, ordinary operation, exact local holder location and box-local admission history; foreign regions and moves refuse file:Exec@842,file:Exec@867 fa96f75f8af5fd1e3ba3dff81e27ac0fb7c6029663472292d698bfddaec99fe8 |
| +internal/host/claimordinaryparks.go productionClaimRegional.ordinaryView authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=request-bound exact-version marker and files manifest reads retain current, unfinished and pinned generations; no move provider or deployment-record lookup 426feefeb9da8c7e2748bfcbacad1eaba561da4a0e29f2422640cb299ab1e06f |
| +internal/host/claimordinaryparks.go productionClaimRegional.VerifyObligation authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=exact current publication obligation and retained root stop receipt bind predecessor generation, installation, box, region and immutable evidence time; missing root authority refuses 09dcc8365f352361ffc3f32bcfc323c25cfcdc0aa4ec3b9e7840e1e2b58e97fd |
| +internal/host/claimproduction.go productionClaimRegional.View authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=ordinary home-region cleanup uses the scoped production view when no move provider is registered; registered moves retain their existing view bcefe9f8fd6420e795ef95a6170fbad1030e29f2261bcc2d677f76b6f6d577ce |
| +internal/host/claimproduction.go productionClaimRegional.Authorize authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=exact predecessor root stop proof or signed operation retirement proof replaces execution permission for cleanup; existing owner and region checks remain file:Prepare@1184 4502c32e2b02fe09123ab4878c741f94a34dd645726b381f8b2455fc40fe09fe |
| +internal/host/claimmoveparks.go productionClaimRegional.VerifyMovesSweep authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=moves-only sweep refuses when the moves provider is absent, including claim-free observations 467b24b7ca60a86caacee31892c6919620be43966fbf31be9ba815487ff9bc5e |
| +internal/host/claimordinaryparks.go productionClaimRegional.authorizeOrdinaryRetirement authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID with authenticated root fence projection;basis=signed operation-bound retirement evidence replaces execution authority after record deletion; only exact holder or empty staging namespace, never move residue d726c4a62a957c92f4743ce791cd05a57cb650a5eae0e03270eff2e0b426b311 |
| +internal/park/sweep.go protected authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID under root stop or signed retirement evidence;basis=ordinary view distinguishes independently retained file versions from database generation pins; only explicit file namespace pins receive per-version protection af0ffc56bb49f87738a94350c4c92305a8b922ede0587bc8fad4bb79b10f20d0 |
| +internal/park/remote.go Engine.selected authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=claim regional worker service UID under root stop or signed retirement evidence;basis=ordinary generation retirement excludes exact live file versions from fresh selection and revalidates on manifest replay; replay selection mismatch refuses deletion 43dd4b71ef97868599cf56560a458026ac77bada65f75b314630c436e57112ca |
| +@coverage internal/park b611c74d7f75c985a1546f0b79b2f7db79312a3d3d2450981c5184c04c8e324e |
| +internal/host/rootexecution_authority.go ClaimRootHTTP.executionAuthority authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=root authenticated execution broker uid 0;basis=ordinary home delete and parking participation only, exact claim root installation inventory and record version or verified absence; completed stops never confer execution file:Exec@1657,file:Read@553 eff2c5806bff1c3d4beec4f54513b71e3890a4970b4b03d1f6a200db0146109e |
| +internal/host/rootexecution_settle.go ClaimRootHTTP.cleanupExecution authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=root authenticated execution broker uid 0;basis=retained exact root installation authorizes participation settlement after retirement; canonical stop receipts and kernel checks retained file:Read@2928,file:Read@4251 5f185b6f1bc2ba75d63d5bbd865c983a92b98cdaa8d1f23110f60661ad24b9a9 |
| +internal/hostclaim/delete.go ToParking authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=production console or local regional worker using authenticated operation;basis=console ordinary delete transition under claim CAS; sets the previously empty ordinary deletion phase to park so exact root stop tuples can retire it; existing nonempty phases retained 9d5d3cf3fd1255e4a3cda50cedcd65197f65155ea4bfa57d50670ebad0e5492a |
| +internal/hostbox/renewing.go renewingHeld.Release authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=production console or local regional worker using authenticated operation;basis=regional park worker release of held lease; cancels work immediately then issues exact conditional lease deletion under detached request bounded cleanup context; grants no new work 0cfd55e1a0f40851dd27be382187fc01cfab5dc2bca54262966f6a66a2799e39 |
| +@coverage internal/hostclaim cd49f5e0ac3870767e3b6caba3159063150e7940cbd183f47451fa86e3b29979 |
| +internal/host/claimstop_producers.go ClaimProducers.DrainClaim authority C,E2,J4 rev3=PROPOSED-p3;run=21;identity=authenticated local writer controller answering root stop;basis=only a fully joined and compacted drain may acknowledge a new fence for the same exact writer; unfinished drains and foreign tuples refuse; producer admission remains closed a3633b7f153cf84f565d3d6d19db1e38eba5f578de57b9a65900fd74a837c5cf |
| +internal/hostbox/typedpark_path.go TypedParker.ParkedArtifactRoot authority C,J2,J4 rev3=PROPOSED-p3;run=21;identity=root retirement or generation verifier uid 0;basis=only configured root or recorded strict descendant mount containing artifact; malformed or unrelated mount paths refuse;basis=recorded mount relocation only for CapturePersistent root-protected artifacts; legacy destinations unchanged e4fe742ac8e421856f66729d888e81f828f8679fee6df93a310e0948d34cca48 |