carlos / platform Public

WIP files seed checkpoint 5 (not for merge): legacy and empty-bootstrap retirement authorization and recovery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d67fdb5a130f8f4be5fc5c7fb48a638b8df98098 parent c4f25c7
9 files changed, +1142 −49
  • internal/host/claimservinglegacy.go +129 −0
  • internal/host/claimservinglegacyroot_linux.go +264 −0
  • internal/host/claimservinglegacyroot_linux_test.go +597 −0
  • internal/host/claimservingpark_records.go +41 −7
  • internal/host/claimservingparkroot_linux.go +28 −17
  • internal/host/claimservingparkroot_linux_test.go +2 −2
  • internal/host/claimstop.go +19 −13
  • internal/host/claimstop_noprocess_test.go +40 −2
  • internal/provision/testdata/plan14-root-reads-seed.tsv +22 −8

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	333.475s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.079s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.206s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.702s
ok  	github.com/carlosframework/platform/internal/activator	233.782s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.288s
ok  	github.com/carlosframework/platform/internal/apperr	0.082s
ok  	github.com/carlosframework/platform/internal/applog	0.132s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.834s
?   	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.698s
ok  	github.com/carlosframework/platform/internal/changelog	0.291s
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	361.688s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.673s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
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.107s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.005s
ok  	github.com/carlosframework/platform/internal/edge	26.079s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.124s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.017s
ok  	github.com/carlosframework/platform/internal/geo	0.011s
ok  	github.com/carlosframework/platform/internal/geoip	0.031s
ok  	github.com/carlosframework/platform/internal/host	128.964s
ok  	github.com/carlosframework/platform/internal/hostactors	0.340s
ok  	github.com/carlosframework/platform/internal/hostbox	2.282s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.506s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	4.748s
ok  	github.com/carlosframework/platform/internal/hostfence	0.033s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.481s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	1.012s
ok  	github.com/carlosframework/platform/internal/hostops	1.425s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.478s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.145s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.005s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.820s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.689s
ok  	github.com/carlosframework/platform/internal/instancefs	0.007s
ok  	github.com/carlosframework/platform/internal/instancekey	0.018s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.025s
ok  	github.com/carlosframework/platform/internal/layout	1.431s
ok  	github.com/carlosframework/platform/internal/lease	9.458s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.025s
ok  	github.com/carlosframework/platform/internal/ledger	0.014s
ok  	github.com/carlosframework/platform/internal/mail	0.161s
ok  	github.com/carlosframework/platform/internal/maintenance	0.078s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.520s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.088s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.933s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.743s
ok  	github.com/carlosframework/platform/internal/objstore	0.181s
ok  	github.com/carlosframework/platform/internal/ops	0.163s
ok  	github.com/carlosframework/platform/internal/park	0.199s
ok  	github.com/carlosframework/platform/internal/privatefile	0.007s
ok  	github.com/carlosframework/platform/internal/provision	10.957s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	3.981s
ok  	github.com/carlosframework/platform/internal/relay	7.235s
ok  	github.com/carlosframework/platform/internal/release	19.240s
ok  	github.com/carlosframework/platform/internal/rollout	3.606s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.010s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.278s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	8.780s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.222s
ok  	github.com/carlosframework/platform/internal/status	0.041s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.282s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.003s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.545s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/14 08:04:08 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE56B7A50525B4, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/internal/host/claimservinglegacy.go b/internal/host/claimservinglegacy.go
new file mode 100644
index 000000000..9b318bbe4
--- /dev/null
+++ b/internal/host/claimservinglegacy.go
@@ -0,0 +1,129 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "reflect"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// A session-zero witness is retained separately from fence receipts. Admission
+// changes its ETag; keeping the original observation binds it to the first cold
+// admission without pretending that session zero held a serving fence.
+type servingLegacyStop struct {
+ Observed hostclaim.Observed
+ Result StopResult
+}
+
+type servingLegacyEvidence struct {
+ Selector servingLegacyPredecessor
+ NoProcess *servingLegacyStop `json:",omitempty"`
+}
+
+func servingLegacyKey(kind string, a servingArtifactIdentity) string {
+ return kind + "-" + servingParkKey(a.Box, a.Host+"\x00"+a.Incarnation) + ".json"
+}
+
+func validateServingLegacyStop(s servingLegacyStop) error {
+ o, result := s.Observed, s.Result
+ if o.Absent || o.ETag == "" || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Host != o.Host {
+ return hostbox.ErrChanged
+ }
+ c, h, t := o.Claim, o.Claim.Holder, result.Target
+ if c.Validate() != nil {
+ return hostbox.ErrChanged
+ }
+ if c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" || c.Op.Exec != t.Box || h.Session != 0 || h.Generation != "" || len(h.Admissions) != 0 || (h.WriterBox != "" && h.WriterBox != t.Box) || t.Host != o.Host || t.Incarnation != h.Incarnation || t.Stamp != h.Stamp || t.Stamp == 0 || t.Session != 0 || t.FenceID != "" || t.Op != "" || t.Phase != "" || t.ObligationID != "" || t.ClaimGen != c.Gen || t.ClaimETag != o.ETag {
+ return hostbox.ErrChanged
+ }
+ var r StopReceipt
+ if json.Unmarshal(result.Bytes, &r) != nil || !bytes.Equal(result.Bytes, mustMarshalStopReceipt(r)) || r.V != 1 || r.Kind != StopReceiptNoProcess || !stopComponent(r.ID) || r.ProducerDrainDigest != "" || !stopDigest(r.InventorySHA256) || !stopDigest(r.LocalJournalSHA256) || len(r.CgroupIdentities) == 0 || len(r.UnitInvocationIDs) != 0 || r.ClaimETag != o.ETag || r.ClaimGen != c.Gen || result.EvidenceAt.IsZero() || result.Key != stopEvidenceKey(o.Host, "", t.Box, r.ID) {
+ return ErrClaimStopReceipt
+ }
+ req := StopRequest{Region: t.Region, Host: t.Host, Incarnation: t.Incarnation, InstallationStamp: t.Stamp, Box: t.Box, Enrollment: t.Enrollment, BootID: t.BootID, ControllerID: t.ControllerID, ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: t.DirectoryRevision}
+ if t.Region == "" || t.Box == "" || t.Enrollment == "" || t.BootID == "" || t.ControllerID == "" || t.DirectoryRevision == "" {
+ return ErrClaimStopReceipt
+ }
+ _, err := validateTransportStop(req, result)
+ return err
+}
+
+func (l *servingParkLedger) legacyStop(a servingArtifactIdentity) (servingLegacyStop, error) {
+ var s servingLegacyStop
+ b, err := l.state.Read(servingLegacyKey("legacy-stop", a))
+ if err != nil {
+ return s, err
+ }
+ if servingParkDecode(b, &s) != nil || validateServingLegacyStop(s) != nil {
+ return s, hostbox.ErrChanged
+ }
+ t := s.Result.Target
+ if t.Host != a.Host || t.Box != a.Box || t.Region != a.Region || t.Incarnation != a.Incarnation {
+ return s, hostbox.ErrChanged
+ }
+ return s, nil
+}
+
+func (l *servingParkLedger) legacyConsumption(a servingArtifactIdentity) (*servingLegacyConsumption, error) {
+ b, err := l.state.Read(servingLegacyKey("legacy", a))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var c servingLegacyConsumption
+ if servingParkDecode(b, &c) != nil || !c.Authorization.Legacy || c.Authorization.Successor.Host != a.Host || c.Authorization.Successor.Box != a.Box || c.Authorization.Successor.Incarnation != a.Incarnation {
+ return nil, hostbox.ErrChanged
+ }
+ return &c, nil
+}
+
+func sameServingLegacyEvidence(a, b *servingLegacyEvidence) bool { return reflect.DeepEqual(a, b) }
+
+func validateServingLegacyEvidence(a servingParkAuthorization, evidence *servingLegacyEvidence) error {
+ if !a.Legacy || evidence == nil {
+ return hostbox.ErrChanged
+ }
+ p := evidence.Selector
+ if (p.Session == 0) != (p.Generation == "") || p.Session >= a.OriginalSuccessor.Session || (p.Empty && p.Session != 0) {
+ return hostbox.ErrChanged
+ }
+ if p.Session != 0 {
+ if evidence.NoProcess != nil {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
+ if evidence.NoProcess == nil || validateServingLegacyStop(*evidence.NoProcess) != nil {
+ return hostbox.ErrChanged
+ }
+ s, current := *evidence.NoProcess, a.OriginalSuccessor
+ h, t := s.Observed.Claim.Holder, s.Result.Target
+ if t.Host != current.Host || t.Box != current.Box || t.Region != current.Region || t.Incarnation != current.Incarnation || h.Location != current.Location || h.Stamp >= current.Stamp || s.Observed.Claim.Gen >= current.Stamp || p.Empty != (h.WriterBox == "") {
+ return hostbox.ErrChanged
+ }
+ return nil
+}
+
+// The current writer must still be only a preparation. Even an uncompleted
+// launch intention makes untagged bytes ambiguous and forbids legacy capture.
+func (l *servingParkLedger) legacyUnlaunched(ctx context.Context, a servingArtifactIdentity, enrollment string) error {
+ records, err := l.journal.RootSessionLaunches(ctx, a.Host, a.Incarnation, a.Stamp, a.Session, a.Box, enrollment)
+ if err != nil {
+ return err
+ }
+ for _, r := range records {
+ for _, reservation := range r.Reservations {
+ if reservation.Role == "main" {
+ return hostbox.ErrChanged
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/host/claimservinglegacyroot_linux.go b/internal/host/claimservinglegacyroot_linux.go
new file mode 100644
index 000000000..47d042597
--- /dev/null
+++ b/internal/host/claimservinglegacyroot_linux.go
@@ -0,0 +1,264 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "errors"
+ "net"
+ "os"
+ "reflect"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+// Called only by the root stop coordinator after its kernel observation and
+// before publishing the receipt. There is no transport operation for inserting
+// this evidence. A claim change during the observation or lock wait refuses it.
+func (p *servingParkRoot) retainLegacyNoProcess(ctx context.Context, o hostclaim.Observed, result StopResult) error {
+ if p == nil || p.ledger == nil || p.root.RequireNewWork == nil || p.root.InventoryActor == nil || p.root.Stop.Coordinator.Guard.Root == nil {
+ return hostbox.ErrUnsupported
+ }
+ s := servingLegacyStop{Observed: o, Result: result}
+ if validateServingLegacyStop(s) != nil {
+ return ErrClaimStopReceipt
+ }
+ return p.ledger.withNamespace(ctx, func() error {
+ return p.root.Stop.Coordinator.Guard.With(ctx, o.Host, hostguard.Captured{Incarnation: result.Target.Incarnation, Stamp: result.Target.Stamp}, func(c *hostclaim.Claim) error {
+ if !reflect.DeepEqual(c, o.Claim) {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }, func() error {
+ if err := p.root.RequireNewWork(ctx); err != nil {
+ return err
+ }
+ actor, err := p.root.InventoryActor(ctx)
+ if err != nil {
+ return err
+ }
+ t := result.Target
+ if !actor.Eligible || actor.Quarantined || actor.Box != t.Box || actor.Region != t.Region || actor.Enrollment != t.Enrollment || actor.Revision == 0 || strconv.FormatUint(actor.Revision, 10) != t.DirectoryRevision {
+ return hostbox.ErrChanged
+ }
+ fresh, err := p.root.Stop.Coordinator.Guard.Claims.Read(ctx, o.Host)
+ if err != nil {
+ return err
+ }
+ if fresh.ETag != o.ETag || fresh.Absent {
+ return hostbox.ErrChanged
+ }
+ a := servingArtifactIdentity{Host: o.Host, Box: result.Target.Box, Region: result.Target.Region, Incarnation: result.Target.Incarnation}
+ old, err := p.ledger.legacyStop(a)
+ if err == nil {
+ // Retry reservation and settlement change Gen, ETag and WakeRetry
+ // without admitting a writer. Keep the first receipt across those
+ // changes, but refuse a different holder or enrollment.
+ priorClaim, nextClaim := *old.Observed.Claim, *o.Claim
+ priorHolder, nextHolder := *priorClaim.Holder, *nextClaim.Holder
+ priorHolder.WakeRetry, nextHolder.WakeRetry = nil, nil
+ priorClaim.Holder, nextClaim.Holder = &priorHolder, &nextHolder
+ priorClaim.Gen = nextClaim.Gen
+ if !reflect.DeepEqual(priorClaim, nextClaim) || old.Result.Target.Enrollment != result.Target.Enrollment || old.Result.Target.DirectoryRevision != result.Target.DirectoryRevision {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return p.ledger.write(servingLegacyKey("legacy-stop", a), s)
+ })
+ })
+}
+
+func (p *servingParkRoot) validateLegacyRootEvidence(ctx context.Context, o hostclaim.Observed, view hostguard.FenceView, actor hostbox.Actor, a servingParkAuthorization, e *servingLegacyEvidence, receipts []hostguard.FenceReceipt) error {
+ if validateServingLegacyEvidence(a, e) != nil {
+ return hostbox.ErrChanged
+ }
+ current := a.OriginalSuccessor
+ var entry *hostclaim.AdmissionRecord
+ for i := range o.Claim.Holder.Admissions {
+ r := &o.Claim.Holder.Admissions[i]
+ if r.Session == current.Session {
+ if entry != nil {
+ return hostbox.ErrChanged
+ }
+ entry = r
+ }
+ }
+ if entry == nil || entry.Mode != "cold" || entry.Box != current.Box || entry.Region != current.Region || entry.Generation != current.Generation || entry.Predecessor != e.Selector.Generation {
+ return hostbox.ErrChanged
+ }
+ if e.NoProcess != nil {
+ saved, err := p.ledger.legacyStop(current)
+ if err != nil || !reflect.DeepEqual(saved, *e.NoProcess) || len(receipts) != 0 {
+ return hostbox.ErrChanged
+ }
+ t, h := saved.Result.Target, saved.Observed.Claim.Holder
+ if t.Enrollment != actor.Enrollment || t.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) || h.Account != o.Claim.Holder.Account || h.App != o.Claim.Holder.App || h.Key != o.Claim.Holder.Key || !h.CreatedAt.Equal(o.Claim.Holder.CreatedAt) {
+ return hostbox.ErrChanged
+ }
+ for _, r := range o.Claim.Holder.Admissions {
+ if r.Session < current.Session {
+ return hostbox.ErrChanged
+ }
+ }
+ if e.Selector.Empty {
+ if entry.PredecessorRegion != "" || entry.WritesLost != nil {
+ return hostbox.ErrChanged
+ }
+ } else if entry.WritesLost == nil || entry.WritesLost.Box != current.Box || entry.WritesLost.Session != 0 || entry.WritesLost.Generation != "" || entry.PredecessorRegion != current.Region {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
+ prior, err := servingPredecessor(o.Claim, a.Successor, e.Selector.Session, e.Selector.Generation)
+ if err != nil || prior.Stamp >= current.Stamp {
+ return hostbox.ErrChanged
+ }
+ stops, err := servingRootStops(ctx, view, prior, actor)
+ if err != nil {
+ return err
+ }
+ if !sameServingRootStops(stops, receipts) {
+ return ErrClaimStopReceipt
+ }
+ return nil
+}
+
+func (p *servingParkRoot) parkLegacyServingDatabase(ctx context.Context, conn net.Conn, current servingArtifactIdentity, requested servingLegacyPredecessor) (servingParkAuthorization, error) {
+ var out servingParkAuthorization
+ err := p.withAuthority(ctx, conn, current, func(o hostclaim.Observed, view hostguard.FenceView, actor hostbox.Actor, dir servingArtifactDirectory, tags uint64) error {
+ var err error
+ consumed, err := p.ledger.legacyConsumption(current)
+ if err != nil {
+ return err
+ }
+ a := servingParkAuthorization{Legacy: true, OriginalSuccessor: current, Successor: current, Directory: dir}
+ a.ID = servingRetirementID(a.Predecessor, current, true)
+ e := &servingLegacyEvidence{Selector: requested}
+ var receipts []hostguard.FenceReceipt
+ var saved *servingParkRecord
+ if consumed != nil {
+ a, e, receipts = consumed.Authorization, consumed.LegacyEvidence, consumed.Receipts
+ if a.Directory != dir || e == nil {
+ return hostbox.ErrChanged
+ }
+ r, readErr := p.ledger.read(a.ID)
+ if readErr == nil {
+ saved = &r
+ } else if !errors.Is(readErr, os.ErrNotExist) {
+ return readErr
+ }
+ } else {
+ if tags != 0 {
+ return hostbox.ErrChanged
+ }
+ if requested.Session == 0 {
+ stop, readErr := p.ledger.legacyStop(current)
+ if readErr != nil {
+ return readErr
+ }
+ e.NoProcess = &stop
+ } else {
+ prior, readErr := servingPredecessor(o.Claim, current, requested.Session, requested.Generation)
+ if readErr != nil {
+ return readErr
+ }
+ receipts, err = servingRootStops(ctx, view, prior, actor)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ if err = p.validateLegacyRootEvidence(ctx, o, view, actor, a, e, receipts); err != nil {
+ return err
+ }
+ active := a
+ if saved != nil {
+ if !sameServingLegacySource(saved.Source, consumed.Source) || saved.Authorization != a || !sameServingLegacyEvidence(saved.LegacyEvidence, e) || !sameServingRootStops(saved.Receipts, receipts) {
+ return hostbox.ErrChanged
+ }
+ if err = validateServingRootHistory(ctx, o, view, actor, *saved); err != nil {
+ return err
+ }
+ active = saved.activeAuthorization()
+ }
+ if tags != active.TagsFrom && tags != active.Successor.Stamp && tags != current.Stamp {
+ return hostbox.ErrChanged
+ }
+ var next servingParkAuthorization
+ var stops []hostguard.FenceReceipt
+ if active.Successor != current {
+ if saved != nil && saved.Complete || active.Successor.Stamp >= current.Stamp {
+ return hostbox.ErrChanged
+ }
+ prior, e := servingPredecessor(o.Claim, current, active.Successor.Stamp, active.Successor.Generation)
+ if e != nil || prior != active.Successor {
+ return hostbox.ErrChanged
+ }
+ stops, err = servingRootStops(ctx, view, prior, actor)
+ if err != nil {
+ return err
+ }
+ next = active
+ next.Successor, next.TagsFrom = current, tags
+ }
+ if err = p.ledger.legacyUnlaunched(ctx, current, actor.Enrollment); err != nil {
+ return err
+ }
+ if consumed == nil && e.Selector.Empty {
+ if p.empty == nil {
+ return hostbox.ErrUnsupported
+ }
+ if a.Successor != current {
+ return hostbox.ErrChanged
+ }
+ in := ClaimLaunchInputs{Empty: true, Admission: hostclaim.Admission{NewGeneration: current.Generation}}
+ if err = p.empty.EmptyAuthority(ctx, o, in); err != nil {
+ return err
+ }
+ }
+ // Remote first-creator proof can wait on I/O; it cannot carry a stale claim
+ // into the first source capture or any replay mutation.
+ fresh, err := p.root.Stop.Coordinator.Guard.Claims.Read(ctx, current.Host)
+ if err != nil {
+ return err
+ }
+ if fresh.Absent || fresh.ETag != o.ETag {
+ return hostbox.ErrChanged
+ }
+ r, err := p.ledger.parkWithEvidenceLocked(ctx, a, receipts, e)
+ if err != nil {
+ return err
+ }
+ if err = p.ledger.fs.CheckParked(&r); err != nil {
+ return err
+ }
+ if next.ID != "" {
+ r, err = p.ledger.continueLocked(ctx, a.ID, next, stops)
+ if err != nil {
+ return err
+ }
+ }
+ out = r.activeAuthorization()
+ return nil
+ })
+ return out, err
+}
+
+func sameServingLegacySource(a, b servingParkSource) bool {
+ if a.Directory != b.Directory || a.Parent != b.Parent {
+ return false
+ }
+ for i := range a.Files {
+ if !sameServingParkFile(a.Files[i], b.Files[i]) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/host/claimservinglegacyroot_linux_test.go b/internal/host/claimservinglegacyroot_linux_test.go
new file mode 100644
index 000000000..3ec95cadc
--- /dev/null
+++ b/internal/host/claimservinglegacyroot_linux_test.go
@@ -0,0 +1,597 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/lease"
+)
+
+func newServingLegacyRootFixture(t *testing.T, empty bool, beforeAdmission ...func(*servingRootFixture, *ClaimStopCoordinator, StopRequest)) (*servingRootFixture, ClaimLaunchInputs) {
+ t.Helper()
+ f, _, _, in := servingRetirementFixture(t)
+ if !empty {
+ _, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error {
+ c.Holder.WriterBox = "box-1"
+ c.Holder.Location = "instances/" + c.Host
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ disk := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: t.TempDir()}}
+ roots := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: t.TempDir()}}
+ f.guard.Root = hostguard.NewRootAuthority(roots)
+ journals, err := hostbox.NewJournalService(f.journal)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fx := &servingRootFixture{t: t, f: f, disk: disk, rootDisk: roots, db: in.DBPath, actor: hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enrollment", Revision: 7, Eligible: true}}
+ fs := newServingParkFS(f.guard.DataDir, disk)
+ fs.owner = uint32(os.Geteuid())
+ fs.mount = func(string) (bool, error) { return true, nil }
+ fx.p = &servingParkRoot{ledger: &servingParkLedger{state: disk, fs: fs, journal: f.journal, now: func() time.Time { return f.now }}, empty: plan9D5Fixture(t).owners}
+ f.peer = StopPeer{UID: 1001, PID: 77, ProcessStart: "edge-start", BootID: "boot", ControllerID: "controller"}
+ coordinator := f.root(stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) {
+ t.Fatal("session zero requested drain")
+ return DrainAcknowledgement{}, hostbox.ErrChanged
+ }))
+ coordinator.Kernel = &noProcessKernelFake{}
+ coordinator.Inventory = stopInventoryFunc(func(ctx context.Context, host string) (StopInventory, error) {
+ return journals.Inventory.ReadStopInventory(ctx, host)
+ })
+ fx.p.root = ClaimRootHTTP{Roots: map[string]string{"data": f.guard.DataDir}, Journals: journals, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, InventoryActor: func(context.Context) (hostbox.Actor, error) { return fx.actor, nil }, RequireNewWork: func(context.Context) error { return nil }}
+ fx.install(f.read())
+ coordinator.RetainNoProcess = fx.p.retainLegacyNoProcess
+ o := f.read()
+ h := o.Claim.Holder
+ req := StopRequest{Region: "ie", Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"}
+ if _, err := coordinator.FenceAndStop(t.Context(), req); err != nil {
+ t.Fatalf("no-process: %v", err)
+ }
+ for _, fn := range beforeAdmission {
+ fn(fx, &coordinator, req)
+ }
+ admission := hostclaim.Admission{ObservedSession: 0, Box: "box-1", Region: "ie", Mode: "cold", NewGeneration: "G"}
+ if !empty {
+ admission.PredecessorRegion = "ie"
+ }
+ admitted, err := hostclaim.Update(t.Context(), f.claims, f.read(), hostclaim.AdmitWriter(admission))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fx.admitted = admitted
+ fx.current, err = servingWriterIdentity(admitted, "box-1", "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fx.install(admitted)
+ in.Admission, in.Empty = admission, empty
+ if !empty {
+ for suffix, body := range map[string]string{"": "legacy bytes", "-wal": "legacy WAL", "-shm": "legacy SHM"} {
+ if err := os.WriteFile(fx.db+suffix, []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ left, right := net.Pipe()
+ fx.conn = left
+ t.Cleanup(func() { left.Close(); right.Close() })
+ return fx, in
+}
+
+func TestServingLegacyRootAuthority(t *testing.T) {
+ for _, empty := range []bool{false, true} {
+ t.Run(fmt.Sprint(empty), func(t *testing.T) {
+ f, in := newServingLegacyRootFixture(t, empty)
+ r := &claimServingArtifactRetirement{Claims: f.f.claims, Guard: f.f.guard, DataDir: f.f.guard.DataDir, Box: f.actor.Box, Region: f.actor.Region, Root: servingRootClientFixture{f}}
+ work := servingRootWork{f.f.guard, f.admitted}
+ if err := r.BeforeLegacyInstall(t.Context(), f.admitted, in, work); err != nil {
+ t.Fatal(err)
+ }
+ id := servingRetirementID(servingArtifactIdentity{}, f.current, true)
+ record, err := f.p.ledger.read(id)
+ if err != nil || !record.Complete {
+ t.Fatalf("record: %+v %v", record, err)
+ }
+ if record.LegacyEvidence == nil || record.LegacyEvidence.NoProcess == nil || len(record.Receipts) != 0 {
+ t.Fatal("invented fence for session zero")
+ }
+ if (record.Disposition == servingParkArtifactFree) != empty {
+ t.Fatal(record.Disposition)
+ }
+ if !empty {
+ b, err := os.ReadFile(filepath.Join(f.f.guard.DataDir, "instances", ".serving-parked", id, filepath.Base(f.db)))
+ if err != nil || string(b) != "legacy bytes" {
+ t.Fatalf("park %q %v", b, err)
+ }
+ }
+ if err := r.Recover(t.Context(), f.admitted, in); err != nil {
+ t.Fatalf("lost completion reply: %v", err)
+ }
+ if err := work.With(t.Context(), in.Identity, func() error { return r.EnrollLocked(t.Context(), f.admitted, in) }); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestServingLegacyRootRefusals(t *testing.T) {
+ for _, attack := range []string{"stop-missing", "stop-bytes", "stop-region", "first-creator", "marker", "half-tags", "tags", "peer", "census", "enrollment", "revision", "source", "main-intention", "empty-database", "empty-wal", "empty-remote", "empty-unavailable", "selector"} {
+ t.Run(attack, func(t *testing.T) {
+ empty := strings.HasPrefix(attack, "empty-")
+ f, _ := newServingLegacyRootFixture(t, empty)
+ requested := servingLegacyPredecessor{Empty: empty}
+ switch attack {
+ case "stop-missing":
+ if err := os.Remove(filepath.Join(f.disk.dir, servingLegacyKey("legacy-stop", f.current))); err != nil {
+ t.Fatal(err)
+ }
+ case "stop-bytes", "stop-region":
+ s, err := f.p.ledger.legacyStop(f.current)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if attack == "stop-bytes" {
+ s.Result.Bytes = []byte("forged")
+ } else {
+ s.Result.Target.Region = "foreign"
+ }
+ if err = f.p.ledger.write(servingLegacyKey("legacy-stop", f.current), s); err != nil {
+ t.Fatal(err)
+ }
+ case "first-creator":
+ requested.Empty = true
+ case "marker":
+ if err := os.WriteFile(f.db+".gen", []byte("foreign"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ case "tags":
+ f.tags(f.current.Stamp)
+ case "half-tags":
+ _, err := hostclaim.Update(t.Context(), f.f.claims, f.f.read(), func(c *hostclaim.Claim) error {
+ rel, _ := filepath.Rel(f.f.guard.DataDir, f.db)
+ c.Inventory = map[string]hostclaim.InventoryEntry{f.actor.Box: {Layout: "nested", Paths: map[string]uint64{rel: f.current.Stamp}}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ case "peer":
+ f.p.root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, ErrClaimStopIdentity }
+ case "census":
+ f.p.root.RequireNewWork = func(context.Context) error { return hostbox.ErrChanged }
+ case "enrollment":
+ f.actor.Enrollment = "changed"
+ case "revision":
+ f.actor.Revision++
+ case "source":
+ if err := os.Rename(filepath.Dir(f.db), filepath.Dir(f.db)+".saved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Dir(f.db), 0700); err != nil {
+ t.Fatal(err)
+ }
+ case "main-intention":
+ target := recoveryTuple(f.admitted, claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "controller"})
+ if err := f.f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/parent", Device: 1, Inode: 2}}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.f.journal.IntendUnit(t.Context(), hostbox.LaunchReservation{ID: "attempt:main", Role: "main", InvocationID: "attempt", Inputs: "digest", Target: target, Group: hostbox.LaunchGroup{RelativePath: "system.slice/carlos-app@shop.service"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 3}}); err != nil {
+ t.Fatal(err)
+ }
+ case "empty-database", "empty-wal":
+ suffix := ""
+ if attack == "empty-wal" {
+ suffix = "-wal"
+ }
+ if err := os.WriteFile(f.db+suffix, []byte("unexpected"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ case "empty-remote":
+ fixture := plan9D5Fixture(t)
+ fixture.bucket.Put("data", f.admitted.Claim.Holder.Location+"/unexpected", []byte("x"))
+ f.p.empty = fixture.owners
+ case "empty-unavailable":
+ f.p.empty = nil
+ case "selector":
+ requested.Session = 1
+ requested.Generation = "forged"
+ }
+ before, _ := f.disk.Names()
+ if _, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, requested); err == nil {
+ t.Fatal("unsafe legacy authority accepted")
+ }
+ after, _ := f.disk.Names()
+ if !reflect.DeepEqual(before, after) {
+ t.Fatalf("refusal effects: %v -> %v", before, after)
+ }
+ if !empty {
+ db := f.db
+ if attack == "source" {
+ db = filepath.Join(filepath.Dir(db)+".saved", filepath.Base(db))
+ }
+ b, err := os.ReadFile(db)
+ if err != nil || string(b) != "legacy bytes" {
+ t.Fatalf("source %q %v", b, err)
+ }
+ }
+ })
+ }
+}
+
+func TestServingLegacyRootContinuation(t *testing.T) {
+ for _, point := range []string{"consumption", "intent", "rename", "parked", "retag", "lost-continuation"} {
+ t.Run(point, func(t *testing.T) {
+ f, _ := newServingLegacyRootFixture(t, false)
+ original := f.current
+ fs := f.p.ledger.fs.(*servingParkFS)
+ rename := fs.rename
+ if point == "rename" {
+ once := false
+ fs.rename = func(a int, b string, c int, d string, e uint) error {
+ err := rename(a, b, c, d, e)
+ if err == nil && !once {
+ once = true
+ return os.ErrPermission
+ }
+ return err
+ }
+ }
+ if point == "consumption" || point == "intent" {
+ n := 1
+ if point == "intent" {
+ n = 2
+ }
+ f.p.ledger.state = &servingParkFaultStore{Store: f.disk, failAt: n}
+ }
+ a, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{})
+ if point == "consumption" || point == "intent" || point == "rename" {
+ if err == nil {
+ t.Fatal("fault missed")
+ }
+ a.ID = servingRetirementID(servingArtifactIdentity{}, original, true)
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ fs.rename = rename
+ f.p.ledger.state = f.disk
+ before, _ := f.p.ledger.read(a.ID)
+ if point == "retag" || point == "lost-continuation" {
+ f.tags(original.Stamp)
+ }
+ servingStop(t, f.f, f.f.read())
+ o := f.f.read()
+ later, err := hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "G2"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.current, err = servingWriterIdentity(later, "box-1", "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.admitted = later
+ f.install(later)
+ f.f.now = f.f.now.Add(time.Hour)
+ if point == "lost-continuation" {
+ f.p.ledger.state = &servingParkFaultStore{Store: f.disk, failAt: 1}
+ if _, err = f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{}); err == nil {
+ t.Fatal("lost continuation fault missed")
+ }
+ f.p.ledger.state = f.disk
+ }
+ continued, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if continued.ID != a.ID || continued.OriginalSuccessor != original || continued.Successor != f.current {
+ t.Fatalf("relabelled: %+v", continued)
+ }
+ f.tags(f.current.Stamp)
+ if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil {
+ t.Fatal(err)
+ }
+ after, err := f.p.ledger.read(a.ID)
+ if err != nil || !after.Complete || len(after.Continuations) != 1 {
+ t.Fatalf("record %+v %v", after, err)
+ }
+ if !before.ParkedAt.IsZero() && !before.ReapAfter.Equal(after.ReapAfter) {
+ t.Fatal("deadline reset")
+ }
+ if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, original); err == nil {
+ t.Fatal("old successor completed")
+ }
+ })
+ }
+}
+
+func TestServingLegacyRootProductionRestoreSequence(t *testing.T) {
+ for _, failure := range []string{"", "enrolled-refusal", "missing-marker"} {
+ t.Run("first-restore-"+failure, func(t *testing.T) { runServingLegacyRestoreSequence(t, failure) })
+ }
+}
+
+func runServingLegacyRestoreSequence(t *testing.T, failure string) {
+ f, in := newServingLegacyRootFixture(t, false)
+ plan := plan12RestoreBaseline(t, f.f)
+ var legacy servingParkRecord
+ for _, generation := range []string{"G", "G2"} {
+ if generation == "G2" {
+ prior := f.current
+ target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "controller"})
+ if err := f.f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/parent", Device: 1, Inode: 2}}}); err != nil {
+ t.Fatal(err)
+ }
+ servingStop(t, f.f, f.f.read())
+ o := f.f.read()
+ admission := hostclaim.Admission{ObservedSession: prior.Session, ObservedGeneration: prior.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: generation}
+ var err error
+ f.admitted, err = hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(admission))
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.current, err = servingWriterIdentity(f.admitted, "box-1", "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.Admission = admission
+ f.install(f.admitted)
+ }
+ r := &claimServingArtifactRetirement{Claims: f.f.claims, Guard: f.f.guard, DataDir: f.f.guard.DataDir, Box: f.actor.Box, Region: f.actor.Region, Root: servingRootClientFixture{f}}
+ work := servingRootWork{f.f.guard, f.admitted}
+ restore := hostbox.ColdRestore{Reader: f.f.data, Bucket: f.f.bucket, Generation: generation, Work: work, BeforeLegacyInstall: func(ctx context.Context, _, _ string) error { return r.BeforeLegacyInstall(ctx, f.admitted, in, work) }, BeforeInstallLocked: func(ctx context.Context, _, _ string) error { return r.EnrollLocked(ctx, f.admitted, in) }, RetireArtifacts: func(ctx context.Context, g string) error { return r.Retire(ctx, f.admitted, in, g) }}
+ if err := r.Recover(t.Context(), f.admitted, in); err != nil {
+ t.Fatal(err)
+ }
+ if generation == "G" && failure == "enrolled-refusal" {
+ enroll := restore.BeforeInstallLocked
+ restore.BeforeInstallLocked = func(ctx context.Context, db, gen string) error {
+ if err := enroll(ctx, db, gen); err != nil {
+ return err
+ }
+ return os.ErrPermission
+ }
+ if err := restore.Install(t.Context(), f.db, plan); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("enrollment fault: %v", err)
+ }
+ if _, err := os.Stat(f.db); !os.IsNotExist(err) {
+ t.Fatalf("failed restore left database: %v", err)
+ }
+ } else {
+ if err := restore.Install(t.Context(), f.db, plan); err != nil {
+ t.Fatal(err)
+ }
+ if got := mwReadDB(t, f.db); got != "selected recovery baseline" {
+ t.Fatal(got)
+ }
+ marker, err := hostbox.ReadGenerationMarker(f.db + ".gen")
+ if err != nil || marker != generation {
+ t.Fatalf("marker %q %v", marker, err)
+ }
+ // Reproduce the durable state after database rename but before
+ // marker installation, with the actual restored SQLite database.
+ if generation == "G" && failure == "missing-marker" {
+ if err := os.Remove(f.db + ".gen"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ slot, err := f.p.ledger.slot(servingParkAuthorization{Successor: f.current})
+ if err != nil {
+ t.Fatal(err)
+ }
+ record, err := f.p.ledger.read(slot.ID)
+ if err != nil || !record.Authorization.Legacy {
+ t.Fatalf("legacy displaced %+v %v", record, err)
+ }
+ if generation == "G" {
+ legacy = record
+ } else if !reflect.DeepEqual(legacy, record) {
+ t.Fatal("never-launched restore replaced legacy")
+ }
+ }
+ for suffix, want := range map[string]string{"": "legacy bytes", "-wal": "legacy WAL", "-shm": "legacy SHM"} {
+ b, err := os.ReadFile(filepath.Join(f.f.guard.DataDir, "instances", ".serving-parked", legacy.Authorization.ID, filepath.Base(f.db)+suffix))
+ if err != nil || string(b) != want {
+ t.Fatalf("legacy %q %v", b, err)
+ }
+ }
+}
+
+func TestServingLegacyRootConsumedAfterExpiry(t *testing.T) {
+ f, _ := newServingLegacyRootFixture(t, false)
+ a, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.tags(f.current.Stamp)
+ if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil {
+ t.Fatal(err)
+ }
+ f.f.now = f.f.now.Add(7 * 24 * time.Hour)
+ if err = f.p.ledger.withNamespace(t.Context(), func() error { return f.p.ledger.reapLocked(t.Context(), a.ID) }); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(f.db, []byte("restored successor"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { c.Inventory = nil; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{}); err == nil {
+ t.Fatal("reopened legacy source")
+ }
+ b, err := os.ReadFile(f.db)
+ if err != nil || string(b) != "restored successor" {
+ t.Fatalf("source %q %v", b, err)
+ }
+}
+
+func TestServingLegacyRootAdmittedPredecessor(t *testing.T) {
+ f := newServingRootFixture(t, true)
+ if err := os.Remove(f.db + ".gen"); err != nil {
+ t.Fatal(err)
+ }
+ _, err := hostclaim.Update(t.Context(), f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { c.Inventory = nil; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{Session: f.prior.Session, Generation: f.prior.Generation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.tags(f.current.Stamp)
+ if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil {
+ t.Fatal(err)
+ }
+ r, err := f.p.ledger.read(a.ID)
+ if err != nil || !r.Complete || r.LegacyEvidence.NoProcess != nil || len(r.Receipts) != 1 {
+ t.Fatalf("record %+v %v", r, err)
+ }
+}
+
+func TestServingLegacyRootContinuationNeedsStop(t *testing.T) {
+ f, _ := newServingLegacyRootFixture(t, false)
+ a, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ original := f.current
+ o := f.f.read()
+ later, err := hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "G2"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.current, err = servingWriterIdentity(later, "box-1", "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.install(later)
+ if _, err = f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{}); err == nil {
+ t.Fatal("continued without stop")
+ }
+ r, err := f.p.ledger.read(a.ID)
+ if err != nil || r.activeAuthorization().Successor != original || len(r.Continuations) != 0 {
+ t.Fatalf("changed record %+v %v", r, err)
+ }
+}
+
+func TestServingLegacyRootEmptyContinuationAfterConsumption(t *testing.T) {
+ f, _ := newServingLegacyRootFixture(t, true)
+ original := f.current
+ f.p.ledger.state = &servingParkFaultStore{Store: f.disk, failAt: 1}
+ if _, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{Empty: true}); err == nil {
+ t.Fatal("consumption fault missed")
+ }
+ f.p.ledger.state = f.disk
+ servingStop(t, f.f, f.f.read())
+ o := f.f.read()
+ later, err := hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "G2"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.current, err = servingWriterIdentity(later, "box-1", "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.install(later)
+ a, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.OriginalSuccessor != original {
+ t.Fatal("lost first-creator identity")
+ }
+ f.tags(f.current.Stamp)
+ if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil {
+ t.Fatal(err)
+ }
+ slot, err := f.p.ledger.slot(a)
+ if err != nil || slot.ID != "" {
+ t.Fatalf("empty bootstrap took retained slot: %+v %v", slot, err)
+ }
+}
+
+type servingEmptyRaceReader struct {
+ genselect.Reader
+ during func()
+}
+
+func (r servingEmptyRaceReader) ListVersions(ctx context.Context, bucket, prefix string) ([]lease.ObjectVersion, error) {
+ versions, err := r.Reader.ListVersions(ctx, bucket, prefix)
+ if r.during != nil {
+ r.during()
+ }
+ return versions, err
+}
+func TestServingLegacyRootEmptyProofClaimRace(t *testing.T) {
+ f, _ := newServingLegacyRootFixture(t, true)
+ f.p.empty.Data = servingEmptyRaceReader{Reader: f.p.empty.Data, during: func() {
+ _, err := hostclaim.Update(t.Context(), f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }}
+ before, _ := f.disk.Names()
+ if _, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{Empty: true}); err == nil {
+ t.Fatal("stale first-creator proof authorized capture")
+ }
+ after, _ := f.disk.Names()
+ if !reflect.DeepEqual(before, after) {
+ t.Fatalf("stale proof wrote records: %v", after)
+ }
+}
+
+func TestServingLegacyRootNoProcessRetryKeepsOriginalEvidence(t *testing.T) {
+ for _, changedIdentity := range []bool{false, true} {
+ t.Run(fmt.Sprint(changedIdentity), func(t *testing.T) {
+ newServingLegacyRootFixture(t, false, func(f *servingRootFixture, c *ClaimStopCoordinator, req StopRequest) {
+ o := f.f.read()
+ a := servingArtifactIdentity{Host: o.Host, Box: req.Box, Region: req.Region, Incarnation: req.Incarnation}
+ old, err := f.p.ledger.legacyStop(a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reserve := hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: req.Incarnation, EpisodeID: "first-episode", Attempt: hostclaim.WakeRetryAttempt{ID: "first-attempt", Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, InvocationID: "invocation", LaunchInputs: "digest"}, Now: f.f.now})
+ next, err := hostclaim.Update(t.Context(), f.f.claims, o, func(claim *hostclaim.Claim) error {
+ if err := reserve(claim); err != nil {
+ return err
+ }
+ if changedIdentity {
+ claim.Holder.Location += "/" + claim.Holder.Incarnation
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.f.peer.BootID, f.f.peer.ControllerID = "next-boot", "next-controller"
+ req.ExpectedClaimETag, req.BootID, req.ControllerID = next.ETag, f.f.peer.BootID, f.f.peer.ControllerID
+ _, err = c.FenceAndStop(t.Context(), req)
+ if (err != nil) != changedIdentity {
+ t.Fatalf("repeated no-process stop: %v (changed identity %t)", err, changedIdentity)
+ }
+ after, err := f.p.ledger.legacyStop(a)
+ if err != nil || !reflect.DeepEqual(after, old) {
+ t.Fatal("replaced original no-process evidence")
+ }
+ })
+ })
+ }
+}
diff --git a/internal/host/claimservingpark_records.go b/internal/host/claimservingpark_records.go
index aa451e1c5..caf2ad260 100644
--- a/internal/host/claimservingpark_records.go
+++ b/internal/host/claimservingpark_records.go
@@ -9,6 +9,7 @@ import (
"errors"
"io"
"os"
+ "reflect"
"time"
"github.com/carlosframework/platform/internal/hostbox"
@@ -41,6 +42,7 @@ type servingParkSource struct {
}
type servingParkRecord struct {
+ LegacyEvidence *servingLegacyEvidence `json:",omitempty"`
Continuations []servingParkContinuation `json:",omitempty"`
Version int
Authorization servingParkAuthorization
@@ -66,8 +68,10 @@ func (r servingParkRecord) activeAuthorization() servingParkAuthorization {
}
type servingLegacyConsumption struct {
- Authorization servingParkAuthorization
- Source servingParkSource
+ LegacyEvidence *servingLegacyEvidence `json:",omitempty"`
+ Receipts []hostguard.FenceReceipt `json:",omitempty"`
+ Authorization servingParkAuthorization
+ Source servingParkSource
}
type servingParkSlot struct {
@@ -143,7 +147,11 @@ func validateServingParkRecord(r servingParkRecord) error {
return hostbox.ErrChanged
}
}
- if len(r.Receipts) == 0 {
+ if r.LegacyEvidence != nil && validateServingLegacyEvidence(a, r.LegacyEvidence) != nil {
+ return hostbox.ErrChanged
+ }
+ noProcess := a.Legacy && r.LegacyEvidence != nil && r.LegacyEvidence.NoProcess != nil
+ if (len(r.Receipts) == 0) != noProcess {
return hostbox.ErrChanged
}
seen := make(map[string]bool)
@@ -152,6 +160,9 @@ func validateServingParkRecord(r servingParkRecord) error {
if t.Host != s.Host || t.Box != s.Box || t.Region != s.Region || t.Incarnation != s.Incarnation || t.Stamp == 0 || t.Stamp >= s.Stamp || t.Session >= s.Session || t.Op != "" || t.Phase != "" || t.ObligationID != "" || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(context.Background(), receipt) != nil {
return hostbox.ErrChanged
}
+ if a.Legacy && r.LegacyEvidence != nil && t.Session != r.LegacyEvidence.Selector.Session {
+ return hostbox.ErrChanged
+ }
if !a.Legacy && (t.Stamp != a.Predecessor.Stamp || t.Session != a.Predecessor.Session) {
return hostbox.ErrChanged
}
@@ -301,6 +312,10 @@ func (l *servingParkLedger) withNamespace(ctx context.Context, fn func() error)
// parkLocked runs inside independently verified root host authority. Fresh
// capture is never substituted for the immutable source on a replay.
func (l *servingParkLedger) parkLocked(ctx context.Context, a servingParkAuthorization, receipts []hostguard.FenceReceipt) (servingParkRecord, error) {
+ return l.parkWithEvidenceLocked(ctx, a, receipts, nil)
+}
+
+func (l *servingParkLedger) parkWithEvidenceLocked(ctx context.Context, a servingParkAuthorization, receipts []hostguard.FenceReceipt, legacy *servingLegacyEvidence) (servingParkRecord, error) {
var zero servingParkRecord
if err := ctx.Err(); err != nil {
return zero, err
@@ -342,7 +357,14 @@ func (l *servingParkLedger) parkLocked(ctx context.Context, a servingParkAuthori
return zero, e
}
}
- r = servingParkRecord{Version: 1, Authorization: a, Source: source, Receipts: receipts, Disposition: disposition}
+ r = servingParkRecord{Version: 1, Authorization: a, Source: source, Receipts: receipts, LegacyEvidence: legacy, Disposition: disposition}
+ if legacy != nil && legacy.Selector.Empty {
+ for _, f := range source.Files {
+ if f.Present {
+ return zero, hostbox.ErrChanged
+ }
+ }
+ }
if e = validateServingParkRecord(r); e != nil {
return zero, e
}
@@ -350,10 +372,10 @@ func (l *servingParkLedger) parkLocked(ctx context.Context, a servingParkAuthori
if a.Legacy {
key := "legacy-" + servingParkKey(a.Successor.Box, a.Successor.Host+"\x00"+a.Successor.Incarnation) + ".json"
var consumed servingLegacyConsumption
- want := servingLegacyConsumption{Authorization: a, Source: source}
+ want := servingLegacyConsumption{Authorization: a, Source: source, Receipts: receipts, LegacyEvidence: legacy}
body, e := l.state.Read(key)
if e == nil {
- if servingParkDecode(body, &consumed) != nil || consumed != want {
+ if servingParkDecode(body, &consumed) != nil || !reflect.DeepEqual(consumed, want) {
return zero, hostbox.ErrChanged
}
} else if !errors.Is(e, os.ErrNotExist) {
@@ -369,7 +391,7 @@ func (l *servingParkLedger) parkLocked(ctx context.Context, a servingParkAuthori
} else if err != nil {
return zero, err
}
- if r.Authorization != a {
+ if r.Authorization != a || !sameServingLegacyEvidence(r.LegacyEvidence, legacy) || !sameServingRootStops(r.Receipts, receipts) {
return zero, hostbox.ErrChanged
}
if r.Closed || r.Complete {
@@ -606,3 +628,15 @@ func (l *servingParkLedger) replaceLocked(ctx context.Context, id string) error
}
return l.deleteLocked(ctx, &old, cause)
}
+
+func sameServingRootStops(a, b []hostguard.FenceReceipt) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].Target != b[i].Target || !a[i].EvidenceAt.Equal(b[i].EvidenceAt) || !bytes.Equal(a[i].Bytes, b[i].Bytes) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/host/claimservingparkroot_linux.go b/internal/host/claimservingparkroot_linux.go
index 35dd848a5..87acf5c7c 100644
--- a/internal/host/claimservingparkroot_linux.go
+++ b/internal/host/claimservingparkroot_linux.go
@@ -3,7 +3,6 @@
package host
import (
- "bytes"
"context"
"net"
"path/filepath"
@@ -17,12 +16,13 @@ import (
)
// servingParkRoot composes local root authority with the filesystem ledger.
-// Production construction remains disabled until legacy authorization, journal
-// transport and startup recovery are implemented. Requests supply identities
+// Production construction remains disabled until journal transport and startup
+// recovery are implemented. Requests supply identities
// only; installation paths and receipts are read under root's host lock.
type servingParkRoot struct {
root ClaimRootHTTP
ledger *servingParkLedger
+ empty *claimServingOwners
}
func (p *servingParkRoot) withAuthority(ctx context.Context, conn net.Conn, current servingArtifactIdentity, fn func(hostclaim.Observed, hostguard.FenceView, hostbox.Actor, servingArtifactDirectory, uint64) error) error {
@@ -120,20 +120,9 @@ func servingRootStops(ctx context.Context, view hostguard.FenceView, prior servi
return out, nil
}
-func sameServingRootStops(a, b []hostguard.FenceReceipt) bool {
- if len(a) != len(b) {
- return false
- }
- for i := range a {
- if a[i].Target != b[i].Target || !a[i].EvidenceAt.Equal(b[i].EvidenceAt) || !bytes.Equal(a[i].Bytes, b[i].Bytes) {
- return false
- }
- }
- return true
-}
-
func (p *servingParkRoot) parkServingDatabase(ctx context.Context, conn net.Conn, current, requestedPrior servingArtifactIdentity) (servingParkAuthorization, error) {
var out servingParkAuthorization
+ resumeLegacy := false
err := p.withAuthority(ctx, conn, current, func(o hostclaim.Observed, view hostguard.FenceView, actor hostbox.Actor, dir servingArtifactDirectory, tags uint64) error {
var a servingParkAuthorization
var saved *servingParkRecord
@@ -153,7 +142,17 @@ func (p *servingParkRoot) parkServingDatabase(ctx context.Context, conn net.Conn
return err
}
a, saved = r.Authorization, &r
- if a.Legacy || a.Directory != dir {
+ if a.Legacy {
+ if requestedPrior != (servingArtifactIdentity{}) {
+ prior, e := servingPredecessor(o.Claim, current, tags, requestedPrior.Generation)
+ if e != nil || prior != requestedPrior {
+ return hostbox.ErrChanged
+ }
+ }
+ resumeLegacy = true
+ return nil
+ }
+ if a.Directory != dir {
return hostbox.ErrChanged
}
active := r.activeAuthorization()
@@ -222,6 +221,9 @@ func (p *servingParkRoot) parkServingDatabase(ctx context.Context, conn net.Conn
out = r.activeAuthorization()
return nil
})
+ if err == nil && resumeLegacy {
+ return p.parkLegacyServingDatabase(ctx, conn, current, servingLegacyPredecessor{})
+ }
return out, err
}
@@ -235,9 +237,18 @@ func (p *servingParkRoot) completeServingDatabasePark(ctx context.Context, conn
if err = validateServingRootHistory(ctx, o, view, actor, r); err != nil {
return err
}
- if a.Legacy || a.Successor != current || a.Directory != dir || tags != current.Stamp {
+ if a.Successor != current || a.Directory != dir || tags != current.Stamp {
return hostbox.ErrChanged
}
+ if a.Legacy {
+ if err = p.validateLegacyRootEvidence(ctx, o, view, actor, r.Authorization, r.LegacyEvidence, r.Receipts); err != nil {
+ return err
+ }
+ if err = p.ledger.legacyUnlaunched(ctx, current, actor.Enrollment); err != nil {
+ return err
+ }
+ return p.ledger.completeLocked(ctx, id, current)
+ }
prior, err := servingPredecessor(o.Claim, current, a.Predecessor.Stamp, a.Predecessor.Generation)
if err != nil || prior != a.Predecessor {
return hostbox.ErrChanged
diff --git a/internal/host/claimservingparkroot_linux_test.go b/internal/host/claimservingparkroot_linux_test.go
index 1ac5b5b0c..479e845e8 100644
--- a/internal/host/claimservingparkroot_linux_test.go
+++ b/internal/host/claimservingparkroot_linux_test.go
@@ -336,8 +336,8 @@ type servingRootClientFixture struct{ f *servingRootFixture }
func (c servingRootClientFixture) Park(ctx context.Context, current, prior servingArtifactIdentity) (servingParkAuthorization, error) {
return c.f.p.parkServingDatabase(ctx, c.f.conn, current, prior)
}
-func (c servingRootClientFixture) ParkLegacy(context.Context, servingArtifactIdentity, servingLegacyPredecessor) (servingParkAuthorization, error) {
- return servingParkAuthorization{}, hostbox.ErrUnsupported
+func (c servingRootClientFixture) ParkLegacy(ctx context.Context, current servingArtifactIdentity, prior servingLegacyPredecessor) (servingParkAuthorization, error) {
+ return c.f.p.parkLegacyServingDatabase(ctx, c.f.conn, current, prior)
}
func (c servingRootClientFixture) Complete(ctx context.Context, id string, current servingArtifactIdentity) error {
return c.f.p.completeServingDatabasePark(ctx, c.f.conn, id, current)
diff --git a/internal/host/claimstop.go b/internal/host/claimstop.go
index 3cb91f74d..d04ba5fe7 100644
--- a/internal/host/claimstop.go
+++ b/internal/host/claimstop.go
@@ -128,18 +128,19 @@ type StopResult struct {
}
type ClaimStopCoordinator struct {
- EvidenceSigner ed25519.PrivateKey
- RequestLimit time.Duration
- Guard hostguard.Guard
- Claims StopClaimReader
- Peers StopPeerProvider
- Inventory StopInventoryReader
- Drainer StopDrainer
- Kernel StopKernelVerifier
- Evidence release.Store
- CarlosUID uint32
- NewID func() string
- Now func() time.Time
+ RetainNoProcess func(context.Context, hostclaim.Observed, StopResult) error
+ EvidenceSigner ed25519.PrivateKey
+ RequestLimit time.Duration
+ Guard hostguard.Guard
+ Claims StopClaimReader
+ Peers StopPeerProvider
+ Inventory StopInventoryReader
+ Drainer StopDrainer
+ Kernel StopKernelVerifier
+ Evidence release.Store
+ CarlosUID uint32
+ NewID func() string
+ Now func() time.Time
}
func stopComponent(s string) bool {
@@ -383,7 +384,7 @@ func (c ClaimStopCoordinator) witnessNoProcess(ctx context.Context, req StopRequ
return StopResult{}, ErrClaimStopIdentity
}
claim, holder := observed.Claim, observed.Claim.Holder
- if holder.Incarnation != req.Incarnation || holder.Stamp != req.InstallationStamp || holder.Session != 0 || holder.WriterBox != "" || holder.Generation != "" || len(holder.Admissions) != 0 {
+ if holder.Incarnation != req.Incarnation || holder.Stamp != req.InstallationStamp || holder.Session != 0 || (holder.WriterBox != "" && (c.RetainNoProcess == nil || holder.WriterBox != req.Box || claim.Op.Kind != hostclaim.KindServing)) || holder.Generation != "" || len(holder.Admissions) != 0 {
return StopResult{}, ErrClaimStopIdentity
}
target := hostguard.FenceTarget{Region: req.Region, ClaimGen: claim.Gen, ObligationID: req.ObligationID, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp,
@@ -431,6 +432,11 @@ func (c ClaimStopCoordinator) witnessNoProcess(ctx context.Context, req StopRequ
}
sum := sha256.Sum256(body)
result := StopResult{Target: target, Key: stopEvidenceKey(req.Host, req.OperationID, req.Box, receiptID), Digest: hex.EncodeToString(sum[:]), Bytes: body, EvidenceAt: evidenceAt}
+ if c.RetainNoProcess != nil && claim.Op.Kind == hostclaim.KindServing {
+ if err := c.RetainNoProcess(ctx, observed, result); err != nil {
+ return StopResult{}, err
+ }
+ }
if err := c.Publish(ctx, result); err != nil {
return result, err
}
diff --git a/internal/host/claimstop_noprocess_test.go b/internal/host/claimstop_noprocess_test.go
index 41364286a..18547ce04 100644
--- a/internal/host/claimstop_noprocess_test.go
+++ b/internal/host/claimstop_noprocess_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"strings"
"testing"
@@ -116,8 +117,11 @@ func TestNoProcessWitnessRefuses(t *testing.T) {
"a writer was admitted": {func(_ *ClaimStopCoordinator, _ *StopRequest, _ *noProcessKernelFake, cl *changingClaims) {
cl.obs.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Box: "box-1", Session: 1}}
}, ErrClaimStopIdentity},
- "a writer box is named": {func(_ *ClaimStopCoordinator, _ *StopRequest, _ *noProcessKernelFake, cl *changingClaims) {
- cl.obs.Claim.Holder.WriterBox = "box-1"
+ "a legacy holder without retention": {func(_ *ClaimStopCoordinator, r *StopRequest, _ *noProcessKernelFake, cl *changingClaims) {
+ cl.obs.Claim.Holder.WriterBox = r.Box
+ }, ErrClaimStopIdentity},
+ "a foreign writer box is named": {func(_ *ClaimStopCoordinator, _ *StopRequest, _ *noProcessKernelFake, cl *changingClaims) {
+ cl.obs.Claim.Holder.WriterBox = "box-2"
}, ErrClaimStopIdentity},
"another executor": {func(_ *ClaimStopCoordinator, _ *StopRequest, _ *noProcessKernelFake, cl *changingClaims) {
cl.obs.Claim.Op.Exec = "box-2"
@@ -205,3 +209,37 @@ func TestFailedSessionZeroAdmissionStopsNothing(t *testing.T) {
t.Fatalf("an admitted session was not stopped: %v, stops %d", err, stops)
}
}
+
+func TestNoProcessWitnessRetainsBeforePublication(t *testing.T) {
+ for _, reject := range []bool{false, true} {
+ t.Run(fmt.Sprint(reject), func(t *testing.T) {
+ c, req, _, claims := noProcessFixture(t)
+ claims.obs.Claim.Holder.WriterBox = req.Box
+ calls := 0
+ c.RetainNoProcess = func(ctx context.Context, o hostclaim.Observed, result StopResult) error {
+ calls++
+ if o.ETag != req.ExpectedClaimETag || o.Claim.Holder.Session != 0 || result.Target.FenceID != "" {
+ t.Fatal("wrong retained observation")
+ }
+ keys, err := c.Evidence.List(ctx, "control/")
+ if err != nil || len(keys) != 0 {
+ t.Fatal("published before retention")
+ }
+ if reject {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
+ _, err := c.FenceAndStop(t.Context(), req)
+ if calls != 1 || (err != nil) != reject {
+ t.Fatalf("retention %d %v", calls, err)
+ }
+ if reject {
+ keys, _ := c.Evidence.List(t.Context(), "control/")
+ if len(keys) != 0 {
+ t.Fatal("published despite failed retention")
+ }
+ }
+ })
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv
index 70e5eb676..ec2dd7d86 100644
--- a/internal/provision/testdata/plan14-root-reads-seed.tsv
+++ b/internal/provision/testdata/plan14-root-reads-seed.tsv
@@ -1,5 +1,5 @@
-# Files seed checkpoint 4 overlay; pending Paul’s census ruling.
-# Root generation authorization and continuation have test callers only; production binding is refused.
+# Files seed checkpoint 5 overlay; pending Paul’s census ruling.
+# Generation, legacy and empty-bootstrap root authority have test callers only; production binding remains refused.
# Load after zddr4 and its explicit seal check. No legacy fingerprints are added or removed.
# Every classification below is rev3=PROPOSED-seed, not an approved disposition.
internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,G rev3=PROPOSED-seed; box process identity and provider bindings; serving dispatch independent of moves; serving enrollment required before a runtime can be built file:Read@4150,file:Read@9550,file:ReadFile@3694,registry:Host@9577,whole:Route@9665 088f34c7fe1b4f9bb7a3187d40d60bff0fd811ebf1e1888ce3d4781dc4de80d7
@@ -56,7 +56,7 @@ internal/host/claimservingpark_records.go servingParkDecode authority C,J4 rev3=
internal/host/claimservingpark_records.go servingParkKey authority C,J4 rev3=PROPOSED-seed; box/host identity used for stable per-host namespace and slot keys 67518302550380dbf9b741a73758e5c94307eff70ff8a09cc56763a8aa794d86
internal/host/claimservingpark_records.go servingParkLedger.completeLocked authority C,J4 rev3=PROPOSED-seed; root retirement and pending-slot records plus fresh filesystem absence/identity checks; completion requires outer root claim-tag authority which remains unbound 00f9c10b8130c134a41dea74848aa1651ae3331c8440a81e1b3f59f54f6a7218
internal/host/claimservingpark_records.go servingParkLedger.deleteLocked authority C,J4 rev3=PROPOSED-seed; complete retained record or proved discard disposition and persisted cause/progress before filesystem deletion 1545950c564d51d9c29b0d76daca1ddec5a159481698b11accd4dd7411157b37
-internal/host/claimservingpark_records.go servingParkLedger.parkLocked authority C,J4 rev3=PROPOSED-seed; immutable source capture, root-local launch journal, exact stop bytes, legacy consumption, pending work and incumbent slot; only started databases replace slots; outer root authorization remains unbound file:Prepare@3247,file:Read@1693 c46ddfb8aaf8339eb6d5dc414724ead938338552477825efe6d1e6f677897b32
+internal/host/claimservingpark_records.go servingParkLedger.parkLocked authority C,J4 rev3=PROPOSED-seed; generation or test-fixture legacy entry delegates to immutable evidence-aware ledger; no independent source authority 1f16bea822e6c63df15c8259c8972a4084cdf364769f10396f396424e0fe848c
internal/host/claimservingpark_records.go servingParkLedger.read authority C,J4 rev3=PROPOSED-seed; private retirement record by validated deterministic ID and strict record invariants file:Read@249 868c8c3f5682dc36738d6960d57f2f1fcc6512374c8f8e0790187bf69cf3de64
internal/host/claimservingpark_records.go servingParkLedger.reapLocked authority C,J4 rev3=PROPOSED-seed; root completion, retention disposition and fixed seven-day deadline; rollback or pending work refuses expiry aec4f50b45d1d955fac9814fe9e0d403f5f3f7f939c71f8f6b8ac15e9173b846
internal/host/claimservingpark_records.go servingParkLedger.save authority C,J4 rev3=PROPOSED-seed; validated private retirement record before durable rootstate write b6f940b1e21cf1beb67638effea958d2b1119695494a562e22bbbe0469d8af49
@@ -64,18 +64,32 @@ internal/host/claimservingpark_records.go servingParkLedger.slot authority C,J4
internal/host/claimservingpark_records.go servingParkLedger.withNamespace authority C,J4 rev3=PROPOSED-seed; private rootstate namespace lock and cancellation before invoking root authority; composed with root host authority in generation tests; production remains unbound b7cfae44fdb7de9ef8130f5581710ae3a1a526bb059b4f616650172490f5c8ac
internal/host/claimservingpark_records.go servingParkLedger.write authority C,J4 rev3=PROPOSED-seed; serialized private record sent only to the supplied rootstate store 0635d2c27871e67079119a0f2e3034e817b765d0fe3a47e7d9bdc66fb2779571
internal/host/claimservingpark_records.go type servingParkFilesystem authority C,J4 rev3=PROPOSED-seed; private filesystem effect interface over root-recorded retirement identities; no wire or public request fields file:Prepare@180 b93f0e2af16b66aee1ff71294e215eeba9e661fe696c917c982d9ebc2dc682c7
-internal/host/claimservingpark_records.go validateServingParkRecord authority C,J4 rev3=PROPOSED-seed; version, immutable tuples, exact serving receipt bytes, source identities, per-file progress, disposition, completion and retention deadline invariants; bounded immutable continuation history and exact predecessor stops db48025925e4b5acaf3231fdbf4d19900f406017b402efdf5ba1cb2550d00bc6
+internal/host/claimservingpark_records.go validateServingParkRecord authority C,J4 rev3=PROPOSED-seed; version, immutable tuples, exact serving receipt bytes, source identities, per-file progress, disposition, completion and retention deadline invariants; bounded immutable continuation history and exact predecessor stops 7164d44093a2bf9efd5d8798d8bf6674f5b399bca163d098b3f2c9b100e9ea87
internal/host/claimservingparksource_linux.go servingParkStateDirectory authority C,J4 rev3=PROPOSED-seed; root installation/inventory snapshots and authoritative serving observation; exact account/app/record, revision/enrollment, canonical nested path, ownership and descriptor identity; root acquisition under host lock is exercised by generation tests; production remains unbound ec4a121330c5c8f27343f21af0bbe458cced885d4754e96c0d87b12d9b4a8f35
internal/hostguard/servingretirement.go Guard.WithServingRetirement authority C,J4 rev3=PROPOSED-seed; fresh serving claim and root installation/fence snapshot under root and shared host locks; refuses pending, quarantined, foreign, retired or already stopped successors; permits an older completed predecessor 3d105c50be7e613777055f561321efadf77fe823d8dc3263a60f2c1211b71108
internal/host/claimservingparkroot_linux.go servingParkRoot.withAuthority authority C,J4 rev3=PROPOSED-seed; independent active-edge peer authentication, census gate, roster, local root inventory and fresh claim recheck under namespace and root host authority; no registry path or caller receipt file:Read@2432 6d0c84a5a9c5403f166db4f200504a3ccd75ebcd7ddac1736e3ac046237a2882
internal/host/claimservingparkroot_linux.go servingRootStops authority C,J4 rev3=PROPOSED-seed; root fence snapshot and known installation stamps; exact local serving receipt bytes, enrollment, revision and unique fence identities d16eb077adfb8f0f8c9e63d1609250dc3470ec424bdde63b57c47d09500b2bce
-internal/host/claimservingparkroot_linux.go sameServingRootStops authority C,J4 rev3=PROPOSED-seed; byte-exact comparison of persisted and freshly selected root receipt sets, including every target field and evidence time c9e0803635546eecc3a7d07760cb69fc915182f96b0d1581ed655de3382edd88
-internal/host/claimservingparkroot_linux.go servingParkRoot.parkServingDatabase authority C,J4 rev3=PROPOSED-seed; fresh paired claim tags, root slot and retirement records, retained predecessor and successor admissions, root stop history and source identity before park or continuation 802f4b8f5a2e49c3d6d4b0530c8cc484d29abf0a25ae852d1e7f39509d0e986e
-internal/host/claimservingparkroot_linux.go servingParkRoot.completeServingDatabasePark authority C,J4 rev3=PROPOSED-seed; fresh paired enrollment, same current writer, root source identity and complete original/continuation stop history before durable completion f120d235529b61ce93167f4c364a962b7dc4197ba0611719547015182cba96e1
+internal/host/claimservingparkroot_linux.go servingParkRoot.parkServingDatabase authority C,J4 rev3=PROPOSED-seed; fresh paired tags, root slot/record and retained admissions; recorded legacy work dispatches to separately locked fresh legacy authority 61c29d5b37168ab053499553eaf0052fcee134a601c86acb3195fad416d28247
+internal/host/claimservingparkroot_linux.go servingParkRoot.completeServingDatabasePark authority C,J4 rev3=PROPOSED-seed; fresh paired enrollment, same current writer, root source and original/continuation stop history; legacy additionally authenticates persistent no-process or admitted predecessor evidence 8ca9bdd3c1209c6d5257bc07a7d12d0bab900e9c2911d0698077eb2116e5123c
internal/host/claimservingparkroot_linux.go validateServingRootHistory authority C,J4 rev3=PROPOSED-seed; retained local successor admissions and byte-exact root stops for every persisted continuation; rejects relabelled or missing history c85a1be96de5fa61acd48c4adf9393d8e837880f5133a2f965f1104037e02329
internal/host/claimservingpark_records.go servingParkRecord.activeAuthorization authority C,J4 rev3=PROPOSED-seed; last persisted continuation authorization or immutable original authorization; no source or deadline changes fe168fe52317e45ce70c3e97a9bc2c52d2409328ab4844a8d2a34fc4babba36d
internal/host/claimservingpark_records.go servingParkLedger.continueLocked authority C,J4 rev3=PROPOSED-seed; namespace/host-protected slot and record locks; pending original retirement, absent source and retained parked identities before durable bounded continuation 87f297a924f7f153624304233218d8b5548a016fc7dbf0f7cb8dedb789324009
internal/host/claimservingpark_records.go servingParkLedger.replaceLocked authority C,J4 rev3=PROPOSED-seed; completed incumbent record under its record lock; resumes its recorded deletion cause before slot replacement 3e67316e959c9a901c32a2a61fda4e6ea5e92fe51bdb7fdb5f5c53d9d5960aad
-@coverage internal/host 9900e8ad9fe9d49c4359c0d7d11ff5a804a5b7b54328a50bcf6c3dfde91f771e
+@coverage internal/host 948bdc0ad5d699a917ce834c2e4f48c44486d72a5c689c5e9da9b748c88fe486
@coverage internal/hostbox 72c9b640d98c779a044579f70a103a68cd9819c794a0bb3e53f378d39ad11419
@coverage internal/hostguard 94912485e1e5fad4bad56985dd48ede776cfcb5fe18e0f4a59953584ddab06c0
+internal/host/claimservinglegacy.go servingLegacyKey authority C,J4 rev3=PROPOSED-seed; box, host and incarnation derive private stop and consumption keys; never a caller path 93c10e40202ab23986b54a366edd7abfab9f8d942736305b5732e8741e96f3ab
+internal/host/claimservinglegacy.go validateServingLegacyStop authority C,J4 rev3=PROPOSED-seed; original session-zero serving observation and exact root no-process bytes; claim ETag, receipt digest, kernel evidence and empty fence identity file:Exec@413 88bf4d578819b8d3f62b80e2068a4c574a1aaa8118a8461bbeee99569e035ed0
+internal/host/claimservinglegacy.go servingParkLedger.legacyStop authority C,J4 rev3=PROPOSED-seed; strict private retained no-process record and exact host, box, region and incarnation; no bucket or caller evidence file:Read@131 3cc2122431a095406bcd11a1a6ace825a870f5f8d0b8053d2b27486084838c4f
+internal/host/claimservinglegacy.go servingParkLedger.legacyConsumption authority C,J4 rev3=PROPOSED-seed; private persistent consumption record for the box, host and incarnation; absence differs from malformed or unavailable state file:Read@121 f8acefe9ac0f00640b68e7ad51d02b6544fb06c4130863d9159f8e2800281ad8
+internal/host/claimservinglegacy.go sameServingLegacyEvidence authority C,J4 rev3=PROPOSED-seed; exact immutable legacy selector and retained no-process observation comparison 806588dfe151a558273222fcd77c6f780a72e293f312531ab7ba9fcde7a9e46f
+internal/host/claimservinglegacy.go validateServingLegacyEvidence authority C,J4 rev3=PROPOSED-seed; original successor, legacy selector and original session-zero holder; first-creator status is not a request assertion 485ab1232cc0817ad52a638bb220fe532c5a16086379e689b1c27dea6948f6b5
+internal/host/claimservinglegacy.go servingParkLedger.legacyUnlaunched authority C,J4 rev3=PROPOSED-seed; local root launch history for current successor; any main-role intention refuses legacy source capture and completion 1963414899b4ce2bb059950bc6cfdad4e8c5ea0a575d2e19f87c91a4cf82c8ad
+internal/host/claimservinglegacyroot_linux.go servingParkRoot.retainLegacyNoProcess authority C,J4 rev3=PROPOSED-seed; root coordinator result before publication; namespace and host guards, census, roster, fresh original claim and immutable retained stop; repeated preadmission witnesses tolerate only Gen, ETag and retry metadata changes file:Read@1210 5460530ae4367720515089e9fda3105aa1032b8282f4923bbf7f8af9600f4f63
+internal/host/claimservinglegacyroot_linux.go servingParkRoot.validateLegacyRootEvidence authority C,J4 rev3=PROPOSED-seed; retained original observation, first cold admission and writes-lost identity or exact admitted-predecessor root stops; enrollment and revision are rechecked 8ebc1e7841b8b8cfabc8e4f3520ef13d40130e68aa7ded608b829063e3d3f89a
+internal/host/claimservinglegacyroot_linux.go servingParkRoot.parkLegacyServingDatabase authority C,J4 rev3=PROPOSED-seed; current root authority, persistent consumption, immutable original stop/source and continuation stops; production first-creator empty listing and final claim recheck precede source capture file:Read@3463 4a883d8cc15d58a9a9572d08790173773f35afd46b09caaf9ac99c9ec3a39d1c
+internal/host/claimservinglegacyroot_linux.go sameServingLegacySource authority C,J4 rev3=PROPOSED-seed; immutable directory and artifact identities from consumed source and retirement; mutable rename/delete progress is excluded 925e4f20aee042895a83ea3787f4482ae732b6c6bcd45d9d10226ff87bd37091
+internal/host/claimservingpark_records.go servingParkLedger.parkWithEvidenceLocked authority C,J4 rev3=PROPOSED-seed; root source identities, exact stop bytes, launch disposition and optional session-zero evidence; persistent consumption precedes intent/rename, empty creators refuse all local artifacts file:Prepare@3632,file:Read@1957 257d2c2674dd3a781f6b1ec842d65128a8e9f3d03b5468cbf6f487c37b45e94f
+internal/host/claimservingpark_records.go sameServingRootStops authority C,J4 rev3=PROPOSED-seed; byte-identical root stop sets including targets, evidence timestamps and original receipt bytes c9e0803635546eecc3a7d07760cb69fc915182f96b0d1581ed655de3382edd88
+internal/host/claimstop.go ClaimStopCoordinator.witnessNoProcess authority C,G,J4 rev3=PROPOSED-seed; existing peer, claim, root inventory and positive kernel no-process checks; local adopted holder requires new retained-evidence binding; original observation is retained before receipt publication file:Exec@1845,file:Read@2541,file:Read@668 4a0df51297d2f6ed2e9c284ee11c26b73747bf84e1da5214d00f190eece5eb38
+internal/host/claimstop.go type ClaimStopCoordinator authority C,G,J4 rev3=PROPOSED-seed; root-only coordinator dependency for retaining its own session-zero observation; nil preserves existing refusal for adopted legacy holders b7114aa7340cdba82fef42d62b784eae16da7acc48407ae6edba6453a4889f9a