carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 888fb0b40223aeffb00c7943eef0f57616f32995 parent f32a66a
25 files changed, +1122 −89
  • internal/host/claimmoveparks.go +3 −3
  • internal/host/claimordinaryparks.go +177 −0
  • internal/host/claimordinaryparks_linux_test.go +280 −0
  • internal/host/claimproduction.go +17 −2
  • internal/host/claimretirerecovery_test.go +66 −9
  • internal/host/claimstop_producers.go +4 −2
  • internal/host/plan14_delete_participation_test.go +116 −0
  • internal/host/plan14_generation_parked_linux_test.go +30 −0
  • internal/host/plan14_lifecycle_retire_root_linux_test.go +43 −0
  • internal/host/plan14_lifecycle_root_linux_test.go +76 −48
  • internal/host/plan14_lifecycle_trace_root_linux_test.go +62 −0
  • internal/host/rootexecution_authority.go +43 −3
  • internal/host/rootexecution_settle.go +14 −3
  • internal/host/rootgeneration_parked.go +11 −1
  • internal/hostbox/renewing.go +1 −1
  • internal/hostbox/renewing_test.go +52 −0
  • internal/hostbox/typedpark_linux.go +6 −12
  • internal/hostbox/typedpark_linux_test.go +33 −0
  • internal/hostbox/typedpark_path.go +37 −0
  • internal/hostclaim/delete.go +3 −0
  • internal/hostclaim/delete_test.go +10 −0
  • internal/park/remote.go +3 −0
  • internal/park/sweep.go +4 −0
  • internal/provision/plan14_p3_census_test.go +9 −0
  • internal/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