WIP files seed checkpoint 11 (not for merge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+1005
−121
internal/host/claimfilesseed_restore_linux_test.go+5 −6internal/host/claimhandover_recovery_test.go+29 −1internal/host/claimhandover_transport.go+36 −3internal/host/claimretire_transport.go+4 −1internal/host/claimservinghandover.go+139 −0internal/host/claimservinghandover_linux_test.go+216 −73internal/host/claimservinglegacyroot_linux.go+1 −1internal/host/claimservingpark_transport_linux_test.go+10 −2internal/host/claimservingparkroot_linux.go+1 −2internal/host/claimservingpeer_rootrunner_linux_test.go+40 −2internal/hostbox/coldhandover.go+25 −20internal/hostbox/servinghandover.go+288 −0internal/hostbox/servinghandover_linux_test.go+167 −0internal/hostguard/coldhandover.go+19 −2internal/provision/testdata/plan14-root-reads-seed.tsv+25 −8
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 361.476s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.274s
ok github.com/carlosframework/platform/cmd/moveprobe 93.231s
ok github.com/carlosframework/platform/cmd/zddprobe 0.711s
ok github.com/carlosframework/platform/internal/activator 233.953s
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.335s
ok github.com/carlosframework/platform/internal/apperr 0.073s
ok github.com/carlosframework/platform/internal/applog 0.137s
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.648s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.717s
ok github.com/carlosframework/platform/internal/changelog 0.305s
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 369.281s
? 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.147s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.004s
ok github.com/carlosframework/platform/internal/domains 2.147s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.311s
? 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.126s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.016s
ok github.com/carlosframework/platform/internal/geo 0.019s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/host 150.038s
ok github.com/carlosframework/platform/internal/hostactors 0.477s
ok github.com/carlosframework/platform/internal/hostbox 2.340s
ok github.com/carlosframework/platform/internal/hostclaim 0.556s
ok github.com/carlosframework/platform/internal/hostevidence 0.018s
ok github.com/carlosframework/platform/internal/hostexec 5.026s
ok github.com/carlosframework/platform/internal/hostfence 0.045s
ok github.com/carlosframework/platform/internal/hostfiles 10.548s
ok github.com/carlosframework/platform/internal/hostfloor 0.018s
ok github.com/carlosframework/platform/internal/hostguard 1.010s
ok github.com/carlosframework/platform/internal/hostops 1.512s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.017s
ok github.com/carlosframework/platform/internal/hostquarantine 0.017s
ok github.com/carlosframework/platform/internal/hostrelocate 0.493s
ok github.com/carlosframework/platform/internal/hostresolve 0.136s
ok github.com/carlosframework/platform/internal/hostwrite 0.017s
ok github.com/carlosframework/platform/internal/infraview 0.016s
ok github.com/carlosframework/platform/internal/instancectl 1.830s
ok github.com/carlosframework/platform/internal/instanceexec 2.748s
ok github.com/carlosframework/platform/internal/instancefs 0.022s
ok github.com/carlosframework/platform/internal/instancekey 0.018s
ok github.com/carlosframework/platform/internal/instanceupdates 0.024s
ok github.com/carlosframework/platform/internal/layout 1.482s
ok github.com/carlosframework/platform/internal/lease 9.473s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.022s
ok github.com/carlosframework/platform/internal/ledger 0.014s
ok github.com/carlosframework/platform/internal/mail 0.166s
ok github.com/carlosframework/platform/internal/maintenance 0.091s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.575s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.075s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.128s
ok github.com/carlosframework/platform/internal/moves/archive 0.796s
ok github.com/carlosframework/platform/internal/objstore 0.176s
ok github.com/carlosframework/platform/internal/ops 0.173s
ok github.com/carlosframework/platform/internal/park 0.210s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 13.104s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.127s
ok github.com/carlosframework/platform/internal/relay 7.222s
ok github.com/carlosframework/platform/internal/release 19.775s
ok github.com/carlosframework/platform/internal/rollout 3.819s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.009s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.321s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.189s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.025s
ok github.com/carlosframework/platform/internal/status 0.044s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.222s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.008s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.579s
=== 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.026s
=== RUN TestT4StorageProbeMinio
2026/10/14 18:36:57 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE793FEF644036, 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.024s
minio contract: PASS
diff --git a/internal/host/claimfilesseed_restore_linux_test.go b/internal/host/claimfilesseed_restore_linux_test.go| index 43b70d92d..617175f38 100644 |
| --- a/internal/host/claimfilesseed_restore_linux_test.go |
| +++ b/internal/host/claimfilesseed_restore_linux_test.go |
| @@ -33,7 +33,7 @@ import ( |
| // This fixture ends at Restore: a legacy holder has no C8 evidence yet. |
| // It uses the real selector, lease against local S3, retry wrapper, admission, |
| // activation, prelaunch journal, selected Work, stop coordinator and retirement |
| -// transport. Kernel checks, installation enrollment and launch preflight are |
| +// transport. Initial installation, kernel checks and launch preflight are |
| // explicit seams; it does not claim an installed-unit or privileged witness. |
| type servingPreparation struct { |
| f *servingRootFixture |
| @@ -60,12 +60,11 @@ func newServingPreparation(t *testing.T, f *servingRootFixture, boot, controller |
| producers := &ClaimProducerSet{} |
| w := &ClaimWakeAssembly{LeaseRegion: "eu-west-1", Manager: new(cgroup.Manager), Inputs: p.owners, RequireServingEnrollment: true} |
| p.wake = w |
| - w.AfterAdmission = func(ctx context.Context, _, admitted hostclaim.Observed) error { |
| + root := servingHandoverRoot(t, f) |
| + hook := coldAdmissionHook(f.f.guard, handoverRecoveryIdentity(t, f.actor), servingRootSocket(t, root)) |
| + w.AfterAdmission = func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| p.events = append(p.events, "admit") |
| - // The production root installation door requires privileged host inventory. |
| - // Retain that explicit seam, but do not advance it before admission. |
| - f.install(admitted) |
| - return nil |
| + return hook(ctx, prior, admitted) |
| } |
| w.Prelaunch = func(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) (*activator.RoleLaunch, error) { |
| p.events = append(p.events, "prelaunch") |
diff --git a/internal/host/claimhandover_recovery_test.go b/internal/host/claimhandover_recovery_test.go| index f1959faca..c2e52e469 100644 |
| --- a/internal/host/claimhandover_recovery_test.go |
| +++ b/internal/host/claimhandover_recovery_test.go |
| @@ -24,6 +24,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -98,7 +99,7 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| admitted := former.admit(t, f) |
| f.revision = 8 |
| restarted := f.controller(boot, "controller-2", 4202, "start-2") |
| - route := registry.Route{Host: recoveryHost, Kind: "instance", ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: admitted.Claim.Holder.Incarnation, ClaimStamp: admitted.Claim.Holder.Stamp, ClaimOwner: "old-installation", ClaimRowID: "old-route"}} |
| + route := registry.Route{Host: recoveryHost, Kind: "instance", Account: admitted.Claim.Holder.Account, App: admitted.Claim.Holder.App, DBPath: provision.NestedDBPath(f.guard.DataDir, recoveryHost), Socket: provision.NestedSocket(recoveryHost), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: admitted.Claim.Holder.Incarnation, ClaimStamp: admitted.Claim.Holder.Stamp, ClaimOwner: "old-installation", ClaimRowID: "old-route"}} |
| f.kernel.alive = true |
| if err := restarted.runtime.Prerequisites(ctx, route, f.read(), recoveryPolicy); !errors.Is(err, ErrClaimStopControllerAlive) { |
| t.Fatalf("live predecessor accepted: %v", err) |
| @@ -134,6 +135,27 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| } |
| owner := route.ClaimOwnership |
| in := hostbox.StopInventory{Complete: true, Host: route.Host, Incarnation: owner.ClaimIncarnation, InstallationStamp: owner.ClaimStamp, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "7", Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: route.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}} |
| + if err = os.MkdirAll(filepath.Dir(route.DBPath), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + rel, err := filepath.Rel(f.guard.DataDir, filepath.Dir(route.DBPath)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + roots := map[string]string{"data": f.guard.DataDir} |
| + a, err := (hostbox.TypedParker{Roots: roots, ParkedRoots: roots, Incarnation: owner.ClaimIncarnation, Host: route.Host, Retirement: "fixture"}).CaptureArtifact("data", rel, owner.ClaimOwner, hostbox.ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: owner.ClaimOwner, Materialization: "materialized", Device: a.Dev, Inode: a.Ino, Type: a.Type}) |
| + holder := prior.Claim.Holder |
| + body, err := json.Marshal(provision.Record{Schema: 1, Host: prior.Host, Account: holder.Account, App: holder.App, CreatedAt: holder.CreatedAt, Channel: "edge"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = f.claims.Put(ctx, holder.Key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| if err = inventory.WriteStopInventory(ctx, in); err != nil { |
| t.Fatal(err) |
| } |
| @@ -162,6 +184,12 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) { |
| coordinator := f.root(recoveryStopDrain{}) |
| coordinator.Guard = rootGuard |
| root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, Roots: map[string]string{"data": f.guard.DataDir}, Reconcile: hostbox.ReconcileAdapter{Box: "box-1", Enrollment: "enrollment-1", Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retire")}}} |
| + root.RequireNewWork = func(context.Context) error { return nil } |
| + root.InventoryActor = func(context.Context) (hostbox.Actor, error) { |
| + actor := restarted.local.Actor |
| + actor.Eligible = true |
| + return actor, nil |
| + } |
| socket := startRetirementRecoveryTransport(t, root) |
| unauthenticated := root |
| unauthenticated.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("unknown requester") } |
diff --git a/internal/host/claimhandover_transport.go b/internal/host/claimhandover_transport.go| index cf0f414cc..7d1e6d8f6 100644 |
| --- a/internal/host/claimhandover_transport.go |
| +++ b/internal/host/claimhandover_transport.go |
| @@ -14,6 +14,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| ) |
| type coldHandoverRequest struct { |
| @@ -29,7 +30,15 @@ func (h ClaimRootHTTP) serveColdHandover(w http.ResponseWriter, r *http.Request, |
| w.WriteHeader(http.StatusBadRequest) |
| return |
| } |
| - if request.Prior.BootID != peer.BootID || request.Prior.ControllerID != peer.ControllerID { |
| + if request.Prior.Session == 0 && request.Prior.Op == "" && request.Prior.Phase == "" { |
| + prior, err := h.servingLegacyHandoverTarget(r.Context(), request) |
| + if err != nil { |
| + w.WriteHeader(http.StatusConflict) |
| + return |
| + } |
| + request.Prior = prior |
| + } |
| + if request.Prior.Session != 0 && (request.Prior.BootID != peer.BootID || request.Prior.ControllerID != peer.ControllerID) { |
| // ServeHTTP authenticates the live requester independently. Recovery |
| // preserves the predecessor's recorded tuple; only its exact completed |
| // root receipt grants cleanup authority, never the requester's tuple. |
| @@ -43,8 +52,25 @@ func (h ClaimRootHTTP) serveColdHandover(w http.ResponseWriter, r *http.Request, |
| w.WriteHeader(http.StatusServiceUnavailable) |
| return |
| } |
| - handover := hostbox.ColdHandover{Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt} |
| - if err := handover.Run(r.Context(), request.Prior, request.Next); err != nil { |
| + handover := hostbox.ColdHandover{OperationsOnly: true, Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt} |
| + var err error |
| + if request.Next.Op == "" && request.Next.Phase == "" { |
| + serving := h.servingHandover() |
| + serving.Validate = func(ctx context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, n hostbox.ColdSuccessor, in hostbox.StopInventory, installation hostguard.RootInstallation) error { |
| + fresh, err := h.Stop.Authenticate(ctx, instancectl.RequestConn(r.Context())) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh != peer { |
| + return ErrClaimStopIdentity |
| + } |
| + return h.validateServingHandover(ctx, c, p, n, in, installation) |
| + } |
| + err = serving.Run(r.Context(), request.Prior, request.Next) |
| + } else { |
| + err = handover.Run(r.Context(), request.Prior, request.Next) |
| + } |
| + if err != nil { |
| w.WriteHeader(http.StatusConflict) |
| return |
| } |
| @@ -53,6 +79,13 @@ func (h ClaimRootHTTP) serveColdHandover(w http.ResponseWriter, r *http.Request, |
| func coldAdmissionHook(guard hostguard.Guard, identity hostbox.IdentityProvider, socket string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| return coldHandoverWith(identity, socket, func(ctx context.Context, prior hostclaim.Observed) (hostguard.FenceReceipt, error) { |
| old := prior.Claim.Holder |
| + if old.Session == 0 && prior.Claim.Op.Kind == hostclaim.KindServing && prior.Claim.Op.ID == "" && prior.Claim.Op.Phase == "" { |
| + actor, err := identity.Verify(ctx) |
| + if err != nil { |
| + return hostguard.FenceReceipt{}, err |
| + } |
| + return hostguard.FenceReceipt{Target: hostguard.FenceTarget{Host: prior.Host, Incarnation: old.Incarnation, Stamp: old.Stamp, Box: actor.Box, Enrollment: actor.Enrollment}}, nil |
| + } |
| return guard.CompletedColdPredecessor(ctx, prior.Host, hostguard.Captured{Incarnation: old.Incarnation, Stamp: old.Stamp}, old.Session, prior.Claim.Op.ID, prior.Claim.Op.Phase) |
| }) |
| } |
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go| index 3495b03bd..9b9f6ff88 100644 |
| --- a/internal/host/claimretire_transport.go |
| +++ b/internal/host/claimretire_transport.go |
| @@ -39,7 +39,10 @@ func (h ClaimRootHTTP) RecoverPending(ctx context.Context) (recoveryErr error) { |
| // startup recovery failed. Attempt them before the service accepts work. |
| defer func() { recoveryErr = errors.Join(recoveryErr, h.servingParks.recoverPending(ctx)) }() |
| - cold := hostbox.ColdHandover{Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt} |
| + if err := h.servingHandover().Recover(ctx); err != nil { |
| + return err |
| + } |
| + cold := hostbox.ColdHandover{OperationsOnly: true, Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt} |
| if err := cold.Recover(ctx); err != nil { |
| return err |
| } |
diff --git a/internal/host/claimservinghandover.go b/internal/host/claimservinghandover.go| new file mode 100644 |
| index 000000000..35f663518 |
| --- /dev/null |
| +++ b/internal/host/claimservinghandover.go |
| @@ -0,0 +1,139 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "path/filepath" |
| + "reflect" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +func (h ClaimRootHTTP) servingHandover() hostbox.ServingHandover { |
| + return hostbox.ServingHandover{ColdHandover: hostbox.ColdHandover{Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "serving"), VerifyReceipt: verifyRetirementReceipt}, Validate: h.validateServingHandover} |
| +} |
| + |
| +func (h ClaimRootHTTP) validateServingHandover(ctx context.Context, c *hostclaim.Claim, prior hostguard.FenceTarget, next hostbox.ColdSuccessor, in hostbox.StopInventory, installation hostguard.RootInstallation) error { |
| + if h.RequireNewWork == nil || h.InventoryActor == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + if err := h.RequireNewWork(ctx); err != nil { |
| + return err |
| + } |
| + actor, err := h.InventoryActor(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if !actor.Eligible || actor.Quarantined || actor.Revision == 0 || actor.Box != prior.Box || actor.Region != prior.Region || actor.Enrollment != prior.Enrollment || strconv.FormatUint(actor.Revision, 10) != next.DirectoryRevision { |
| + return hostbox.ErrChanged |
| + } |
| + if !servingPriorRevision(prior.DirectoryRevision, actor.Revision) { |
| + return hostbox.ErrChanged |
| + } |
| + current, err := servingWriterIdentity(hostclaim.Observed{Host: prior.Host, Claim: c}, actor.Box, actor.Region) |
| + if err != nil { |
| + return err |
| + } |
| + holder := c.Holder |
| + if installation.Empty || installation.Host != prior.Host || installation.Incarnation != prior.Incarnation || installation.Stamp != prior.Stamp || installation.Migration == 0 || installation.Account != holder.Account || installation.App != holder.App || installation.Record != holder.Key || installation.Box != actor.Box || installation.Enrollment != actor.Enrollment { |
| + return hostbox.ErrChanged |
| + } |
| + canonical, _, err := rootClaimRecord(ctx, h.Stop.Coordinator.Evidence, c, h.Roots["data"]) |
| + if err != nil { |
| + return err |
| + } |
| + canonical.ClaimIncarnation, canonical.ClaimStamp = prior.Incarnation, prior.Stamp |
| + for _, e := range in.Entries { |
| + if e.Kind == "route" { |
| + canonical.ClaimOwner, canonical.ClaimRowID = e.Ownership, e.RowIdentity |
| + } |
| + } |
| + if err = hostbox.ValidateAgentInventory(in, &canonical, h.Roots["data"]); err != nil { |
| + return err |
| + } |
| + var admission hostclaim.AdmissionRecord |
| + for _, a := range holder.Admissions { |
| + if a.Session == current.Session { |
| + admission = a |
| + } |
| + } |
| + if prior.Session != 0 { |
| + p, err := servingPredecessor(c, current, prior.Stamp, admission.Predecessor) |
| + if err != nil || p.Session != prior.Session || admission.Predecessor != p.Generation || admission.PredecessorRegion != p.Region || admission.WritesLost == nil || admission.WritesLost.Box != p.Box || admission.WritesLost.Session != p.Session || admission.WritesLost.Generation != p.Generation { |
| + return hostbox.ErrChanged |
| + } |
| + for _, a := range holder.Admissions { |
| + if a.Session > p.Session && a.Session < current.Session { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + } else { |
| + if h.servingParks == nil || h.servingParks.state == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + body, err := h.servingParks.state.Read(servingLegacyKey("legacy-stop", current)) |
| + if err != nil { |
| + return err |
| + } |
| + var saved servingLegacyStop |
| + if servingParkDecode(body, &saved) != nil || validateServingLegacyStop(saved) != nil || saved.Result.Target != prior { |
| + return hostbox.ErrChanged |
| + } |
| + old := saved.Observed.Claim.Holder |
| + if old.Account != holder.Account || old.App != holder.App || old.Key != holder.Key || old.Incarnation != holder.Incarnation || !old.CreatedAt.Equal(holder.CreatedAt) || admission.Predecessor != old.Generation { |
| + return hostbox.ErrChanged |
| + } |
| + for _, a := range holder.Admissions { |
| + if a.Session < current.Session { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + if old.WriterBox == "" { |
| + if admission.WritesLost != nil || admission.PredecessorRegion != "" { |
| + return hostbox.ErrChanged |
| + } |
| + } else if old.WriterBox != actor.Box || admission.WritesLost == nil || admission.WritesLost.Box != actor.Box || admission.WritesLost.Session != 0 || admission.WritesLost.Generation != "" || admission.PredecessorRegion != actor.Region { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + fresh, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, prior.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Absent || !reflect.DeepEqual(fresh.Claim, c) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// Session zero sends a selector. Root supplies the complete retained stop tuple. |
| +func (h ClaimRootHTTP) servingLegacyHandoverTarget(ctx context.Context, q coldHandoverRequest) (hostguard.FenceTarget, error) { |
| + if h.servingParks == nil || h.servingParks.state == nil { |
| + return hostguard.FenceTarget{}, hostbox.ErrUnsupported |
| + } |
| + p, n := q.Prior, q.Next |
| + selector := hostguard.FenceTarget{Host: n.Host, Incarnation: n.Incarnation, Stamp: p.Stamp, Box: n.Box, Enrollment: n.Enrollment} |
| + if p != selector || n.Op != "" || n.Phase != "" { |
| + return hostguard.FenceTarget{}, hostbox.ErrChanged |
| + } |
| + body, err := h.servingParks.state.Read(servingLegacyKey("legacy-stop", servingArtifactIdentity{Host: n.Host, Box: n.Box, Incarnation: n.Incarnation})) |
| + if err != nil { |
| + return hostguard.FenceTarget{}, err |
| + } |
| + var saved servingLegacyStop |
| + if servingParkDecode(body, &saved) != nil || validateServingLegacyStop(saved) != nil { |
| + return hostguard.FenceTarget{}, hostbox.ErrChanged |
| + } |
| + t := saved.Result.Target |
| + if t.Host != p.Host || t.Incarnation != p.Incarnation || t.Stamp != p.Stamp || t.Box != p.Box || t.Enrollment != p.Enrollment { |
| + return hostguard.FenceTarget{}, hostbox.ErrChanged |
| + } |
| + return t, ctx.Err() |
| +} |
| + |
| +func servingPriorRevision(recorded string, current uint64) bool { |
| + revision, err := strconv.ParseUint(recorded, 10, 64) |
| + return err == nil && revision > 0 && revision <= current && strconv.FormatUint(revision, 10) == recorded |
| +} |
diff --git a/internal/host/claimservinghandover_linux_test.go b/internal/host/claimservinghandover_linux_test.go| index 1b2640714..a7ab85c95 100644 |
| --- a/internal/host/claimservinghandover_linux_test.go |
| +++ b/internal/host/claimservinghandover_linux_test.go |
| @@ -4,115 +4,258 @@ package host |
| import ( |
| "context" |
| - "crypto/sha256" |
| - "encoding/hex" |
| - "errors" |
| - "fmt" |
| + "encoding/json" |
| "os" |
| "path/filepath" |
| + "reflect" |
| + "strconv" |
| "testing" |
| "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| - "golang.org/x/sys/unix" |
| ) |
| -// Characterize the installation gate still separating the preparation fixture |
| -// from production. ColdHandover parks the whole state directory. A serving |
| -// handover must preserve it before the database-only retirement can be bound. |
| -func TestServingParkColdHandoverRequiresStatePreservation(t *testing.T) { |
| - runServingColdHandoverBoundary(t, nil) |
| -} |
| - |
| -func runServingColdHandoverBoundary(t *testing.T, mount func(*testing.T, string)) { |
| +// Initial installation remains a fixture. Every later ownership transfer uses |
| +// the production serving handover and retains the original source directory. |
| +func servingHandoverRoot(t *testing.T, f *servingRootFixture) ClaimRootHTTP { |
| t.Helper() |
| - f := newServingRootFixture(t, true) |
| - if mount != nil { |
| - mount(t, f.f.guard.DataDir) |
| - } |
| - ctx := t.Context() |
| inventory := f.p.root.Journals.Inventory |
| - captured := hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp} |
| - in, _, err := inventory.Snapshot(ctx, f.prior.Host, captured) |
| - if err != nil || len(in.Entries) != 1 { |
| - t.Fatal("predecessor state inventory", err) |
| - } |
| - owner := registry.ClaimOwnership{ClaimIncarnation: f.prior.Incarnation, ClaimStamp: f.prior.Stamp, ClaimOwner: in.Entries[0].Ownership, ClaimRowID: "prior-route"} |
| - reg := restartTestRegistry(t) |
| - row := registry.Route{Host: f.prior.Host, Account: f.admitted.Claim.Holder.Account, App: f.admitted.Claim.Holder.App, Kind: "instance", DBPath: f.db, Socket: provision.NestedSocket(f.prior.Host), ClaimOwnership: owner} |
| - if err := reg.Upsert(row); err != nil { |
| - t.Fatal(err) |
| - } |
| - in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: row.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}) |
| - if err := inventory.WriteStopInventory(ctx, in); err != nil { |
| + captured, err := f.f.guard.Capture(f.f.read().Host) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - _, revision, err := inventory.Snapshot(ctx, row.Host, captured) |
| + in, _, err := inventory.Snapshot(t.Context(), f.f.read().Host, captured) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - // Fixture bootstrap only. The transition below uses production handover, |
| - // root inventory refresh, exact receipt verification and the typed parker. |
| - f.mutateRoot(func(m map[string]any) { m["Installation"].(map[string]any)["InventoryRevision"] = revision }) |
| - guard := f.f.guard |
| - guard.Root = hostguard.NewRootAuthorityWithInventory(f.rootDisk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| + if f.p.root.Reconcile.Registry == nil { |
| + owner := registry.ClaimOwnership{ClaimIncarnation: in.Incarnation, ClaimStamp: in.InstallationStamp, ClaimOwner: in.Entries[0].Ownership, ClaimRowID: "prior-route"} |
| + reg := restartTestRegistry(t) |
| + holder := f.f.read().Claim.Holder |
| + row := registry.Route{Host: in.Host, Account: holder.Account, App: holder.App, Kind: "instance", DBPath: f.db, Socket: provision.NestedSocket(in.Host), ClaimOwnership: owner} |
| + if err := reg.Upsert(row); err != nil { |
| + t.Fatal(err) |
| + } |
| + in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: in.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}) |
| + if err := inventory.WriteStopInventory(t.Context(), in); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, revision, err := inventory.Snapshot(t.Context(), in.Host, captured) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := f.rootDisk.Read(in.Host + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var saved map[string]any |
| + if err = json.Unmarshal(raw, &saved); err != nil { |
| + t.Fatal(err) |
| + } |
| + saved["Installation"].(map[string]any)["InventoryRevision"] = revision |
| + raw, err = json.Marshal(saved) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = f.rootDisk.Write(in.Host+".json", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + record := provision.Record{Schema: 1, Host: in.Host, Account: holder.Account, App: holder.App, Region: f.actor.Region, CreatedAt: holder.CreatedAt, Channel: "edge"} |
| + body, err := json.Marshal(record) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = f.f.claims.Put(t.Context(), holder.Key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.p.root.Reconcile.Registry = reg |
| + f.p.root.Reconcile.Inventory = inventory |
| + f.p.root.Reconcile.Box, f.p.root.Reconcile.Enrollment = f.actor.Box, f.actor.Enrollment |
| + f.p.root.Reconcile.Retirement.JournalDir = filepath.Join(t.TempDir(), "handover") |
| + f.p.root.Launches = f.f.journal |
| + } |
| + f.f.guard.Root = hostguard.NewRootAuthorityWithInventory(f.rootDisk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| _, rev, err := inventory.Snapshot(ctx, host, c) |
| return rev, err |
| }) |
| + f.p.root.Stop.Coordinator.Guard = f.f.guard |
| + f.f.evidence = f.f.claims |
| + f.p.root.Stop.Coordinator.Evidence = f.f.claims |
| + return servingTransportRoot(t, f) |
| +} |
| + |
| +func TestServingParkColdHandoverPreservesState(t *testing.T) { runServingColdHandoverBoundary(t, nil) } |
| + |
| +func runServingColdHandoverBoundary(t *testing.T, mount func(*testing.T, string), revisions ...uint64) { |
| + t.Helper() |
| + f := newServingRootFixture(t, true) |
| + if mount != nil { |
| + mount(t, f.f.guard.DataDir) |
| + } |
| + root := servingHandoverRoot(t, f) |
| + if len(revisions) != 0 { |
| + f.actor.Revision = revisions[0] |
| + } |
| stateDir := filepath.Dir(f.db) |
| - for name, body := range map[string]string{"files/upload": "tenant upload", "env": "tenant configuration"} { |
| - path := filepath.Join(stateDir, name) |
| - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| + expected := map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} |
| + for _, name := range []string{"files/upload", "env"} { |
| + p := filepath.Join(stateDir, name) |
| + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { |
| t.Fatal(err) |
| } |
| - if err := os.WriteFile(path, []byte(body), 0600); err != nil { |
| + if err := os.WriteFile(p, []byte(expected[name]), 0600); err != nil { |
| t.Fatal(err) |
| } |
| } |
| before := f.f.read() |
| - next := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: "7", ETag: before.ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session} |
| - handover := hostbox.ColdHandover{Guard: guard, Claims: f.f.claims, Registry: reg, Inventory: inventory, Launches: f.f.journal, Roots: f.p.root.Roots, JournalDir: filepath.Join(t.TempDir(), "handover"), VerifyReceipt: verifyRetirementReceipt} |
| - err = handover.Run(ctx, f.receipt.Target, next) |
| - if mount != nil { |
| - if !errors.Is(err, unix.EXDEV) { |
| - t.Fatalf("production cold handover across the instance bind: %v", err) |
| - } |
| - for name, want := range map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} { |
| - body, err := os.ReadFile(filepath.Join(stateDir, name)) |
| - if err != nil || string(body) != want { |
| - t.Fatalf("failed handover changed %s: %q %v", name, body, err) |
| - } |
| + old, _, err := root.Reconcile.Inventory.Snapshot(t.Context(), before.Host, hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + next := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: strconv.FormatUint(f.actor.Revision, 10), ETag: before.ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session} |
| + handover := root.servingHandover() |
| + for i := 0; i < 2; i++ { |
| + if err = handover.Run(t.Context(), f.receipt.Target, next); err != nil { |
| + t.Fatal("serving handover", err) |
| } |
| - if f.f.read().ETag != before.ETag { |
| - t.Fatal("failed handover rewrote the claim") |
| + } |
| + for name, want := range expected { |
| + body, err := os.ReadFile(filepath.Join(stateDir, name)) |
| + if err != nil || string(body) != want { |
| + t.Fatalf("handover changed %s: %q %v", name, body, err) |
| } |
| - return |
| } |
| + retained, _, err := root.Reconcile.Inventory.Snapshot(t.Context(), before.Host, hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp}) |
| + if err != nil || !reflect.DeepEqual(old, retained) { |
| + t.Fatal("predecessor inventory changed", err) |
| + } |
| + if f.f.read().ETag != before.ETag { |
| + t.Fatal("handover rewrote claim") |
| + } |
| + a, err := f.park() |
| if err != nil { |
| - t.Fatal("production cold handover", err) |
| + t.Fatal("database retirement after handover", err) |
| } |
| - if _, err := os.Lstat(stateDir); !os.IsNotExist(err) { |
| - t.Fatalf("cold handover now preserves state; replace this gate witness with the serving integration: %v", err) |
| + f.tags(f.current.Stamp) |
| + if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil { |
| + t.Fatal(err) |
| } |
| - sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", next.Host, next.Incarnation, next.Stamp, next.Box))) |
| - parked := filepath.Join(guard.DataDir, ".parked", next.Incarnation, next.Host, "handover-"+hex.EncodeToString(sum[:])+"-old", "data", "instances", provision.InstanceID(next.Host)) |
| - for name, want := range map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} { |
| - body, err := os.ReadFile(filepath.Join(parked, name)) |
| - if err != nil || string(body) != want { |
| - t.Fatalf("cold handover retained %s: %q %v", name, body, err) |
| + for _, name := range []string{"files/upload", "env"} { |
| + body, err := os.ReadFile(filepath.Join(stateDir, name)) |
| + if err != nil || string(body) != expected[name] { |
| + t.Fatalf("retirement changed %s: %q %v", name, body, err) |
| } |
| } |
| - f.p.root.Stop.Coordinator.Guard = guard |
| - if _, err := f.park(); err == nil { |
| - t.Fatal("database retirement accepted the removed source") |
| +} |
| + |
| +func TestServingHandoverLegacy(t *testing.T) { |
| + f, _, coordinator := newServingLegacyRootSetup(t, false) |
| + root := servingHandoverRoot(t, f) |
| + o := f.f.read() |
| + req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: o.Claim.Holder.Stamp, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: "boot", ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"} |
| + if _, err := coordinator.FenceAndStop(t.Context(), req); err != nil { |
| + t.Fatal(err) |
| } |
| - after := f.f.read() |
| - if after.ETag != before.ETag { |
| - t.Fatal("handover or refused park rewrote claim tags") |
| + admitted, err := hostclaim.Update(t.Context(), f.f.claims, f.f.read(), hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: 0, Box: f.actor.Box, Region: f.actor.Region, PredecessorRegion: f.actor.Region, Mode: "cold", NewGeneration: "G"})) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - if names, err := f.disk.Names(); err != nil || len(names) != 0 { |
| - t.Fatalf("removed source gained serving retirement authority: %v %v", names, err) |
| + f.current, err = servingWriterIdentity(admitted, f.actor.Box, f.actor.Region) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + saved, err := f.p.ledger.legacyStop(f.current) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.actor.Revision = 8 |
| + n := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: strconv.FormatUint(f.actor.Revision, 10), ETag: f.f.read().ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session} |
| + if err := root.servingHandover().Run(t.Context(), saved.Result.Target, n); err != nil { |
| + t.Fatalf("legacy transfer: %v prior=%+v next=%+v", err, saved.Result.Target, n) |
| + } |
| + if _, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{}); err != nil { |
| + t.Fatal("legacy park after transfer", err) |
| + } |
| +} |
| + |
| +func TestServingHandoverRefusesForeignAuthority(t *testing.T) { |
| + for _, attack := range []string{"census", "actor", "successor", "directory-revision", "operation", "predecessor", "receipt", "root-revision", "registry-path", "registry-account", "source-replaced", "extra-timer", "prelaunch"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + root := servingHandoverRoot(t, f) |
| + p := f.receipt.Target |
| + n := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: "7", ETag: f.f.read().ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session} |
| + row, err := root.Reconcile.Registry.Get(n.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "census": |
| + root.RequireNewWork = func(context.Context) error { return hostbox.ErrChanged } |
| + case "actor": |
| + f.actor.Eligible = false |
| + case "successor": |
| + n.Generation = "foreign" |
| + case "directory-revision": |
| + n.DirectoryRevision = "8" |
| + case "operation": |
| + n.Op = "move" |
| + case "predecessor": |
| + p.Session++ |
| + case "receipt": |
| + p.FenceID = "unknown" |
| + case "root-revision": |
| + f.mutateRoot(func(m map[string]any) { m["Installation"].(map[string]any)["InventoryRevision"] = 999 }) |
| + case "registry-path": |
| + row.DBPath = filepath.Join(t.TempDir(), "victim.db") |
| + if err = root.Reconcile.Registry.Upsert(*row); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "registry-account": |
| + row.Account = "bac" |
| + if err = root.Reconcile.Registry.Upsert(*row); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "source-replaced": |
| + dir := filepath.Dir(f.db) |
| + if err = os.Rename(dir, dir+"-original"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "extra-timer": |
| + if err = root.Reconcile.Registry.UpsertTimer(registry.Timer{Host: n.Host, Name: "foreign", Kind: "cron", Spec: "0 * * * *"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "prelaunch": |
| + 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/next", Device: 1, Inode: 99}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before, err := root.Reconcile.Registry.Get(n.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = root.servingHandover().Run(t.Context(), p, n); err == nil { |
| + t.Fatal("accepted", attack) |
| + } |
| + after, err := root.Reconcile.Registry.Get(n.Host) |
| + if err != nil || !reflect.DeepEqual(before, after) { |
| + t.Fatal("refusal changed row", err) |
| + } |
| + source := f.db |
| + if attack == "source-replaced" { |
| + source = filepath.Join(filepath.Dir(f.db)+"-original", filepath.Base(f.db)) |
| + } |
| + body, err := os.ReadFile(source) |
| + if err != nil || string(body) != "served bytes" { |
| + t.Fatal("refusal changed source", err) |
| + } |
| + }) |
| } |
| } |
diff --git a/internal/host/claimservinglegacyroot_linux.go b/internal/host/claimservinglegacyroot_linux.go| index 47d042597..459c9c82a 100644 |
| --- a/internal/host/claimservinglegacyroot_linux.go |
| +++ b/internal/host/claimservinglegacyroot_linux.go |
| @@ -99,7 +99,7 @@ func (p *servingParkRoot) validateLegacyRootEvidence(ctx context.Context, o host |
| 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) { |
| + if t.Enrollment != actor.Enrollment || !servingPriorRevision(t.DirectoryRevision, actor.Revision) || 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 { |
diff --git a/internal/host/claimservingpark_transport_linux_test.go b/internal/host/claimservingpark_transport_linux_test.go| index a3edb257e..d36c268b6 100644 |
| --- a/internal/host/claimservingpark_transport_linux_test.go |
| +++ b/internal/host/claimservingpark_transport_linux_test.go |
| @@ -21,6 +21,10 @@ import ( |
| ) |
| func servingParkSocket(t *testing.T, handler http.Handler) hostbox.JournalCall { |
| + t.Helper() |
| + return rootJournalClient(servingRootSocket(t, handler)) |
| +} |
| +func servingRootSocket(t *testing.T, handler http.Handler) string { |
| t.Helper() |
| dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "serving-park-") |
| if err != nil { |
| @@ -42,13 +46,13 @@ func servingParkSocket(t *testing.T, handler http.Handler) hostbox.JournalCall { |
| t.Error(err) |
| } |
| }) |
| - return rootJournalClient(path) |
| + return path |
| } |
| func servingTransportRoot(t *testing.T, f *servingRootFixture) ClaimRootHTTP { |
| t.Helper() |
| ctx, cancel := context.WithCancel(context.Background()) |
| - p := &servingParkRuntime{authority: f.p, state: f.disk, ctx: ctx, cancel: cancel, idle: make(chan struct{})} |
| + p := &servingParkRuntime{authority: f.p, state: servingBorrowedState{f.p.ledger.state}, ctx: ctx, cancel: cancel, idle: make(chan struct{})} |
| t.Cleanup(func() { |
| if err := p.Close(t.Context()); err != nil { |
| t.Error(err) |
| @@ -347,3 +351,7 @@ func TestServingParkProductionBindingRetainsSessionZero(t *testing.T) { |
| t.Fatal("failed binding leaked private store", err) |
| } |
| } |
| + |
| +type servingBorrowedState struct{ rootstate.Store } |
| + |
| +func (servingBorrowedState) Close() error { return nil } |
diff --git a/internal/host/claimservingparkroot_linux.go b/internal/host/claimservingparkroot_linux.go| index 7004e9eea..97a3d2e4e 100644 |
| --- a/internal/host/claimservingparkroot_linux.go |
| +++ b/internal/host/claimservingparkroot_linux.go |
| @@ -8,7 +8,6 @@ import ( |
| "net" |
| "path/filepath" |
| "sort" |
| - "strconv" |
| "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| @@ -129,7 +128,7 @@ func servingRootStops(ctx context.Context, view hostguard.FenceView, prior servi |
| if t.Host != prior.Host || t.Incarnation != prior.Incarnation || t.Stamp != prior.Stamp || t.Box != prior.Box || t.Op != "" || t.Phase != "" || t.ObligationID != "" { |
| continue |
| } |
| - if t.Session != prior.Session || t.Region != prior.Region || t.Enrollment != actor.Enrollment || t.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(ctx, receipt) != nil { |
| + if t.Session != prior.Session || t.Region != prior.Region || t.Enrollment != actor.Enrollment || !servingPriorRevision(t.DirectoryRevision, actor.Revision) || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(ctx, receipt) != nil { |
| return nil, ErrClaimStopReceipt |
| } |
| seen[t.FenceID] = true |
diff --git a/internal/host/claimservingpeer_rootrunner_linux_test.go b/internal/host/claimservingpeer_rootrunner_linux_test.go| index bd21f49d0..85033015e 100644 |
| --- a/internal/host/claimservingpeer_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpeer_rootrunner_linux_test.go |
| @@ -21,6 +21,7 @@ import ( |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/rootstate" |
| @@ -266,6 +267,7 @@ func TestRootServingParkAuthenticatedPeer(t *testing.T) { |
| return nil |
| } |
| h := servingTransportRoot(t, f) |
| + h.servingParks.authority = servingPeerParkTrace{servingParkAuthority: h.servingParks.authority, root: f.p, t: t} |
| path := filepath.Join(root, "root.sock") |
| listener, err := net.Listen("unix", path) |
| if err != nil { |
| @@ -438,9 +440,45 @@ func TestRootServingParkAuthenticatedPeer(t *testing.T) { |
| } |
| } |
| -// Exercise the actual earlier cold-handover parker on the nested mount. This |
| -// is a refusal witness, not successful serving installation or Restore. |
| +// Transfer the serving installation on the nested mount without moving its |
| +// state directory, then park the database through the root transport. |
| func TestRootServingParkColdHandoverBoundary(t *testing.T) { |
| servingRootRunner(t) |
| runServingColdHandoverBoundary(t, servingRunnerMount) |
| } |
| + |
| +// Keep root errors in the test log; the public transport intentionally returns |
| +// only uncertainty after a durable request may have made filesystem changes. |
| +type servingPeerParkTrace struct { |
| + servingParkAuthority |
| + root *servingParkRoot |
| + t *testing.T |
| +} |
| + |
| +func (p servingPeerParkTrace) parkServingDatabase(ctx context.Context, conn net.Conn, current, prior servingArtifactIdentity) (servingParkAuthorization, error) { |
| + a, err := p.servingParkAuthority.parkServingDatabase(ctx, conn, current, prior) |
| + p.t.Logf("authenticated peer park authority: %v", err) |
| + if err != nil { |
| + check := p.root.withAuthority(ctx, conn, current, func(o hostclaim.Observed, view hostguard.FenceView, actor hostbox.Actor, dir servingArtifactDirectory, tags uint64) error { |
| + selected, e := servingPredecessor(o.Claim, current, tags, prior.Generation) |
| + p.t.Logf("park diagnostic predecessor: exact=%v error=%v", selected == prior, e) |
| + receipts, e := servingRootStops(ctx, view, prior, actor) |
| + p.t.Logf("park diagnostic receipts: count=%d error=%v", len(receipts), e) |
| + source, e := p.root.ledger.fs.Capture(servingParkAuthorization{TagsFrom: tags, Predecessor: prior, OriginalSuccessor: current, Successor: current, Directory: dir, ID: servingRetirementID(prior, current, false)}) |
| + p.t.Logf("park diagnostic source capture: %v", e) |
| + if e == nil { |
| + disposition, e := servingGenerationParkDisposition(ctx, p.root.ledger.journal, prior, receipts, source.Files[0].Present) |
| + p.t.Logf("park diagnostic disposition: %v error=%v", disposition, e) |
| + } |
| + return nil |
| + }) |
| + p.t.Logf("park diagnostic root installation: %v", check) |
| + } |
| + return a, err |
| +} |
| + |
| +func (p servingPeerParkTrace) completeServingDatabasePark(ctx context.Context, conn net.Conn, id string, current servingArtifactIdentity) error { |
| + err := p.servingParkAuthority.completeServingDatabasePark(ctx, conn, id, current) |
| + p.t.Logf("authenticated peer completion authority: %v", err) |
| + return err |
| +} |
diff --git a/internal/hostbox/coldhandover.go b/internal/hostbox/coldhandover.go| index 166d489a0..9319d1590 100644 |
| --- a/internal/hostbox/coldhandover.go |
| +++ b/internal/hostbox/coldhandover.go |
| @@ -25,28 +25,30 @@ type ColdSuccessor struct { |
| Stamp, Session uint64 |
| } |
| type ColdHandover struct { |
| - checkpoint func(string) |
| - afterIntent func() error |
| - afterPark func() error |
| - Guard hostguard.Guard |
| - Claims release.Store |
| - Registry *registry.Registry |
| - Inventory *StopInventoryDB |
| - Launches *LaunchJournal |
| - Roots map[string]string |
| - JournalDir string |
| - VerifyReceipt hostguard.CleanupVerify |
| + OperationsOnly bool |
| + checkpoint func(string) |
| + afterIntent func() error |
| + afterPark func() error |
| + Guard hostguard.Guard |
| + Claims release.Store |
| + Registry *registry.Registry |
| + Inventory *StopInventoryDB |
| + Launches *LaunchJournal |
| + Roots map[string]string |
| + JournalDir string |
| + VerifyReceipt hostguard.CleanupVerify |
| } |
| type coldJournal struct { |
| - V int |
| - IntendedAbsent bool |
| - ID, Phase string |
| - Prior hostguard.FenceTarget |
| - Barrier hostguard.FenceTarget |
| - Successor ColdSuccessor |
| - Inventory StopInventory |
| - Receipt hostguard.FenceReceipt |
| - Before, After registry.ColdRows |
| + ServingInstallation *hostguard.RootInstallation `json:",omitempty"` |
| + V int |
| + IntendedAbsent bool |
| + ID, Phase string |
| + Prior hostguard.FenceTarget |
| + Barrier hostguard.FenceTarget |
| + Successor ColdSuccessor |
| + Inventory StopInventory |
| + Receipt hostguard.FenceReceipt |
| + Before, After registry.ColdRows |
| } |
| func ownershipFor(host, incarnation string, stamp uint64, box string) string { |
| @@ -58,6 +60,9 @@ func rowIDFor(owner, kind, name string) string { |
| return hex.EncodeToString(sum[:]) |
| } |
| func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error { |
| + if h.OperationsOnly && (next.Op == "" || next.Phase == "") { |
| + return ErrUnsupported |
| + } |
| if h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || len(h.Roots) == 0 || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Stamp <= prior.Stamp || next.Session <= prior.Session || next.Stamp != next.Session || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.ETag == "" || next.Generation == "" { |
| return ErrUnsupported |
| } |
diff --git a/internal/hostbox/servinghandover.go b/internal/hostbox/servinghandover.go| new file mode 100644 |
| index 000000000..8109e0ee5 |
| --- /dev/null |
| +++ b/internal/hostbox/servinghandover.go |
| @@ -0,0 +1,288 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// ServingHandover retains the stopped installation's state directory. Its |
| +// barrier covers inventory and row transfer; database retirement follows later. |
| +type ServingHandover struct { |
| + ColdHandover |
| + Validate func(context.Context, *hostclaim.Claim, hostguard.FenceTarget, ColdSuccessor, StopInventory, hostguard.RootInstallation) error |
| + checkpoint func(string) error |
| +} |
| + |
| +func (h ServingHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error { |
| + if h.Guard.Root == nil || h.Validate == nil || h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Inventory.Remote() || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || h.Roots["data"] != h.Guard.DataDir || prior.Op != "" || prior.Phase != "" || prior.ObligationID != "" || next.Op != "" || next.Phase != "" || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.Stamp <= prior.Stamp || next.Session != next.Stamp || next.Session <= prior.Session || next.Generation == "" || next.ETag == "" { |
| + return ErrUnsupported |
| + } |
| + id := "serving-handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) |
| + path := filepath.Join(h.JournalDir, id+".json") |
| + var j coldJournal |
| + body, err := readTrustedJournal(path) |
| + if err == nil { |
| + if decodeStrict(body, &j) != nil || j.V != 1 || j.ID != id || j.Prior != prior || j.Successor != next || j.ServingInstallation == nil || (j.Phase != "prepared" && j.Phase != "complete") { |
| + return ErrTypedJournal |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } else { |
| + captured := hostguard.Captured{Incarnation: prior.Incarnation, Stamp: prior.Stamp} |
| + view, err := h.Guard.Root.View(ctx, prior.Host, captured) |
| + if err != nil { |
| + return err |
| + } |
| + in, rev, err := h.Inventory.Snapshot(ctx, prior.Host, captured) |
| + if err != nil { |
| + return err |
| + } |
| + if view.Installation.Stamp != prior.Stamp || view.Installation.InventoryRevision != rev || !in.Complete || in.Box != prior.Box || in.Enrollment != prior.Enrollment || in.DirectoryRevision != prior.DirectoryRevision { |
| + return ErrTypedInventory |
| + } |
| + var receipt hostguard.FenceReceipt |
| + if prior.Session != 0 { |
| + receipt, err = h.Guard.FenceReceiptFor(ctx, prior) |
| + if err != nil { |
| + return err |
| + } |
| + if err = h.VerifyReceipt(ctx, receipt); err != nil { |
| + return err |
| + } |
| + } |
| + row, err := h.Registry.Get(prior.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if row == nil || row.Kind != "instance" || row.Account != view.Installation.Account || row.App != view.Installation.App || row.Socket != provision.NestedSocket(prior.Host) || row.DBPath != provision.NestedDBPath(h.Roots["data"], prior.Host) || row.Unit != "" || row.Addr != "" || row.Addrs != "" || row.Backing != "" || row.Tunnel != "" || row.Platform || row.Canary || row.Replica { |
| + return ErrTypedInventory |
| + } |
| + timers, err := h.Registry.TimersFor(prior.Host) |
| + if err != nil { |
| + return err |
| + } |
| + before := registry.ColdRows{Route: *row, Timers: timers} |
| + after := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)} |
| + owner := ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) |
| + after.Route.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: next.Incarnation, ClaimStamp: next.Stamp, ClaimOwner: owner, ClaimRowID: rowIDFor(owner, "route", next.Host)} |
| + for i := range after.Timers { |
| + after.Timers[i].ClaimOwnership = after.Route.ClaimOwnership |
| + after.Timers[i].ClaimRowID = rowIDFor(owner, "timer", after.Timers[i].Name) |
| + } |
| + barrier := prior |
| + barrier.FenceID, barrier.Stamp, barrier.Session, barrier.ClaimETag, barrier.DirectoryRevision = id, next.Stamp, next.Session, next.ETag, next.DirectoryRevision |
| + j = coldJournal{V: 1, ID: id, Phase: "prepared", Prior: prior, Barrier: barrier, Successor: next, Inventory: in, Receipt: receipt, Before: before, After: after, ServingInstallation: &view.Installation} |
| + err = h.Guard.WithServingRetirement(ctx, next.Host, hostguard.Captured{Incarnation: next.Incarnation, Stamp: next.Stamp}, func(fresh hostclaim.Observed, locked hostguard.FenceView) error { |
| + if locked.Installation != view.Installation { |
| + return ErrChanged |
| + } |
| + if fresh.ETag != next.ETag { |
| + return ErrChanged |
| + } |
| + if err = h.validate(ctx, j, fresh.Claim); err != nil { |
| + return err |
| + } |
| + saved, err := readTrustedJournal(path) |
| + if err == nil { |
| + var existing coldJournal |
| + if decodeStrict(saved, &existing) != nil || !reflect.DeepEqual(existing, j) { |
| + return ErrTypedJournal |
| + } |
| + return nil |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + return writeColdJournal(path, j) |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + check := func(c *hostclaim.Claim) error { return h.validate(ctx, j, c) } |
| + if j.Phase == "complete" { |
| + return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) |
| + } |
| + if err = h.at("intent"); err != nil { |
| + return err |
| + } |
| + if err = h.Guard.BeginServingHandover(ctx, prior, j.Barrier, *j.ServingInstallation, check); err != nil { |
| + return err |
| + } |
| + if err = h.at("barrier"); err != nil { |
| + return err |
| + } |
| + return h.Guard.CompleteColdHandover(ctx, j.Barrier, false, check, func() error { |
| + if prior.Session != 0 { |
| + if err := h.VerifyReceipt(ctx, j.Receipt); err != nil { |
| + return err |
| + } |
| + } |
| + launches, err := h.Launches.RootSessionLaunches(ctx, next.Host, next.Incarnation, next.Stamp, next.Session, next.Box, next.Enrollment) |
| + if err != nil { |
| + return err |
| + } |
| + if len(launches) != 0 { |
| + return ErrChanged |
| + } |
| + rebound := j.Inventory |
| + rebound.Entries = append([]StopInventoryEntry(nil), j.Inventory.Entries...) |
| + rebound.InstallationStamp, rebound.DirectoryRevision = next.Stamp, next.DirectoryRevision |
| + for i := range rebound.Entries { |
| + e := &rebound.Entries[i] |
| + e.Ownership = j.After.Route.ClaimOwner |
| + if e.Kind == "route" { |
| + e.RowIdentity = j.After.Route.ClaimRowID |
| + } |
| + if e.Kind == "timer" { |
| + for _, timer := range j.After.Timers { |
| + if e.RelativePath == next.Host+"/"+timer.Name { |
| + e.RowIdentity = timer.ClaimRowID |
| + } |
| + } |
| + } |
| + } |
| + existing, err := h.Inventory.ReadInstallation(ctx, next.Host, next.Incarnation, next.Stamp) |
| + if err == nil { |
| + if !reflect.DeepEqual(existing, rebound) { |
| + return ErrTypedInventory |
| + } |
| + } else if !errors.Is(err, sql.ErrNoRows) { |
| + return err |
| + } else if err = h.Inventory.WriteStopInventory(ctx, rebound); err != nil { |
| + return err |
| + } |
| + if err = h.at("inventory"); err != nil { |
| + return err |
| + } |
| + if err = h.Registry.RebindClaimRows(ctx, j.Before, j.After); err != nil { |
| + return err |
| + } |
| + if err = h.at("rows"); err != nil { |
| + return err |
| + } |
| + j.Phase = "complete" |
| + if err = writeColdJournal(path, j); err != nil { |
| + return err |
| + } |
| + return h.at("complete-journal") |
| + }) |
| +} |
| + |
| +func (h ServingHandover) validate(ctx context.Context, j coldJournal, c *hostclaim.Claim) error { |
| + n, p := j.Successor, j.Prior |
| + if c == nil || c.Host != n.Host || c.Holder == nil || c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" || c.Op.Exec != n.Box || c.Holder.Incarnation != n.Incarnation || c.Holder.Stamp != n.Stamp || c.Holder.Session != n.Session || c.Holder.Generation != n.Generation || c.Holder.WriterBox != n.Box { |
| + return ErrChanged |
| + } |
| + if j.ServingInstallation == nil || (p.Session != 0 && (j.Receipt.Target != p || h.VerifyReceipt(ctx, j.Receipt) != nil)) || (p.Session == 0 && !reflect.DeepEqual(j.Receipt, hostguard.FenceReceipt{})) { |
| + return ErrTypedJournal |
| + } |
| + current, rev, err := h.Inventory.Snapshot(ctx, p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp}) |
| + if err != nil { |
| + return err |
| + } |
| + if rev != j.ServingInstallation.InventoryRevision || !reflect.DeepEqual(current, j.Inventory) { |
| + return ErrTypedInventory |
| + } |
| + if err = ValidateAgentInventory(j.Inventory, &j.Before.Route, h.Roots["data"]); err != nil { |
| + return err |
| + } |
| + stateRelative, err := filepath.Rel(h.Roots["data"], filepath.Dir(provision.NestedDBPath(h.Roots["data"], p.Host))) |
| + if err != nil { |
| + return err |
| + } |
| + routes, paths, timers := 0, 0, 0 |
| + for _, e := range j.Inventory.Entries { |
| + if e.Materialization != "materialized" || e.Ownership != j.Before.Route.ClaimOwner { |
| + return ErrTypedInventory |
| + } |
| + switch e.Kind { |
| + case "route": |
| + routes++ |
| + case "path": |
| + paths++ |
| + if e.ManagedRoot != "data" || e.RelativePath != stateRelative || e.Type != 0040000 || e.Device == 0 || e.Inode == 0 { |
| + return ErrTypedInventory |
| + } |
| + a, err := (TypedParker{Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: p.Incarnation, Host: p.Host, Retirement: j.ID}).CaptureArtifact(e.ManagedRoot, e.RelativePath, e.Ownership, ArtifactPath) |
| + if err != nil { |
| + return err |
| + } |
| + if a.Dev != e.Device || a.Ino != e.Inode || a.Type != e.Type { |
| + return ErrChanged |
| + } |
| + case "timer": |
| + timers++ |
| + found := 0 |
| + for _, timer := range j.Before.Timers { |
| + if e.RelativePath == p.Host+"/"+timer.Name && timer.ClaimOwnership.Valid() && timer.ClaimIncarnation == p.Incarnation && timer.ClaimStamp == p.Stamp && timer.ClaimOwner == e.Ownership && timer.ClaimRowID == e.RowIdentity && timer.Registration != "" { |
| + found++ |
| + } |
| + } |
| + if found != 1 { |
| + return ErrTypedInventory |
| + } |
| + default: |
| + return ErrTypedInventory |
| + } |
| + } |
| + if routes != 1 || paths != 1 || timers != len(j.Before.Timers) { |
| + return ErrTypedInventory |
| + } |
| + return h.Validate(ctx, c, p, n, j.Inventory, *j.ServingInstallation) |
| +} |
| + |
| +func (h ServingHandover) Recover(ctx context.Context) error { |
| + entries, err := readTrustedJournalDir(h.JournalDir) |
| + if err != nil { |
| + return err |
| + } |
| + if len(entries) == 0 { |
| + return nil |
| + } |
| + pending, err := h.Guard.PendingFences(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + for _, e := range entries { |
| + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { |
| + continue |
| + } |
| + body, err := readTrustedJournal(filepath.Join(h.JournalDir, e.Name())) |
| + if err != nil { |
| + return err |
| + } |
| + var j coldJournal |
| + if decodeStrict(body, &j) != nil || j.V != 1 || j.ID+".json" != e.Name() || j.ServingInstallation == nil { |
| + return ErrTypedJournal |
| + } |
| + if j.Phase == "complete" { |
| + found := false |
| + for _, target := range pending { |
| + found = found || target == j.Barrier |
| + } |
| + if !found { |
| + continue |
| + } |
| + } |
| + if err = h.Run(ctx, j.Prior, j.Successor); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| +func (h ServingHandover) at(point string) error { |
| + if h.checkpoint != nil { |
| + return h.checkpoint(point) |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostbox/servinghandover_linux_test.go b/internal/hostbox/servinghandover_linux_test.go| new file mode 100644 |
| index 000000000..d5fecd0e0 |
| --- /dev/null |
| +++ b/internal/hostbox/servinghandover_linux_test.go |
| @@ -0,0 +1,167 @@ |
| +//go:build linux |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func servingHandoverFixture(t *testing.T) (ServingHandover, hostguard.FenceTarget, ColdSuccessor) { |
| + t.Helper() |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + source, store, initial := workFixture(t) |
| + admit := func(o hostclaim.Observed, g string) hostclaim.Observed { |
| + n, err := hostclaim.Update(t.Context(), store, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box", Region: "ie", Mode: "cold", NewGeneration: g})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| + } |
| + old := admit(initial, "first") |
| + base := t.TempDir() |
| + disk := &plan14ColdDisk{dir: t.TempDir()} |
| + reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + inv, err := OpenStopInventoryDB(filepath.Join(base, "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { inv.Close() }) |
| + launches, err := OpenLaunchJournal(filepath.Join(base, "launch.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { launches.Close() }) |
| + guard := source.Guard |
| + guard.Root = hostguard.NewRootAuthorityWithInventory(disk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| + _, r, e := inv.Snapshot(ctx, host, c) |
| + return r, e |
| + }) |
| + holder := old.Claim.Holder |
| + owner := ownershipFor(old.Host, holder.Incarnation, holder.Stamp, "box") |
| + row := registry.Route{Host: old.Host, Kind: "instance", Account: holder.Account, App: holder.App, DBPath: provision.NestedDBPath(guard.DataDir, old.Host), Socket: provision.NestedSocket(old.Host), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: holder.Incarnation, ClaimStamp: holder.Stamp, ClaimOwner: owner, ClaimRowID: "old-route"}} |
| + if err = reg.Upsert(row); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := filepath.Dir(row.DBPath) |
| + if err = os.MkdirAll(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.WriteFile(filepath.Join(dir, "env"), []byte("configuration"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + roots := map[string]string{"data": guard.DataDir} |
| + rel, _ := filepath.Rel(guard.DataDir, dir) |
| + a, err := (TypedParker{Roots: roots, ParkedRoots: roots, Incarnation: holder.Incarnation, Host: old.Host, Retirement: "capture"}).CaptureArtifact("data", rel, owner, ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in := StopInventory{Complete: true, Host: old.Host, Incarnation: holder.Incarnation, InstallationStamp: holder.Stamp, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", Entries: []StopInventoryEntry{ |
| + {Kind: "route", ArtifactID: "old-route", ManagedRoot: "registry", RelativePath: old.Host, RowIdentity: "old-route", Ownership: owner, Materialization: "materialized"}, |
| + {Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: owner, Materialization: "materialized", Device: a.Dev, Inode: a.Ino, Type: a.Type}, |
| + }} |
| + if err = inv.WriteStopInventory(t.Context(), in); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, rev, err := inv.Snapshot(t.Context(), old.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + installation := hostguard.RootInstallation{Host: old.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box", Enrollment: "enrollment", Migration: 1, InventoryRevision: rev} |
| + if err = guard.EstablishRoot(t.Context(), installation, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + prior := hostguard.FenceTarget{FenceID: "stop", Region: "ie", Host: old.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Session: holder.Session, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ClaimETag: old.ETag, DirectoryRevision: "1"} |
| + if _, err = guard.BeginFence(t.Context(), prior); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = guard.CompleteFence(t.Context(), prior, []byte("verified stop"), time.Now().UTC(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + successor := admit(old, "second") |
| + n := successor.Claim.Holder |
| + next := ColdSuccessor{Host: old.Host, Incarnation: n.Incarnation, Stamp: n.Stamp, Session: n.Session, Generation: n.Generation, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", ETag: successor.ETag} |
| + h := ServingHandover{ColdHandover: ColdHandover{Guard: guard, Claims: store, Registry: reg, Inventory: inv, Launches: launches, Roots: roots, JournalDir: filepath.Join(base, "handover"), VerifyReceipt: func(_ context.Context, r hostguard.FenceReceipt) error { |
| + if r.Target != prior || string(r.Bytes) != "verified stop" { |
| + return ErrChanged |
| + } |
| + return nil |
| + }}, Validate: func(_ context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, _ ColdSuccessor, _ StopInventory, i hostguard.RootInstallation) error { |
| + if p != prior || i != installation || !reflect.DeepEqual(c, successor.Claim) { |
| + return ErrChanged |
| + } |
| + return nil |
| + }} |
| + return h, prior, next |
| +} |
| + |
| +func TestServingHandoverRecoversEveryBoundary(t *testing.T) { |
| + for _, point := range []string{"intent", "barrier", "inventory", "rows", "complete-journal"} { |
| + t.Run(point, func(t *testing.T) { |
| + h, p, n := servingHandoverFixture(t) |
| + old, _, err := h.Inventory.Snapshot(t.Context(), p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + crash := errors.New("injected interruption") |
| + h.checkpoint = func(got string) error { |
| + if got == point { |
| + return crash |
| + } |
| + return nil |
| + } |
| + if err = h.Run(t.Context(), p, n); !errors.Is(err, crash) { |
| + t.Fatal(err) |
| + } |
| + _ = h.Guard.Activate(t.Context(), p.Host, p.Box, nil) |
| + allowed := func(*hostclaim.Claim) error { return nil } |
| + if err = h.Guard.With(t.Context(), p.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}, allowed, func() error { t.Fatal("successor ran during partial handover"); return nil }); err == nil { |
| + t.Fatal("partial transfer authorized") |
| + } |
| + h.checkpoint = nil |
| + if err = h.Recover(t.Context()); err != nil { |
| + t.Fatal("recover", err) |
| + } |
| + if err = h.Run(t.Context(), p, n); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + if err = h.Guard.With(t.Context(), p.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}, allowed, func() error { return nil }); err != nil { |
| + t.Fatal("completed transfer", err) |
| + } |
| + retained, _, err := h.Inventory.Snapshot(t.Context(), p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp}) |
| + if err != nil || !reflect.DeepEqual(retained, old) { |
| + t.Fatal("old inventory", err) |
| + } |
| + b, err := os.ReadFile(filepath.Join(filepath.Dir(provision.NestedDBPath(h.Guard.DataDir, p.Host)), "env")) |
| + if err != nil || string(b) != "configuration" { |
| + t.Fatal("source lost", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingHandoverRefusesOldDestructiveRecovery(t *testing.T) { |
| + h, p, n := servingHandoverFixture(t) |
| + old := h.ColdHandover |
| + old.OperationsOnly = true |
| + if err := old.Run(t.Context(), p, n); !errors.Is(err, ErrUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Dir(provision.NestedDBPath(h.Guard.DataDir, p.Host))); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/hostguard/coldhandover.go b/internal/hostguard/coldhandover.go| index b477c6f05..8919d1265 100644 |
| --- a/internal/hostguard/coldhandover.go |
| +++ b/internal/hostguard/coldhandover.go |
| @@ -6,8 +6,19 @@ import ( |
| ) |
| func (g Guard) BeginColdHandover(ctx context.Context, prior, barrier FenceTarget, check func(*hostclaim.Claim) error) error { |
| + return g.beginColdHandover(ctx, prior, barrier, nil, check) |
| +} |
| + |
| +func (g Guard) BeginServingHandover(ctx context.Context, prior, barrier FenceTarget, installation RootInstallation, check func(*hostclaim.Claim) error) error { |
| + if g.Root == nil || prior.Op != "" || prior.Phase != "" || prior.ObligationID != "" || barrier.Op != "" || barrier.Phase != "" || barrier.ObligationID != "" || installation.Empty || installation.Host != prior.Host || installation.Incarnation != prior.Incarnation || installation.Stamp != prior.Stamp || installation.Box != prior.Box || installation.Enrollment != prior.Enrollment || installation.InventoryRevision == 0 { |
| + return ErrInvalidInput |
| + } |
| + return g.beginColdHandover(ctx, prior, barrier, &installation, check) |
| +} |
| + |
| +func (g Guard) beginColdHandover(ctx context.Context, prior, barrier FenceTarget, installation *RootInstallation, check func(*hostclaim.Claim) error) error { |
| return g.locked(ctx, prior.Host, func(d *recordDir) error { |
| - if prior.validate(prior.Host) != nil || barrier.validate(prior.Host) != nil || prior.Host != barrier.Host || prior.Incarnation != barrier.Incarnation || barrier.Stamp <= prior.Stamp || barrier.Session <= prior.Session || barrier.Stamp != barrier.Session || check == nil { |
| + if ((installation == nil || prior.Session != 0) && prior.validate(prior.Host) != nil) || barrier.validate(prior.Host) != nil || prior.Host != barrier.Host || prior.Incarnation != barrier.Incarnation || barrier.Stamp <= prior.Stamp || barrier.Session <= prior.Session || barrier.Stamp != barrier.Session || check == nil { |
| return ErrInvalidInput |
| } |
| claim, err := g.fresh(ctx, prior.Host) |
| @@ -24,15 +35,21 @@ func (g Guard) BeginColdHandover(ctx context.Context, prior, barrier FenceTarget |
| if err != nil { |
| return err |
| } |
| + if installation != nil && (r.installation != *installation || r.quarantined || r.pendingInstallation != nil) { |
| + return ErrRecordIncarnation |
| + } |
| found := false |
| for _, receipt := range r.FenceJournal { |
| if receipt.Target == prior { |
| found = true |
| } |
| } |
| - if !found { |
| + if !found && (installation == nil || prior.Session != 0) { |
| return ErrNoFenceReceipt |
| } |
| + if installation != nil && prior.Session == 0 && (prior.FenceID != "" || (r.ActiveFence != nil && *r.ActiveFence != barrier)) { |
| + return ErrFenceTarget |
| + } |
| if r.ActiveFence != nil && *r.ActiveFence == barrier { |
| return d.sync(prior.Host) |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index 169040101..84e757af5 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 9 overlay; pending Paul’s census ruling. |
| -# Generation, legacy and empty-bootstrap root authority have test callers only; production binding remains refused. |
| +# Files seed checkpoint 11 overlay; pending Paul’s census ruling. |
| +# Database retirement activation remains refused; serving installation transfer uses the existing root transport. |
| # 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 |
| @@ -68,16 +68,16 @@ internal/host/claimservingpark_records.go validateServingParkRecord authority C, |
| 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 acquires these inputs under the host lock through the typed journal service 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; live edge peer reauthentication under namespace and host locks before census, inventory and final claim checks; root request entry refuses nil connections d3582f0760b4459721b3e6040cda738b105c1da19cc5d0746d04fea26e313b5c |
| -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 servingRootStops authority C,J4 rev3=PROPOSED-seed; retained exact root serving receipts and known retired installation; same enrollment, session and region, with canonical recorded revision no newer than the active actor d371f01614c0bb64d3f412123fc9a90b8bee43d02b6c48e67fc89d9a35187005 |
| 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 c10497dab1ac3aef334df090b73e4d20fcc67d43a31dd4d457ead6f4f61de83f |
| -@coverage internal/hostbox 4b68d7fc25b29963dcf1b7fe92b047fd2229515d83e5cf424c0835d6e690859d |
| -@coverage internal/hostguard 94912485e1e5fad4bad56985dd48ede776cfcb5fe18e0f4a59953584ddab06c0 |
| +@coverage internal/host 8b2636a4c0d76b826c438e30b4a433ebf2ce5195e9d6aa21980b72a9bf84caa3 |
| +@coverage internal/hostbox eb1f4980ba609cc886ae4bd73aedbb6be3fb2eb104165bb1d8404da0814ed190 |
| +@coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6 |
| 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 |
| @@ -86,7 +86,7 @@ internal/host/claimservinglegacy.go sameServingLegacyEvidence authority C,J4 rev |
| 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.validateLegacyRootEvidence authority C,J4 rev3=PROPOSED-seed; retained no-process or stopped-admission evidence; exact predecessor fields with canonical recorded directory revision bounded by the current actor aa0dfe36c042ab65fd826e4b83eb81bfb9d03e6a54daafa828d5164492329d0d |
| internal/host/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 |
| @@ -122,7 +122,7 @@ internal/hostbox/servingpark_transport.go validateServingParkRequest authority C |
| internal/host/claimservingpark_transport.go var openClaimServingParkAuthority authority C,J4 rev3=PROPOSED-seed; descriptor-relative private rootstate at /var/lib/carlos-claims/authority/serving-parks with creation enabled and public reads disabled file:Open@78 7a1db088a7240e6a65ba0bed96fc5f973a69f70c2273c4475c486a6e0359fb23 |
| internal/host/claimwakestop.go ClaimAssembly.collectCapturedStop authority C,G,J4 rev3=PROPOSED-seed; fresh claim and verified local stop identity; session-zero legacy holder may name only the local writer box, with no admissions; root independently proves and retains no-process evidence file:Exec@2994,file:Read@605 d4cd80663dfae5f9737a74e319d3423f4ef853e1843666b02b4cd2341e869d72 |
| internal/hostbox/restore.go loadPinned authority C,J2,J4 rev3=PROPOSED-seed; exact selected LTX versions and original trailer/pin proof; validated bytes normalized for Litestream while retaining original database checksum for final comparison file:ReadAll@662 1e2b22c7c501d31cd1f2cb3be0fe419fa7e25afde87c6ecb43ca85edb7dbc3a4 |
| -internal/host/claimretire_transport.go ClaimRootHTTP.RecoverPending authority C,J4 rev3=PROPOSED-seed; root startup recovery order; attempts bounded serving retirement recovery even when another root recovery returns an error 0e6c92aae15cac7055607fb24d0e9044223c74933767b2859ed105e49974e006 |
| +internal/host/claimretire_transport.go ClaimRootHTTP.RecoverPending authority C,J4 rev3=PROPOSED-seed; startup serving handover recovery before ordinary cold and root recovery; old destructive serving intents refuse 627b40daecf11e1a6a6c9f92aeb3ebcf226131da6dfa584ed85559c8b9b5ff30 |
| internal/host/claimservingparkroot_linux.go servingParkRoot.withRootInstallation authority C,J4 rev3=PROPOSED-seed; fresh claim, root installation and directory inventory, eligible enrollment, paired tags, census gate and post-input claim reread under namespace then host guard; startup grants no new identity file:Read@2113 8060d3a2628f86257c3600c986242e0d4957ee918214a89655af828dd3ddf93b |
| internal/host/claimservingpark_linux.go servingParkFS.removeEvent authority C,J4 rev3=PROPOSED-seed; recorded empty event directory identity and absence; journaled removal intent before descriptor-relative rmdir, root parent fsync and durable progress; no recursion file:Readdirnames@789 b493696d2f16425ddc2714184b407acbaf13ef62c93f28c3d4ac8dc7012607c9 |
| internal/host/claimservingpark_linux.go servingParkFS.removedEvent authority C,J4 rev3=PROPOSED-seed; recorded namespace parent and absence of previously removed event; replacement names refuse completion 792218fc7bfb4ac7475e7c694f72489812db8591493218f8ee1c9545cb813566 |
| @@ -137,3 +137,20 @@ internal/host/claimservingpark_recovery.go servingParkRuntime.recoverPending aut |
| internal/host/claimservingpark_recovery_linux.go sameServingLegacyConsumption authority C,J4 rev3=PROPOSED-seed; immutable legacy consumption and retained retirement source identities; excludes only per-artifact journal progress from comparison 1524b2aae315e05734a666f80596bc341a007098a84fedeaf1e32451663d4fd4 |
| internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingPark authority C,J4 rev3=PROPOSED-seed; retained intent, exact current successor, independently rechecked installation, enrollment, receipt and local launch history; resumes original park and completes only existing paired tags; no retry settlement 0839c879792469648ceb62edce21319050351cf4db0a57c1a5220282afb7808f |
| internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingParks authority C,J4 rev3=PROPOSED-seed; private record page and per-record validation; continues past corrupt or refused hosts and reports incomplete enumeration; completed deletion separated from live host authority c2f1d9da88c4aaea97783a6d3f3e6b78856f20402e55900cc96d88e8c88ba323 |
| +internal/host/claimservinghandover.go ClaimRootHTTP.servingHandover authority C,J4 rev3=PROPOSED-seed; root-only serving transfer constructor and existing private journal, inventory and registry dependencies 213a1885dd9aa5d0079cd52c4f88c6893ea5b43bfbfd83a67ec2c4697a83265a |
| +internal/host/claimservinghandover.go ClaimRootHTTP.validateServingHandover authority C,J4 rev3=PROPOSED-seed; fresh actor/census, claim, canonical deployment record, root installation and predecessor admission; exact retained session-zero no-process evidence or stopped serving predecessor file:Read@2660,file:Read@3770,registry:ClaimIncarnation@1500,registry:ClaimOwner@1642,registry:ClaimRowID@1664,registry:ClaimStamp@1528,whole:Route@1767 10a3ea792139e8328eec6292e9612d0f3d84c2a8500f8959ede3b92230a00788 |
| +internal/host/claimservinghandover.go ClaimRootHTTP.servingLegacyHandoverTarget authority C,J4 rev3=PROPOSED-seed; root-private no-process record selected by host/box/incarnation; only its retained exact stop tuple supplies legacy transfer authority file:Read@524 3fbec96fa13bd112c83253221e808a80943118b1dc06b6b5bcbbeb41ac5771d9 |
| +internal/host/claimhandover_transport.go ClaimRootHTTP.serveColdHandover authority C,J4 rev3=PROPOSED-seed; authenticated existing handover transport; serving-specific transfer and repeated peer identity check under the host lock; operation handover remains separate 9ea7b9ea4a4962ad92a5f6ce19a24fcffca73e40aae9318a2d8c79ef25351e6a |
| +internal/host/claimhandover_transport.go coldAdmissionHook authority C,J4 rev3=PROPOSED-seed; serving predecessor receipt or session-zero identity selector; root supplies the retained no-process result 70974148b3f47e4cb2057e4000c472cc4b372707fb1ff64b5e62582dc92b70c2 |
| +internal/hostbox/coldhandover.go type ColdHandover authority C,J4 rev3=PROPOSED-seed; operation-only production cold-handover policy; no source-preserving serving authority from operation cleanup 874b2b74ea349dd93655d4e11f4d9e97752c3f216593ebb922d819caba7981c4 |
| +internal/hostbox/coldhandover.go type coldJournal authority C,J4 rev3=PROPOSED-seed; additive root installation snapshot for serving transfer; retained predecessor inventory, receipt, row contents and exact successor 2f5e48fec1ec2b6f1774f905fc27f8c8d0b33605f260e1ea483cbf55ace9b53e |
| +internal/hostbox/coldhandover.go ColdHandover.Run authority C,J4 rev3=PROPOSED-seed; production operation-only gate refuses destructive serving handover, including old pending journals; existing move effects unchanged file:Exec@1027,file:Lstat@9838,file:OpenRoot@9760,file:Read@1379,registry:Addr@3654,registry:Addrs@3672,registry:Backing@3691,registry:Canary@3748,registry:ClaimIncarnation@3532,registry:ClaimIncarnation@4034,registry:ClaimOwner@10588,registry:ClaimOwner@4116,registry:ClaimOwner@4136,registry:ClaimOwner@6011,registry:ClaimOwner@7439,registry:ClaimOwnership@10180,registry:ClaimOwnership@3502,registry:ClaimOwnership@4480,registry:ClaimOwnership@4698,registry:ClaimOwnership@4731,registry:ClaimRowID@10478,registry:ClaimRowID@10559,registry:ClaimRowID@4154,registry:ClaimRowID@4762,registry:ClaimRowID@7464,registry:ClaimStamp@3577,registry:ClaimStamp@4081,registry:Host@7401,registry:Kind@3610,registry:Name@4816,registry:Name@7420,registry:Platform@3732,registry:Registration@4180,registry:Registration@7489,registry:Replica@3762,registry:Route@10180,registry:Route@4480,registry:Route@4731,registry:Route@5262,registry:Route@6011,registry:Timers@4679,registry:Timers@4698,registry:Timers@4762,registry:Timers@4816,registry:Timers@7276,registry:Tunnel@3712,registry:Unit@3636,whole:ClaimOwnership@10194,whole:ClaimOwnership@3506,whole:ClaimOwnership@4492,whole:ClaimOwnership@4714,whole:ClaimOwnership@4743,whole:ColdRows@10182,whole:ColdRows@11294,whole:ColdRows@11304,whole:ColdRows@4977,whole:ColdRows@4985,whole:ColdRows@4993,whole:ColdRows@5000,whole:ColdRows@5264,whole:ColdRows@6013,whole:ColdRows@7278,whole:Route@10188,whole:Route@3487,whole:Route@3847,whole:Route@4279,whole:Route@4287,whole:Route@4338,whole:Route@4346,whole:Route@4486,whole:Route@4737,whole:Route@5271,whole:Route@6020,whole:Timer@4018,whole:Timer@4292,whole:Timer@4300,whole:Timer@4351,whole:Timer@4389,whole:Timer@4685,whole:Timer@4704,whole:Timer@4768,whole:Timer@4822,whole:Timer@7285 050ae1e6818687e19e7a0cd443c09e837e54bdc00f4ea1feb6430cfedd2b5343 |
| +internal/hostbox/servinghandover.go type ServingHandover authority C,J4 rev3=PROPOSED-seed; root-only serving transfer dependencies and validation callback; source state remains in place d56e1f341efd969be8757c2a2440170192d30338095c28ee9cba337de7a81bd6 |
| +internal/hostbox/servinghandover.go ServingHandover.Run authority C,J4 rev3=PROPOSED-seed; root installation/inventory snapshot, exact stopped predecessor, canonical nested row, no-follow state identity, successor launch absence and private journal; fenced inventory/row transfer with retained old evidence registry:Account@2154,registry:Addr@2373,registry:Addrs@2391,registry:App@2198,registry:Backing@2410,registry:Canary@2467,registry:ClaimOwner@5480,registry:ClaimOwnership@2841,registry:ClaimOwnership@3057,registry:ClaimOwnership@3090,registry:ClaimRowID@3120,registry:ClaimRowID@5551,registry:ClaimRowID@5725,registry:DBPath@2286,registry:Kind@2128,registry:Name@3174,registry:Name@5690,registry:Platform@2451,registry:Replica@2481,registry:Route@2841,registry:Route@3090,registry:Route@5480,registry:Route@5551,registry:Socket@2234,registry:Timers@3039,registry:Timers@3057,registry:Timers@3120,registry:Timers@3174,registry:Timers@5633,registry:Tunnel@2431,registry:Unit@2355,whole:ClaimOwnership@2853,whole:ClaimOwnership@3073,whole:ClaimOwnership@3102,whole:ColdRows@3514,whole:ColdRows@3522,whole:ColdRows@3530,whole:ColdRows@3537,whole:ColdRows@5482,whole:ColdRows@5553,whole:ColdRows@5635,whole:ColdRows@6216,whole:ColdRows@6226,whole:Route@2114,whole:Route@2643,whole:Route@2651,whole:Route@2701,whole:Route@2709,whole:Route@2847,whole:Route@3096,whole:Route@5488,whole:Route@5559,whole:Timer@2656,whole:Timer@2664,whole:Timer@2714,whole:Timer@2752,whole:Timer@3045,whole:Timer@3063,whole:Timer@3126,whole:Timer@3180,whole:Timer@5641 01b9e603a5f2a2476017777a7d02fafc76424d491201d6757170d66997fa481a |
| +internal/hostbox/servinghandover.go ServingHandover.validate authority C,J4 rev3=PROPOSED-seed; fresh serving claim, unchanged root-recorded inventory, exact recorded state-directory inode/type, complete route/timer set and production authority callback file:Exec@258,registry:ClaimIncarnation@2164,registry:ClaimOwner@1379,registry:ClaimOwner@2238,registry:ClaimOwnership@2132,registry:ClaimRowID@2273,registry:ClaimStamp@2207,registry:Name@2118,registry:Registration@2310,registry:Route@1033,registry:Route@1379,registry:Timers@2064,registry:Timers@2507,whole:ClaimOwnership@2138,whole:ColdRows@1035,whole:ColdRows@1381,whole:ColdRows@2066,whole:ColdRows@2509,whole:Route@1042,whole:Route@1388,whole:Timer@2073,whole:Timer@2516 2985e74f1c0aed6a6470e4ee4f03e794e32a97806542a51045a21e76ba7bce03 |
| +internal/hostbox/servinghandover.go ServingHandover.Recover authority C,J4 rev3=PROPOSED-seed; trusted serving transfer journals and pending root barriers; replay exact original transfer before prelaunch 7ec3bd1b929ca3b3fd7d20c53437740d54086fbb2ae1fc8c545dbca3e388bab1 |
| +internal/hostbox/servinghandover.go ServingHandover.at authority C,J4 rev3=PROPOSED-seed; in-process interruption seam; nil in production 0178e5fcfb2cc194fb4f33f0f230b52849ddeb87a4b7c4e6cfb640e8b2f00b8a |
| +internal/hostguard/coldhandover.go Guard.BeginColdHandover authority C,J4 rev3=PROPOSED-seed; unchanged operation handover checks delegated to shared locked implementation f06625d82015893692dce315f8014a7f44d65de69f681a5102f418e9772b875e |
| +internal/hostguard/coldhandover.go Guard.BeginServingHandover authority C,J4 rev3=PROPOSED-seed; serving-only root installation and empty-operation checks; session-zero authorization remains in root callback bd953e2794294c5638161eff30fd743000fbd467d6ea37df734668021d8f6c02 |
| +internal/hostguard/coldhandover.go Guard.beginColdHandover authority C,J4 rev3=PROPOSED-seed; fresh claim and root installation under host locks; exact completed stop for admitted predecessors, validated no-process path for session zero, and durable successor barrier 6bdeec90f4322aa0824557cd35950af428b25889e15badeb4b017ccbbf0aae70 |
| +internal/host/claimservinghandover.go servingPriorRevision authority C,J4 rev3=PROPOSED-seed; canonical recorded directory revision bounded by the current authenticated enrollment revision; never rewrites retained stop evidence 8007e188e2ad600cf36d8eed0fb5adb591f08f52be52285c85db348e0780cb84 |