WIP claim Plan 14 batch 2 checkpoint 2 (not for merge): B3 complete with rollback crash-resume; ordinary instances excluded via empty record Unit; J1, C4 mostly done; C3 registration outside the agent lock; fence-view deadline and lock witnesses; source-recovery rebind pending a ruling
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
56 files changed,
+2908
−127
approved_moves_copy_test.go+1 −0internal/activator/rolelaunch.go+1 −0internal/activator/rolelaunch_linux.go+1 −1internal/activator/rolelaunch_root_linux.go+5 −0internal/activator/rolelaunch_root_linux_test.go+54 −0internal/host/agent.go+9 −2internal/host/claimassembly.go+2 −1internal/host/claimconstruction.go+10 −3internal/host/claimjournal_transport.go+10 −0internal/host/claimlaunch_production.go+1 −1internal/host/claimretire_transport.go+2 −1internal/host/claimroleassembly.go+3 −0internal/host/claimroleassembly_linux.go+64 −6internal/host/claimstop_run.go+1 −1internal/host/plan14_backend_source_test.go+104 −0internal/host/plan14_canonical_restart_test.go+28 −5internal/host/plan14_recovery_source_test.go+63 −0internal/host/plan14_release_lock_test.go+51 −0internal/host/plan14_root_launch_test.go+82 −0internal/host/plan9fix4_claimjournal_migration_linux_test.go+5 −0internal/host/proofbinding.go+8 −1internal/host/proofbinding_test.go+5 −2internal/host/registry_privilege_test.go+2 −1internal/host/registryowner_test.go+3 −1internal/host/rootfenceview.go+6 −0internal/host/rootfenceview_test.go+185 −0internal/host/rootinstallation.go+46 −0internal/host/rootinstallation_test.go+74 −0internal/host/rootinventory.go+254 −0internal/host/rootinventory_process_test.go+119 −0internal/host/rootinventory_state_linux.go+140 −0internal/host/rootinventory_test.go+165 −0internal/host/rootrecord_claim.go+61 −0internal/host/rootrestart.go+81 −32internal/host/rootrollback.go+7 −4internal/host/rootrollback_process_test.go+213 −0internal/host/rootrollback_resume.go+113 −0internal/host/rootrollback_resume_test.go+91 −0internal/host/rootrollback_test.go+13 −2internal/host/rootroute.go+6 −0internal/hostbox/inventory_expected.go+43 −0internal/hostbox/inventory_expected_test.go+50 −0internal/hostbox/inventory_registration.go+195 −0internal/hostbox/journalservice.go+23 −3internal/hostbox/launchjournal.go+2 −0internal/hostbox/reconcile.go+22 −9internal/hostbox/reconcile_adapter.go+3 −0internal/hostguard/coldhandover.go+10 −1internal/hostguard/guard.go+3 −0internal/hostguard/plan14_root_fences_test.go+272 −2internal/hostguard/record.go+13 −11internal/hostguard/retirement.go+14 −0internal/hostguard/root.go+115 −9internal/hostguard/rootview.go+3 −0internal/provision/plan9_b8_census_test.go+11 −2internal/provision/testdata/plan14-root-reads.tsv+40 −26
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 328.568s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.089s
ok github.com/carlosframework/platform/cmd/moveprobe 93.195s
ok github.com/carlosframework/platform/internal/activator 228.313s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.334s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.133s
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.481s
? 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.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.190s
ok github.com/carlosframework/platform/internal/changelog 0.236s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 359.328s
? 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.256s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.045s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 25.920s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.135s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.025s
ok github.com/carlosframework/platform/internal/geo 0.014s
ok github.com/carlosframework/platform/internal/geoip 0.036s
ok github.com/carlosframework/platform/internal/host 62.335s
ok github.com/carlosframework/platform/internal/hostactors 0.321s
ok github.com/carlosframework/platform/internal/hostbox 1.223s
ok github.com/carlosframework/platform/internal/hostclaim 0.256s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 0.309s
ok github.com/carlosframework/platform/internal/hostfence 0.047s
ok github.com/carlosframework/platform/internal/hostfiles 4.169s
ok github.com/carlosframework/platform/internal/hostfloor 0.008s
ok github.com/carlosframework/platform/internal/hostguard 0.340s
ok github.com/carlosframework/platform/internal/hostops 1.354s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostresolve 0.140s
ok github.com/carlosframework/platform/internal/hostwrite 0.003s
ok github.com/carlosframework/platform/internal/infraview 0.003s
ok github.com/carlosframework/platform/internal/instancectl 1.711s
ok github.com/carlosframework/platform/internal/instanceexec 2.709s
ok github.com/carlosframework/platform/internal/instancefs 0.004s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.367s
ok github.com/carlosframework/platform/internal/lease 9.439s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.022s
ok github.com/carlosframework/platform/internal/mail 0.157s
ok github.com/carlosframework/platform/internal/maintenance 0.090s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.514s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.077s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.044s
ok github.com/carlosframework/platform/internal/moves/archive 0.754s
ok github.com/carlosframework/platform/internal/objstore 0.163s
ok github.com/carlosframework/platform/internal/ops 0.156s
ok github.com/carlosframework/platform/internal/park 0.189s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 11.571s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.826s
ok github.com/carlosframework/platform/internal/relay 7.230s
ok github.com/carlosframework/platform/internal/release 19.353s
ok github.com/carlosframework/platform/internal/rollout 3.284s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.234s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.528s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.036s
ok github.com/carlosframework/platform/internal/status 0.039s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.131s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.003s
ok github.com/carlosframework/platform/internal/tunnelclient 0.003s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.541s
=== 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.023s
=== RUN TestT4StorageProbeMinio
2026/10/13 01:11:52 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDF1A3D6E3199D, 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.020s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index cbc07bc6f..b90223a54 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -1153,6 +1153,7 @@ func TestMovesTextAppliedPlaceholders(t *testing.T) { |
| // copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff |
| // fails when the list falls behind. |
| var movesScanFiles = []string{ |
| + "internal/host/rootfloor_reconstruct.go", |
| // Claim-owned pages share the alert transport; keep their drafts in the copy census. |
| "internal/console/claim_loss_pages.go", |
| "internal/host/claimhandover_crossbox.go", |
diff --git a/internal/activator/rolelaunch.go b/internal/activator/rolelaunch.go| index a8bebb71c..dc6b40c7b 100644 |
| --- a/internal/activator/rolelaunch.go |
| +++ b/internal/activator/rolelaunch.go |
| @@ -42,6 +42,7 @@ type RoleLaunch struct { |
| MemoryMax int64 |
| RegisterProducer func(context.Context, string) (RoleProducer, error) |
| Initialize func(context.Context) error |
| + InitializeUnlocked func(context.Context) error |
| Parent *cgroup.Prepared |
| ParentPath string |
| CgroupRoot string |
diff --git a/internal/activator/rolelaunch_linux.go b/internal/activator/rolelaunch_linux.go| index 71c1be7c9..528c3ed40 100644 |
| --- a/internal/activator/rolelaunch_linux.go |
| +++ b/internal/activator/rolelaunch_linux.go |
| @@ -34,7 +34,7 @@ func (l RoleLaunch) Start(ctx context.Context, role string, attempt hostclaim.Wa |
| // the future root integration. It grants no execution capability and Start |
| // never calls it while the isolated root protocol is unavailable. |
| func (l RoleLaunch) prepareRoleReservation(ctx context.Context, role string, attempt hostclaim.WakeRetryAttempt, build func() *exec.Cmd) (*exec.Cmd, *cgroup.Prepared, hostbox.LaunchReservation, error) { |
| - if l.Parent == nil || l.ParentPath == "" || l.Work == nil || l.Journal == nil || l.Executable == nil || build == nil || attempt.ID == "" || attempt.InvocationID == "" || attempt.LaunchInputs == "" || attempt.Session != l.Target.Session || attempt.Box != l.Target.Box || attempt.Enrollment != l.Target.Enrollment || attempt.BootID != l.Target.BootID || attempt.ControllerID != l.Target.ControllerID { |
| + if l.Journal.Remote() || l.Parent == nil || l.ParentPath == "" || l.Work == nil || l.Journal == nil || l.Executable == nil || build == nil || attempt.ID == "" || attempt.InvocationID == "" || attempt.LaunchInputs == "" || attempt.Session != l.Target.Session || attempt.Box != l.Target.Box || attempt.Enrollment != l.Target.Enrollment || attempt.BootID != l.Target.BootID || attempt.ControllerID != l.Target.ControllerID { |
| return nil, nil, hostbox.LaunchReservation{}, hostwrite.ErrUnavailable |
| } |
| var cmd *exec.Cmd |
diff --git a/internal/activator/rolelaunch_root_linux.go b/internal/activator/rolelaunch_root_linux.go| index 64e312dcc..d92c39eff 100644 |
| --- a/internal/activator/rolelaunch_root_linux.go |
| +++ b/internal/activator/rolelaunch_root_linux.go |
| @@ -42,6 +42,11 @@ func (l RoleLaunch) startIsolated(ctx context.Context, role string, attempt host |
| attempt.Enrollment != l.Target.Enrollment || attempt.BootID != l.Target.BootID || attempt.ControllerID != l.Target.ControllerID { |
| return fail(hostwrite.ErrUnavailable) |
| } |
| + if l.InitializeUnlocked != nil { |
| + if err := l.InitializeUnlocked(ctx); err != nil { |
| + return fail(err) |
| + } |
| + } |
| host := l.Target.Host |
| var cmd *exec.Cmd |
| var executable *os.File |
diff --git a/internal/activator/rolelaunch_root_linux_test.go b/internal/activator/rolelaunch_root_linux_test.go| index c2535b65b..6fdd808bf 100644 |
| --- a/internal/activator/rolelaunch_root_linux_test.go |
| +++ b/internal/activator/rolelaunch_root_linux_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "strconv" |
| "strings" |
| "sync" |
| "syscall" |
| @@ -415,3 +416,56 @@ func shortTempDir(t *testing.T) string { |
| t.Cleanup(func() { os.RemoveAll(dir) }) |
| return dir |
| } |
| + |
| +type plan14LaunchWork struct { |
| + held bool |
| + calls int |
| + stale bool |
| +} |
| + |
| +func (w *plan14LaunchWork) With(_ context.Context, _ hostwrite.Identity, fn func() error) error { |
| + if w.held { |
| + return hostwrite.ErrUnavailable |
| + } |
| + w.calls++ |
| + if w.stale { |
| + return hostwrite.ErrUnavailable |
| + } |
| + w.held = true |
| + defer func() { w.held = false }() |
| + return fn() |
| +} |
| +func TestPlan14LaunchInitializationOutsideLock(t *testing.T) { |
| + for _, stale := range []bool{false, true} { |
| + t.Run(strconv.FormatBool(stale), func(t *testing.T) { |
| + f := newRootLaunchFixture(t) |
| + work := &plan14LaunchWork{} |
| + f.launch.Work = work |
| + calls := 0 |
| + f.launch.InitializeUnlocked = func(context.Context) error { |
| + if work.held { |
| + t.Fatal("root RPC under agent lock") |
| + } |
| + calls++ |
| + work.stale = stale |
| + return nil |
| + } |
| + _, _, settle, _, err := f.launch.Start(t.Context(), "main", f.attempt, f.build, f.before) |
| + if stale { |
| + if err == nil || len(f.root.launches) != 0 { |
| + t.Fatal("stale initialization applied") |
| + } |
| + } else { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = settle(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if calls != 1 || work.calls != 1 { |
| + t.Fatal(calls, work.calls) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index e87d15ee5..ff7103bb4 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4258,6 +4258,10 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // the hour, or within seconds of a SetHold (its caller touches |
| // stage-requested). Here, before the fatal gates below, so a pass that |
| // dies later still judged. Never fatal; one stat on a box with no floor. |
| + restartChecks := productionRootRestartChecks(systemdUnits{}, release.OptDir()) |
| + if err := recoverRootRestarts(claimCtx, systemdUnits{}, release.OptDir(), os.Getenv, restartChecks, claims.Writes); err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| if err := rootJudgePrev(reg); err != nil { |
| log.Printf("adopt: moves protocol floor: %v", err) |
| } |
| @@ -4303,8 +4307,8 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // settled: D1's settle passing records the build in the healthy history |
| // (D3, proofobserve.go). Root is the process that can hash the unit's |
| // executable. |
| - restartChecks := productionRootRestartChecks(systemdUnits{}, release.OptDir()) |
| - restart := rootRestartRouteWith(reg, systemdUnits{}, release.OptDir(), os.Getenv, recordSettled(systemdUnits{}, procExePath, release.OptDir(), release.RootHealthyDir, time.Now), restartChecks) |
| + settled := recordSettled(systemdUnits{}, procExePath, release.OptDir(), release.RootHealthyDir, time.Now) |
| + restart := rootRestartRouteWith(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks) |
| // The restart-request half, and the ONE place it dispatches from — |
| // including for exec-backed routes, whose children belong to the agent: |
| // rootRestartRoute's try-restart arm reaches a carlos-app@ unit, and an |
| @@ -4429,6 +4433,9 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // run). -stage-only keeps it for the same reason it keeps the converge: |
| // a relayed stage is exactly the request that performs such a restart. |
| passOpts := []release.PassOption{release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(restartChecks.finish), |
| + release.WithSelectedRestarts( |
| + rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks, false), |
| + rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, nil, restartChecks, true)), |
| release.WithStartUnit(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), release.WithHostWrites(claims.Writes), |
| release.WithDigestRatchet(pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv)), |
| release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), |
diff --git a/internal/host/claimassembly.go b/internal/host/claimassembly.go| index 93b3baf30..1f0cc36e8 100644 |
| --- a/internal/host/claimassembly.go |
| +++ b/internal/host/claimassembly.go |
| @@ -350,7 +350,8 @@ func NewProductionRootClaimStop(ctx context.Context) (http.Handler, error) { |
| } |
| reconcile := *assembly.Reconcile |
| reconcile.Box, reconcile.Enrollment = actor.Box, actor.Enrollment |
| - return ClaimRootHTTP{Journals: assembly.Journals, RequireNewWork: func(ctx context.Context) error { _, err := assembly.Identity.Verify(ctx); return err }, Launches: assembly.Launches, Stop: stop, Reconcile: reconcile, Roots: assembly.ManagedRoots}, nil |
| + reconcile.Census = &hostbox.EmptyInstallationCensus{Inventory: assembly.Reconcile.Inventory, Registry: assembly.Reconcile.Registry, Launches: assembly.Launches, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: fmt.Sprint(actor.Revision), Roots: assembly.ManagedRoots, SharedPaths: assembly.SharedPaths} |
| + return ClaimRootHTTP{InventoryActor: assembly.actorForRoot, Journals: assembly.Journals, RequireNewWork: func(ctx context.Context) error { _, err := assembly.Identity.Verify(ctx); return err }, Launches: assembly.Launches, Stop: stop, Reconcile: reconcile, Roots: assembly.ManagedRoots}, nil |
| } |
| func claimMoveLocalAuthorizer(check func(context.Context) error, owner func(context.Context, hostclaim.Observed, hostclaim.Obligation) error) ClaimLocalParkAuthorizer { |
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go| index 954aa88ed..ee53d91ea 100644 |
| --- a/internal/host/claimconstruction.go |
| +++ b/internal/host/claimconstruction.go |
| @@ -33,6 +33,10 @@ import ( |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| +var openClaimRootAuthority = func() (rootstate.Store, error) { |
| + return rootstate.Open("/var/lib/carlos-claims/authority/fences", true, false) |
| +} |
| + |
| type ClaimRuntimeResources struct { |
| Mode string |
| Registry *registry.Registry |
| @@ -257,7 +261,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| if r.Mode == "agent" { |
| guard.FenceView = rootFenceViewClient(rootJournalClient(controlsock.Path)) |
| } else { |
| - authority, err = rootstate.Open("/var/lib/carlos-claims/authority/fences", true, false) |
| + authority, err = openClaimRootAuthority() |
| if err != nil { |
| return nil, err |
| } |
| @@ -266,7 +270,10 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| _ = authority.Close() |
| } |
| }() |
| - guard.Root = hostguard.NewRootAuthority(authority) |
| + guard.Root = hostguard.NewRootAuthorityWithInventory(authority, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) { |
| + _, revision, err := inventory.Snapshot(ctx, host, c) |
| + return revision, err |
| + }) |
| } |
| producers := &ClaimProducerSet{CloseWriters: launches.CloseWriters} |
| inputs, admissionEvidence, publicationEvidence := owners.Inputs, owners.AdmissionEvidence, owners.PublicationEvidence |
| @@ -292,7 +299,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla |
| target := hostguard.FenceTarget{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Op: o.Claim.Op.ID, Phase: o.Claim.Op.Phase, Box: actor.Box, Region: actor.Region, Enrollment: actor.Enrollment, BootID: strings.TrimSpace(string(boot)), ControllerID: controller, ClaimETag: o.ETag, DirectoryRevision: strconv.FormatUint(actor.Revision, 10)} |
| return producers.Ensure(target, processStart, launches.RecordProducer, launches.CompleteProducer) |
| } |
| - wake.Roles = &ClaimRoleAssembly{Manager: r.Manager, Journal: launches, Inventory: inventory, ManagedRoots: roots, Identity: owners.Identity, BootID: strings.TrimSpace(string(boot)), ControllerID: controller} |
| + wake.Roles = &ClaimRoleAssembly{Guard: guard, Claims: store, Manager: r.Manager, Journal: launches, Inventory: inventory, ManagedRoots: roots, Identity: owners.Identity, BootID: strings.TrimSpace(string(boot)), ControllerID: controller} |
| if r.Mode == "agent" { |
| wake.Roles.Root = NewClaimRoleRootClient(controlsock.Path) |
| } |
diff --git a/internal/host/claimjournal_transport.go b/internal/host/claimjournal_transport.go| index 1e1225a22..c380d7e71 100644 |
| --- a/internal/host/claimjournal_transport.go |
| +++ b/internal/host/claimjournal_transport.go |
| @@ -91,6 +91,16 @@ func (h ClaimRootHTTP) serveJournal(w http.ResponseWriter, r *http.Request, peer |
| h.serveFenceView(w, r, q) |
| return |
| } |
| + if q.Op == "inventory-write" && h.Stop.Coordinator.Guard.Root != nil { |
| + out, err := h.commitRootInventory(r.Context(), q) |
| + if err != nil { |
| + w.WriteHeader(http.StatusConflict) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(out) |
| + return |
| + } |
| owner := h.Reconcile.Box + "/" + h.Reconcile.Enrollment |
| if out, found, err := h.Journals.Replay(r.Context(), owner, q); err != nil { |
| // Only a proven mismatch (other owner or bytes under a stored ID) is a |
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index f122c3dc6..cd6750bb7 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -78,7 +78,7 @@ func claimLaunchFor(root ClaimRootHTTP, actor func(context.Context) (hostbox.Act |
| ExeRoot: filepath.Join(controlsock.Dir, "exe"), |
| Room: instancectl.DiskRoom(filepath.Join(roots["data"], "instances")), |
| } |
| - launch := &ClaimLaunchRoot{ValidateExecutable: claimStagedExecutable(&instancectl.Server{OptDir: roots["opt"], HostApp: instanceRegistryHostApp(nil)}), DataRoot: roots["data"], Clock: execlease.SystemClock{}, Authenticate: root.Stop.Authenticate, Guard: coordinator.Guard, Actor: actor, Journal: root.Launches, Units: units, |
| + launch := &ClaimLaunchRoot{ValidateExecutable: claimStagedExecutable(&instancectl.Server{OptDir: roots["opt"], HostApp: rootInstallationHostApp(coordinator.Guard, coordinator.Evidence)}), DataRoot: roots["data"], Clock: execlease.SystemClock{}, Authenticate: root.Stop.Authenticate, Guard: coordinator.Guard, Actor: actor, Journal: root.Launches, Units: units, |
| Kernel: linuxClaimLaunchKernel{OptRoot: roots["opt"], CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}} |
| root.Stop.Coordinator.Kernel = claimUnitStopKernel{Inner: coordinator.Kernel, Journal: root.Launches, Units: units} |
| return ClaimRootWithLaunch{Root: root, Launch: launch}, nil |
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go| index 7c771bec9..912ac70db 100644 |
| --- a/internal/host/claimretire_transport.go |
| +++ b/internal/host/claimretire_transport.go |
| @@ -21,6 +21,7 @@ import ( |
| ) |
| type ClaimRootHTTP struct { |
| + InventoryActor func(context.Context) (hostbox.Actor, error) |
| RequireNewWork func(context.Context) error |
| Journals *hostbox.JournalService |
| Launches *hostbox.LaunchJournal |
| @@ -47,7 +48,7 @@ func (h ClaimRootHTTP) RecoverPending(ctx context.Context) error { |
| return err |
| } |
| } |
| - return nil |
| + return h.recoverRootInventory(ctx) |
| } |
| func (h ClaimRootHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/claim/retire" && r.URL.Path != "/claim/handover" && r.URL.Path != "/claim/journal" { |
diff --git a/internal/host/claimroleassembly.go b/internal/host/claimroleassembly.go| index 7c83f902e..3a108aa60 100644 |
| --- a/internal/host/claimroleassembly.go |
| +++ b/internal/host/claimroleassembly.go |
| @@ -16,9 +16,12 @@ import ( |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| type ClaimRoleAssembly struct { |
| + Guard hostguard.Guard |
| + Claims release.Store |
| // Root is the reservation-bearing isolated root launch protocol (claim |
| // Plan 11, R10). Nil keeps every claimed launch refused at Preflight. |
| prepareGroup func(string, cgroup.Limit) (*cgroup.Prepared, hostbox.LaunchGroup, error) |
diff --git a/internal/host/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go| index 7e044989d..ad98346c0 100644 |
| --- a/internal/host/claimroleassembly_linux.go |
| +++ b/internal/host/claimroleassembly_linux.go |
| @@ -13,6 +13,7 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "reflect" |
| "strconv" |
| "strings" |
| "sync" |
| @@ -63,8 +64,30 @@ func (a *ClaimRoleAssembly) Prelaunch(ctx context.Context, o hostclaim.Observed, |
| if e != nil { |
| return e |
| } |
| + if a.Journal.Remote() { |
| + return nil |
| + } |
| return a.Journal.Prelaunch(ctx, hostbox.LaunchInventory{ControllerPID: os.Getpid(), ProcessStart: start, Target: target, Groups: []hostbox.LaunchGroup{group}, NoState: true}) |
| }) |
| + if err == nil && a.Journal.Remote() { |
| + err = a.Journal.Prelaunch(ctx, hostbox.LaunchInventory{ControllerPID: os.Getpid(), ProcessStart: start, Target: target, Groups: []hostbox.LaunchGroup{group}, NoState: true}) |
| + if err == nil { |
| + err = work.With(ctx, in.Identity, func() error { |
| + if parent == nil { |
| + return hostbox.ErrChanged |
| + } |
| + id, e := parent.Identity() |
| + if e != nil { |
| + return e |
| + } |
| + if id.Device != group.Device || id.Inode != group.Inode { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + }) |
| + } |
| + } |
| + |
| if err != nil { |
| if parent != nil { |
| err = errors.Join(err, parent.Close()) |
| @@ -137,7 +160,7 @@ func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, wo |
| launch.Spec = &hostbox.LaunchSpec{Identity: in.Identity, Executable: in.Executable, Digest: in.ExecutableSHA256, Args: append([]string(nil), in.Args...), Env: append([]string(nil), in.Env...)} |
| launch.Target, launch.Work, launch.Identity = target, work, in.Identity |
| var mu sync.Mutex |
| - launch.Initialize = func(ctx context.Context) error { |
| + initializeFiles := func(ctx context.Context) ([]hostbox.LaunchFile, error) { |
| mu.Lock() |
| defer mu.Unlock() |
| files := []hostbox.LaunchFile{} |
| @@ -157,23 +180,25 @@ func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, wo |
| } |
| } |
| if e != nil { |
| - return e |
| + return nil, e |
| } |
| var stat unix.Stat_t |
| e = unix.Fstat(int(f.Fd()), &stat) |
| closeErr := f.Close() |
| if e != nil || closeErr != nil { |
| - return errors.Join(e, closeErr) |
| + return nil, errors.Join(e, closeErr) |
| } |
| if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Nlink != 1 { |
| - return hostbox.ErrChanged |
| + return nil, hostbox.ErrChanged |
| } |
| files = append(files, hostbox.LaunchFile{Device: uint64(stat.Dev), Inode: stat.Ino}) |
| } |
| if len(files) == 0 { |
| - return hostwrite.ErrUnavailable |
| + return nil, hostwrite.ErrUnavailable |
| } |
| - // The pre-launch record already exists; files join it before Start. |
| + return files, nil |
| + } |
| + recordFiles := func(ctx context.Context, files []hostbox.LaunchFile) error { |
| for _, f := range files { |
| if err := a.Journal.RecordFile(ctx, target, f); err != nil { |
| return err |
| @@ -181,6 +206,36 @@ func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, wo |
| } |
| return nil |
| } |
| + if a.Journal.Remote() { |
| + launch.InitializeUnlocked = func(ctx context.Context) error { |
| + var files []hostbox.LaunchFile |
| + if err := work.With(ctx, in.Identity, func() error { var e error; files, e = initializeFiles(ctx); return e }); err != nil { |
| + return err |
| + } |
| + if err := recordFiles(ctx, files); err != nil { |
| + return err |
| + } |
| + return work.With(ctx, in.Identity, func() error { |
| + again, err := initializeFiles(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if !reflect.DeepEqual(files, again) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + }) |
| + } |
| + } else { |
| + launch.Initialize = func(ctx context.Context) error { |
| + files, err := initializeFiles(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + return recordFiles(ctx, files) |
| + } |
| + } |
| + |
| launch.Executable = func(ctx context.Context, cmd *exec.Cmd) (*os.File, error) { |
| return verifiedClaimExecutable(ctx, cmd, in.Executable, in.ExecutableSHA256) |
| } |
| @@ -292,6 +347,9 @@ func (a *ClaimRoleAssembly) PrepareState(ctx context.Context, o hostclaim.Observ |
| if err != nil { |
| return err |
| } |
| + if a.Inventory.Remote() { |
| + return a.prepareStateRemote(ctx, o, work, in, actor) |
| + } |
| return work.With(ctx, in.Identity, func() error { |
| h := o.Claim.Holder |
| inventory, err := a.Inventory.ReadInstallation(ctx, o.Host, h.Incarnation, h.Stamp) |
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go| index 9c8efa9b0..cada0d6e8 100644 |
| --- a/internal/host/claimstop_run.go |
| +++ b/internal/host/claimstop_run.go |
| @@ -63,7 +63,7 @@ func RunInstanceControl(args []string) { |
| fenceDone := make(chan struct{}) |
| go func() { defer close(fenceDone); runHostFencePass(ctx) }() |
| defer func() { cancel(); <-fenceDone }() |
| - instancectl.RunWithClaim(args, handler, instanceRegistryHostApp(reg)) |
| + instancectl.RunWithClaim(args, handler, rootInstallationHostApp(entry.Assembly.Guard, entry.Assembly.Claims)) |
| } |
| func instanceRegistryHostApp(shared *registry.Registry) func(string) (string, string, error) { |
diff --git a/internal/host/plan14_backend_source_test.go b/internal/host/plan14_backend_source_test.go| new file mode 100644 |
| index 000000000..0a779beec |
| --- /dev/null |
| +++ b/internal/host/plan14_backend_source_test.go |
| @@ -0,0 +1,104 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPlan14OrdinaryDispatchSourceGap(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + store := release.NewDirStore(t.TempDir()) |
| + account := domains.Sqid(2) |
| + manifest, err := json.Marshal(map[string]any{"id": 2, "sqid": account, "name": "fixture", "created_at": time.Now()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(t.Context(), "accounts/"+account+"/account.json", manifest, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.PutManifest(t.Context(), store, account, "app", provision.Manifest{Schema: 1, App: "app", Domain: "example.com", Pool: "test"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + record := provision.Record{Schema: 1, Host: "app.example.com", Slug: "app", Account: account, App: "app", Region: "ie", Pool: "test", Channel: "stable", CreatedAt: time.Now()} |
| + if err := provision.CreateRecord(t.Context(), store, record); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, before, err := store.Get(t.Context(), provision.RecordKey(account, "app", record.Host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, nested := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "flat", true: "nested"}[nested], func(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + cfg := provision.ReconcileConfig{Region: "ie", Pools: []string{"test"}, DataDir: t.TempDir()} |
| + if nested { |
| + cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil } |
| + } |
| + if err := provision.Reconcile(t.Context(), store, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + row, err := reg.Get(record.Host) |
| + if err != nil || row == nil { |
| + t.Fatalf("provisioned route: %+v %v", row, err) |
| + } |
| + wantSocket := provision.DefaultSocket(record.Host) |
| + if nested { |
| + wantSocket = provision.NestedSocket(record.Host) |
| + } |
| + if row.Unit != "" || row.Backing != "" || row.Socket != wantSocket { |
| + t.Fatalf("unexpected ordinary route: %+v", row) |
| + } |
| + source := &proofBindingSource{store: store} |
| + for _, forge := range []string{"honest", "unit", "backing", "both"} { |
| + candidate := *row |
| + if forge == "unit" || forge == "both" { |
| + candidate.Unit = "console.service" |
| + } |
| + if forge == "backing" || forge == "both" { |
| + candidate.Backing = registry.BackingUnit |
| + } |
| + need, err := source.needsRestart(t.Context(), candidate) |
| + if err != nil || need { |
| + t.Fatalf("%s ordinary record dispatched: need=%v err=%v", forge, need, err) |
| + } |
| + if err := reg.Upsert(candidate); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := &plan14RestartUnits{} |
| + checks := rootRestartChecks{dispatch: source.needsRestart} |
| + if err := rootRestartRouteWith(reg, u, "", nil, nil, checks)(record.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(u.effects) != 0 { |
| + t.Fatalf("ordinary restart: %v", u.effects) |
| + } |
| + } |
| + |
| + }) |
| + } |
| + _, after, err := store.Get(t.Context(), provision.RecordKey(account, "app", record.Host)) |
| + if err != nil || before != after { |
| + t.Fatalf("record changed between local layouts: %v", err) |
| + } |
| +} |
| + |
| +func TestPlan14NamedRecordDispatch(t *testing.T) { |
| + e, rt, u, _, _ := plan14AdoptionFixture(t) |
| + source := productionProofBindingSource(e.store, u, u.opt) |
| + need, err := source.needsRestart(t.Context(), rt) |
| + if err != nil || !need { |
| + t.Fatalf("named record excluded: need=%v err=%v", need, err) |
| + } |
| +} |
diff --git a/internal/host/plan14_canonical_restart_test.go b/internal/host/plan14_canonical_restart_test.go| index f1135887c..3e0b5e7fc 100644 |
| --- a/internal/host/plan14_canonical_restart_test.go |
| +++ b/internal/host/plan14_canonical_restart_test.go |
| @@ -25,6 +25,7 @@ type plan14RestartUnits struct { |
| crashNew bool |
| badRollback bool |
| failNew bool |
| + afterEffect func() |
| afterRestart func() |
| } |
| @@ -57,6 +58,9 @@ func (u *plan14RestartUnits) Restart(unit string) error { |
| if u.failNew && u.exe == release.VersionPath(u.opt, "bab", "v2", "console") { |
| return os.ErrPermission |
| } |
| + if u.afterEffect != nil { |
| + u.afterEffect() |
| + } |
| return err |
| } |
| @@ -118,7 +122,7 @@ func plan14AdoptionFixture(t *testing.T) (*proofRootEnv, registry.Route, *plan14 |
| } |
| now, sleep := settleClock() |
| mem := &rollbackMemory{files: map[string][]byte{}} |
| - c := rootRestartChecks{started: &sync.Map{}, attempts: func() (rootstate.Store, error) { return mem, nil }, rollback: source.rollback, resolve: source.resolve, host: source.forHost, ready: e.root.obs.ready, now: now, sleep: sleep} |
| + c := rootRestartChecks{started: &sync.Map{}, attempts: func() (rootstate.Store, error) { return mem, nil }, rollback: source.rollback, prepareRollback: source.prepareRollback, resolve: source.resolve, host: source.forHost, ready: e.root.obs.ready, now: now, sleep: sleep} |
| return e, rt, u, c, pub |
| } |
| @@ -186,9 +190,9 @@ func TestPlan14AdoptionCanonicalReadiness(t *testing.T) { |
| } |
| func TestPlan14RollbackCanonicalReadiness(t *testing.T) { |
| - for _, missing := range []bool{false, true} { |
| + for _, attack := range []string{"missing", "unchanged", "account", "app", "unit", "backing", "socket", "addr", "addrs", "health", "all empty"} { |
| for _, ready := range []bool{false, true} { |
| - name := strconv.FormatBool(missing) + "/" + strconv.FormatBool(ready) |
| + name := attack + "/" + strconv.FormatBool(ready) |
| t.Run(name, func(t *testing.T) { |
| e, rt, units, c, pub := plan14AdoptionFixture(t) |
| c.ready = func(r registry.Route) func() error { |
| @@ -205,13 +209,32 @@ func TestPlan14RollbackCanonicalReadiness(t *testing.T) { |
| forward := rootRestartRouteWith(e.root.reg, units, units.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| restart := func(host string) error { |
| err := forward(host) |
| - if missing { |
| + if attack == "missing" { |
| if e := e.root.reg.Delete(host); e != nil { |
| t.Fatal(e) |
| } |
| } else { |
| forged := rt |
| - forged.Unit, forged.Backing, forged.Socket, forged.Addr, forged.Addrs, forged.Health = "", "", "", "", "", "" |
| + switch attack { |
| + case "account": |
| + forged.Account = "bap" |
| + case "app": |
| + forged.App = "other" |
| + case "unit": |
| + forged.Unit = "other.service" |
| + case "backing": |
| + forged.Backing = registry.BackingUnit |
| + case "socket": |
| + forged.Socket = "/run/forged.sock" |
| + case "addr": |
| + forged.Addr = "" |
| + case "addrs": |
| + forged.Addrs = "127.0.0.1:1" |
| + case "health": |
| + forged.Health = `{"path":"/forged"}` |
| + case "all empty": |
| + forged.Unit, forged.Backing, forged.Socket, forged.Addr, forged.Addrs, forged.Health = "", "", "", "", "", "" |
| + } |
| if e := e.root.reg.Upsert(forged); e != nil { |
| t.Fatal(e) |
| } |
diff --git a/internal/host/plan14_recovery_source_test.go b/internal/host/plan14_recovery_source_test.go| new file mode 100644 |
| index 000000000..bedbecdf8 |
| --- /dev/null |
| +++ b/internal/host/plan14_recovery_source_test.go |
| @@ -0,0 +1,63 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os/exec" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlan14RecoveryInventorySourceGap(t *testing.T) { |
| + f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| + holder := o.Claim.Holder |
| + authority := f.guard |
| + authority.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| + in := hostguard.RootInstallation{Host: o.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Migration: 1, InventoryRevision: 1, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box-1", Enrollment: "enrollment-1"} |
| + if err := authority.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.Guard.FenceView = authority.Root.View |
| + before, err := h.Registry.Get(o.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + c.wake.Inputs = plan9D2Inputs{recoveryInputs{f}} |
| + c.wake.Operations = &plan9D2Attempts{} |
| + c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| + wireClaimAdmission(c.wake, "box-1", func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + t.Fatal("wrong handover") |
| + return nil |
| + }, h, nil) |
| + if err = c.wake.stopPredecessor(t.Context(), o); err != nil { |
| + t.Fatal(err) |
| + } |
| + live, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + done := make(chan struct{}) |
| + go func() { <-live.Done(); close(done) }() |
| + c.wake.mu.Lock() |
| + c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| + c.wake.mu.Unlock() |
| + _, err = c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true}) |
| + if err == nil { |
| + t.Fatal("agent admission silently rebound root installation") |
| + } |
| + current := f.read() |
| + if current.Claim.Holder.Stamp <= holder.Stamp { |
| + t.Fatal("fixture did not reach the new claim stamp", err) |
| + } |
| + after, e := h.Registry.Get(o.Host) |
| + if e != nil || after.ClaimOwnership != before.ClaimOwnership { |
| + t.Fatal("rebound rows before root authority", e) |
| + } |
| + view, e := authority.Root.View(t.Context(), o.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| + if e != nil || view.Installation != in { |
| + t.Fatal("root installation changed", view, e) |
| + } |
| + t.Logf("fresh claim stamp %d, retained root stamp %d; recovery refuses: %v", current.Claim.Holder.Stamp, view.Installation.Stamp, err) |
| +} |
diff --git a/internal/host/plan14_release_lock_test.go b/internal/host/plan14_release_lock_test.go| index 65ed7a050..7fee8dfe5 100644 |
| --- a/internal/host/plan14_release_lock_test.go |
| +++ b/internal/host/plan14_release_lock_test.go |
| @@ -3,6 +3,9 @@ package host |
| import ( |
| "context" |
| "errors" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| "os" |
| "sync" |
| "testing" |
| @@ -56,3 +59,51 @@ func TestPlan14AdoptReleaseLock(t *testing.T) { |
| t.Fatal(err, effects) |
| } |
| } |
| + |
| +func TestPlan14AdoptBodyIsUnderRelease(t *testing.T) { |
| + f, err := parser.ParseFile(token.NewFileSet(), "agent.go", nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var wrapper *ast.FuncDecl |
| + for _, d := range f.Decls { |
| + if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "runAdoptWithEnsureSystemUnits" { |
| + wrapper = fn |
| + } |
| + } |
| + if wrapper == nil { |
| + t.Fatal("missing adoption entrypoint") |
| + } |
| + calls, protected := 0, 0 |
| + ast.Inspect(wrapper, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + id, ok := call.Fun.(*ast.Ident) |
| + if !ok { |
| + return true |
| + } |
| + if id.Name == "runAdoptUnderRootRelease" { |
| + calls++ |
| + } |
| + if id.Name == "withClaimRootRelease" && len(call.Args) == 4 { |
| + fn, ok := call.Args[3].(*ast.FuncLit) |
| + if !ok { |
| + t.Fatal("release effect is not the adoption callback") |
| + } |
| + ast.Inspect(fn.Body, func(n ast.Node) bool { |
| + if c, ok := n.(*ast.CallExpr); ok { |
| + if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "runAdoptUnderRootRelease" { |
| + protected++ |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + return true |
| + }) |
| + if calls != 1 || protected != 1 { |
| + t.Fatalf("adoption calls=%d; under release=%d", calls, protected) |
| + } |
| +} |
diff --git a/internal/host/plan14_root_launch_test.go b/internal/host/plan14_root_launch_test.go| new file mode 100644 |
| index 000000000..3d4aca994 |
| --- /dev/null |
| +++ b/internal/host/plan14_root_launch_test.go |
| @@ -0,0 +1,82 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +func TestPlan14RootLaunchRefusesReplacedAgentFence(t *testing.T) { |
| + for _, attack := range []string{"honest", "delete", "rename", "replace lock", "forge", "receipt replay"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newLaunchFixture(t, servingOp()) |
| + f.claims.set(func(c *hostclaim.Claim) { |
| + c.Holder.Account = "bab" |
| + c.Holder.App = "app" |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{{Box: f.target.Box, Region: f.target.Region, Session: f.target.Session, Generation: c.Holder.Generation}} |
| + }) |
| + guard := f.root.Guard |
| + guard.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| + in := hostguard.RootInstallation{Host: f.target.Host, Account: "bab", App: "app", Record: "record", Incarnation: f.target.Incarnation, Stamp: f.target.Stamp, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1} |
| + if err := guard.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.root.Guard = guard |
| + if attack == "honest" { |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, _ := f.units.counts(); n != 1 { |
| + t.Fatalf("honest launch count: %d", n) |
| + } |
| + return |
| + } |
| + if attack == "receipt replay" { |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + target := f.target |
| + target.FenceID = "root-fence" |
| + if _, err := guard.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(guard.DataDir, "hostguard", target.Host+".json") |
| + switch attack { |
| + case "delete": |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "replace lock": |
| + if err := os.Rename(guard.LockDir, guard.LockDir+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(guard.LockDir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "forge": |
| + if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","incarnation":"inc-1","active_stamp":11}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before, _ := f.units.counts() |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil { |
| + t.Fatal("root launch bypassed fence") |
| + } |
| + if n, _ := f.units.counts(); n != before { |
| + t.Fatal("fenced launch started a unit") |
| + } |
| + pending, err := guard.PendingFences(t.Context()) |
| + if err != nil || len(pending) != 1 || pending[0] != target { |
| + t.Fatalf("lost root recovery target: %+v %v", pending, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan9fix4_claimjournal_migration_linux_test.go b/internal/host/plan9fix4_claimjournal_migration_linux_test.go| index cb4789cac..55b3cee61 100644 |
| --- a/internal/host/plan9fix4_claimjournal_migration_linux_test.go |
| +++ b/internal/host/plan9fix4_claimjournal_migration_linux_test.go |
| @@ -24,6 +24,7 @@ import ( |
| "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| type fix4Owners struct{ ClaimOwnerBindings } |
| @@ -84,6 +85,10 @@ func fix4JournalFixture(t *testing.T) (release.Store, ClaimRuntimeResources, fix |
| t.Fatal(err) |
| } |
| journalPath := filepath.Join(t.TempDir(), "journals.db") |
| + authorityDir := t.TempDir() |
| + originalAuthority := openClaimRootAuthority |
| + t.Cleanup(func() { openClaimRootAuthority = originalAuthority }) |
| + openClaimRootAuthority = func() (rootstate.Store, error) { return &rollbackDisk{dir: authorityDir}, nil } |
| original := openClaimRootJournal |
| t.Cleanup(func() { openClaimRootJournal = original }) |
| openClaimRootJournal = func() (*hostbox.LaunchJournal, error) { return hostbox.OpenLaunchJournal(journalPath) } |
diff --git a/internal/host/proofbinding.go b/internal/host/proofbinding.go| index 026dfd869..7f5d49a57 100644 |
| --- a/internal/host/proofbinding.go |
| +++ b/internal/host/proofbinding.go |
| @@ -115,7 +115,7 @@ func (s *proofBindingSource) resolve(ctx context.Context, candidate registry.Rou |
| return b, nil |
| } |
| -func (s *proofBindingSource) binding(ctx context.Context, host string, rollback *proofBinding) (proofBinding, error) { |
| +func (s *proofBindingSource) binding(ctx context.Context, host string, rollback *proofBinding, prepare ...bool) (proofBinding, error) { |
| if s.store == nil || s.sys == nil || s.openFile == nil { |
| return proofBinding{}, os.ErrPermission |
| } |
| @@ -182,6 +182,9 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback |
| return proofBinding{}, os.ErrPermission |
| } |
| exe, err = filepath.EvalSymlinks(release.LiveLink(s.optDir, host)) |
| + if len(prepare) == 1 && prepare[0] { |
| + exe, err = release.VersionPath(s.optDir, rec.Account, rollback.Version, rec.App), nil |
| + } |
| } else { |
| if p.MainPID <= 0 || p.InvocationID == "" { |
| return proofBinding{}, os.ErrPermission |
| @@ -257,3 +260,7 @@ func (s *proofBindingSource) forHost(ctx context.Context, host string) (proofBin |
| func (s *proofBindingSource) rollback(ctx context.Context, b proofBinding) (proofBinding, error) { |
| return s.binding(ctx, b.Route.Host, &b) |
| } |
| + |
| +func (s *proofBindingSource) prepareRollback(ctx context.Context, b proofBinding) (proofBinding, error) { |
| + return s.binding(ctx, b.Route.Host, &b, true) |
| +} |
diff --git a/internal/host/proofbinding_test.go b/internal/host/proofbinding_test.go| index 2a104010a..4c0feeeb0 100644 |
| --- a/internal/host/proofbinding_test.go |
| +++ b/internal/host/proofbinding_test.go |
| @@ -224,8 +224,11 @@ func TestPlan14OrdinaryInstanceBinding(t *testing.T) { |
| } |
| u := source.sys.(*fakeUnits) |
| u.shows[0].Name = activator.UnitName(rec.Host) |
| - if got := e.root.observeAndRecord(rt, 0); got.err != nil { |
| - t.Fatal(got.err) |
| + if got := e.root.observeAndRecord(rt, 0); got.err == nil { |
| + t.Fatal("ordinary record recorded root health") |
| + } |
| + if got := release.ReadHealthy(e.bake, rt.Account, rt.App); len(got) != 0 { |
| + t.Fatalf("ordinary health: %+v", got) |
| } |
| rt.Unit = "other.service" |
| if got := e.root.observeAndRecord(rt, 0); got.err == nil { |
diff --git a/internal/host/registry_privilege_test.go b/internal/host/registry_privilege_test.go| index 5612bea4d..a6dffe563 100644 |
| --- a/internal/host/registry_privilege_test.go |
| +++ b/internal/host/registry_privilege_test.go |
| @@ -95,7 +95,8 @@ func TestRootRegistryRefusesPlantedFilesBeforeSQLite(t *testing.T) { |
| func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) { |
| for _, tc := range []struct{ file, function, opener string }{ |
| - {"agent.go", "runAdoptWithEnsureSystemUnits", "openServiceRegistry"}, |
| + {"agent.go", "runAdoptWithEnsureSystemUnits", "withClaimRootRelease"}, |
| + {"agent.go", "runAdoptUnderRootRelease", "openServiceRegistry"}, |
| {"systemapply.go", "openSystemRegistry", "openServiceRegistry"}, |
| {"claimstop_run.go", "RunInstanceControl", "openServiceRegistry"}, |
| {"claimstop_run.go", "instanceRegistryHostApp", "openServiceRegistryReadOnly"}, |
diff --git a/internal/host/registryowner_test.go b/internal/host/registryowner_test.go| index 3013160f3..57c04cb2a 100644 |
| --- a/internal/host/registryowner_test.go |
| +++ b/internal/host/registryowner_test.go |
| @@ -320,7 +320,9 @@ func TestRunAdoptOpensTheRegistryThroughTheHelper(t *testing.T) { |
| chain := []struct{ name, calls string }{ |
| {"RunAdopt", "runAdopt("}, |
| {"runAdopt", "runAdoptWithEnsureSystemUnits("}, |
| - {"runAdoptWithEnsureSystemUnits", "openServiceRegistry("}, |
| + {"runAdoptWithEnsureSystemUnits", "withClaimRootRelease("}, |
| + {"runAdoptWithEnsureSystemUnits", "runAdoptUnderRootRelease("}, |
| + {"runAdoptUnderRootRelease", "openServiceRegistry("}, |
| } |
| for _, c := range chain { |
| b := body(c.name) |
diff --git a/internal/host/rootfenceview.go b/internal/host/rootfenceview.go| index c62f823ea..1257c9e0d 100644 |
| --- a/internal/host/rootfenceview.go |
| +++ b/internal/host/rootfenceview.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "net/http" |
| "os" |
| + "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostguard" |
| @@ -15,8 +16,13 @@ func rootFenceViewClient(call hostbox.JournalCall) hostguard.FenceViewProvider { |
| if call == nil { |
| return hostguard.FenceView{}, os.ErrPermission |
| } |
| + ctx, cancel := context.WithTimeout(ctx, time.Second) |
| + defer cancel() |
| q := hostbox.JournalRequest{V: 1, Op: "fence-view", Target: hostguard.FenceTarget{Host: host, Incarnation: c.Incarnation, Stamp: c.Stamp}} |
| out, err := call(ctx, q) |
| + if ctx.Err() != nil { |
| + return hostguard.FenceView{}, ctx.Err() |
| + } |
| if err != nil { |
| return hostguard.FenceView{}, err |
| } |
diff --git a/internal/host/rootfenceview_test.go b/internal/host/rootfenceview_test.go| new file mode 100644 |
| index 000000000..62f884ffd |
| --- /dev/null |
| +++ b/internal/host/rootfenceview_test.go |
| @@ -0,0 +1,185 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14FenceViewDeadline(t *testing.T) { |
| + for _, lateSuccess := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "timeout", true: "late success"}[lateSuccess], func(t *testing.T) { |
| + captured := hostguard.Captured{Incarnation: "inc", Stamp: 1} |
| + read := rootFenceViewClient(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| + deadline, ok := ctx.Deadline() |
| + if !ok || time.Until(deadline) > time.Second { |
| + t.Fatal("fence view has no short deadline") |
| + } |
| + <-ctx.Done() |
| + if !lateSuccess { |
| + return hostbox.JournalResponse{}, ctx.Err() |
| + } |
| + return hostbox.JournalResponse{V: 1, ID: q.ID, FenceView: &hostguard.FenceView{Schema: 1, Host: q.Target.Host, Captured: captured}}, nil |
| + }) |
| + _, err := read(t.Context(), "shop.example", captured) |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("expired view accepted: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type fenceViewTraceStore struct { |
| + rootstate.Store |
| + calls []string |
| +} |
| + |
| +func (s *fenceViewTraceStore) Read(name string) ([]byte, error) { |
| + s.calls = append(s.calls, "read "+name) |
| + return s.Store.Read(name) |
| +} |
| +func (s *fenceViewTraceStore) Write(name string, body []byte) error { |
| + s.calls = append(s.calls, "write "+name) |
| + return s.Store.Write(name, body) |
| +} |
| +func (s *fenceViewTraceStore) Lock(ctx context.Context, name string) (func(), error) { |
| + s.calls = append(s.calls, "lock "+name) |
| + return s.Store.Lock(ctx, name) |
| +} |
| +func (s *fenceViewTraceStore) Names() ([]string, error) { |
| + s.calls = append(s.calls, "names") |
| + return s.Store.Names() |
| +} |
| +func (s *fenceViewTraceStore) Remove(name string) error { |
| + s.calls = append(s.calls, "remove "+name) |
| + return s.Store.Remove(name) |
| +} |
| + |
| +func TestPlan14FenceViewHandlerAcquisitionSet(t *testing.T) { |
| + store := &fenceViewTraceStore{Store: &rollbackMemory{files: map[string][]byte{}}} |
| + root := hostguard.NewRootAuthority(store) |
| + obs := hostclaim.Observed{Host: "shop.example", Claim: &hostclaim.Claim{V: 2, Host: "shop.example", Holder: &hostclaim.Holder{Account: "bab", App: "app", Incarnation: "inc", Stamp: 1}}} |
| + guard := hostguard.Guard{Root: root, Claims: stopClaimReader{obs: obs}, DataDir: t.TempDir(), LockDir: t.TempDir()} |
| + installation := hostguard.RootInstallation{Host: obs.Host, Account: "bab", App: "app", Incarnation: "inc", Stamp: 1, Migration: 1, Record: "record@etag", Box: "box", Enrollment: "enroll"} |
| + if err := guard.EstablishRoot(t.Context(), installation, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + launches, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer launches.Close() |
| + journals, err := hostbox.NewJournalService(launches) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + guard.LockDir = filepath.Join(t.TempDir(), "absent-legacy-lock-directory") |
| + handler := ClaimRootHTTP{Journals: journals, Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: guard}}} |
| + q := hostbox.JournalRequest{V: 1, Op: "fence-view", Target: hostguard.FenceTarget{Host: obs.Host, Incarnation: "inc", Stamp: 1}} |
| + body, err := json.Marshal(q) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + store.calls = nil |
| + response := httptest.NewRecorder() |
| + handler.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), StopPeer{}) |
| + if response.Code != http.StatusOK { |
| + t.Fatalf("view handler: %d %s", response.Code, response.Body) |
| + } |
| + if !reflect.DeepEqual(store.calls, []string{"read shop.example.json"}) { |
| + t.Fatalf("handler acquired resources beyond its root snapshot: %v", store.calls) |
| + } |
| + var out hostbox.JournalResponse |
| + if err := json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.FenceView == nil || out.FenceView.Installation != installation { |
| + t.Fatalf("view handler response: %+v %v", out, err) |
| + } |
| +} |
| + |
| +func TestPlan14FenceViewTransport(t *testing.T) { |
| + for _, attack := range []string{"none", "peer", "host", "tuple", "schema"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + o := f.read() |
| + h := o.Claim.Holder |
| + store := &rollbackDisk{dir: t.TempDir()} |
| + guard := f.guard |
| + guard.Root = hostguard.NewRootAuthority(store) |
| + in := hostguard.RootInstallation{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: "box-1", Enrollment: "en", Migration: 1} |
| + if err := guard.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + journals, err := hostbox.NewJournalService(f.journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := store.Read(o.Host + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + handler := ClaimRootHTTP{Journals: journals, Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: guard}, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { |
| + if attack == "peer" { |
| + return StopPeer{}, os.ErrPermission |
| + } |
| + return StopPeer{UID: 1001, PID: 42, ProcessStart: "start", BootID: "boot", ControllerID: "controller"}, nil |
| + }}} |
| + call := rootJournalClient(startRetirementRecoveryTransport(t, handler)) |
| + host := o.Host |
| + captured := hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp} |
| + if attack == "host" { |
| + host = "other.example" |
| + } |
| + if attack == "tuple" { |
| + captured.Stamp++ |
| + } |
| + if attack == "schema" { |
| + transport := call |
| + call = func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| + q.V = 99 |
| + return transport(ctx, q) |
| + } |
| + } |
| + view, err := rootFenceViewClient(call)(t.Context(), host, captured) |
| + if attack == "none" { |
| + if err != nil || view.Installation != in { |
| + t.Fatalf("authenticated view: %+v %v", view, err) |
| + } |
| + } else if err == nil { |
| + t.Fatalf("%s accepted", attack) |
| + } |
| + after, err := store.Read(o.Host + ".json") |
| + if err != nil || !bytes.Equal(before, after) { |
| + t.Fatal("read-only view wrote root state") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14FenceViewRefusesOldOrMalformedServer(t *testing.T) { |
| + c := hostguard.Captured{Incarnation: "inc", Stamp: 1} |
| + for _, out := range []hostbox.JournalResponse{ |
| + {V: 1}, |
| + {V: 99, FenceView: &hostguard.FenceView{Schema: 1, Host: "shop.example", Captured: c}}, |
| + {V: 1, FenceView: &hostguard.FenceView{Schema: 99, Host: "shop.example", Captured: c}}, |
| + {V: 1, FenceView: &hostguard.FenceView{Schema: 1, Host: "other.example", Captured: c}}, |
| + {V: 1, FenceView: &hostguard.FenceView{Schema: 1, Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 2}}}, |
| + } { |
| + view := rootFenceViewClient(func(context.Context, hostbox.JournalRequest) (hostbox.JournalResponse, error) { return out, nil }) |
| + if _, err := view(t.Context(), "shop.example", c); err == nil { |
| + t.Fatalf("malformed view admitted: %+v", out) |
| + } |
| + } |
| +} |
diff --git a/internal/host/rootinstallation.go b/internal/host/rootinstallation.go| new file mode 100644 |
| index 000000000..2ee6f8685 |
| --- /dev/null |
| +++ b/internal/host/rootinstallation.go |
| @@ -0,0 +1,46 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func rootInstallationHostApp(guard hostguard.Guard, store release.Store) func(string) (string, string, error) { |
| + return func(host string) (string, string, error) { |
| + if guard.Root == nil || guard.Claims == nil || store == nil { |
| + return "", "", os.ErrPermission |
| + } |
| + captured, err := guard.Capture(host) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + view, err := guard.Root.View(context.Background(), host, captured) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + observed, err := guard.Claims.Read(context.Background(), host) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if observed.Absent || observed.Host != host || observed.Claim == nil || observed.Claim.Holder == nil { |
| + return "", "", os.ErrPermission |
| + } |
| + h := observed.Claim.Holder |
| + in := view.Installation |
| + if h.Incarnation != in.Incarnation || h.Stamp != in.Stamp || h.Account != in.Account || h.App != in.App || h.Key != in.Record { |
| + return "", "", os.ErrPermission |
| + } |
| + rec, _, err := rootClaimRecord(context.Background(), store, observed.Claim, guard.DataDir) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if rec.Account != in.Account || rec.App != in.App { |
| + return "", "", os.ErrPermission |
| + } |
| + |
| + return in.Account, in.App, nil |
| + } |
| +} |
diff --git a/internal/host/rootinstallation_test.go b/internal/host/rootinstallation_test.go| new file mode 100644 |
| index 000000000..7fc533966 |
| --- /dev/null |
| +++ b/internal/host/rootinstallation_test.go |
| @@ -0,0 +1,74 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "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" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPlan14RootDispatchRejectsRegistryMutation(t *testing.T) { |
| + for _, attack := range []string{"none", "account", "app", "unit", "backing", "delete", "record owner", "claim owner", "missing installation"} { |
| + t.Run(attack, func(t *testing.T) { |
| + store := release.NewDirStore(t.TempDir()) |
| + rec := provision.Record{Schema: 1, Host: "shop.example.com", Account: "bab", App: "shop", Channel: "stable", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)} |
| + plan14PutRecord(t, store, rec) |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + claim := &hostclaim.Claim{Host: rec.Host, Holder: &hostclaim.Holder{Key: provision.RecordKey(rec.Account, rec.App, rec.Host), Account: rec.Account, App: rec.App, Incarnation: inc, Stamp: 1, Session: 1, WriterBox: "box"}} |
| + g := hostguard.Guard{DataDir: t.TempDir(), LockDir: t.TempDir(), Claims: rootViewClaimReader{obs: hostclaim.Observed{Host: rec.Host, ETag: "claim", Claim: claim}}, Root: hostguard.NewRootAuthority(&rollbackMemory{files: map[string][]byte{}})} |
| + if attack != "missing installation" { |
| + in := hostguard.RootInstallation{Host: rec.Host, Incarnation: inc, Account: rec.Account, App: rec.App, Stamp: 1, Migration: 1, Record: claim.Holder.Key, Box: "box", Enrollment: "enroll"} |
| + if err := g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + reg := restartTestRegistry(t) |
| + row := registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App} |
| + switch attack { |
| + case "account": |
| + row.Account = "bap" |
| + case "app": |
| + row.App = "other" |
| + case "unit": |
| + row.Unit = "other.service" |
| + case "backing": |
| + row.Backing = registry.BackingUnit |
| + case "record owner": |
| + rec.App = "other" |
| + plan14PutRecord(t, store, rec) |
| + case "claim owner": |
| + claim.Holder.App = "other" |
| + } |
| + if attack != "delete" { |
| + if err := reg.Upsert(row); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + acct, app, err := rootInstallationHostApp(g, store)(rec.Host) |
| + refused := attack == "record owner" || attack == "claim owner" || attack == "missing installation" |
| + if refused { |
| + if err == nil { |
| + t.Fatal("unbound root dispatch") |
| + } |
| + return |
| + } |
| + if err != nil || acct != "bab" || app != "shop" { |
| + t.Fatalf("root installation dispatch: %q %q %v", acct, app, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type rootViewClaimReader struct{ obs hostclaim.Observed } |
| + |
| +func (r rootViewClaimReader) Read(context.Context, string) (hostclaim.Observed, error) { |
| + return r.obs, nil |
| +} |
diff --git a/internal/host/rootinventory.go b/internal/host/rootinventory.go| new file mode 100644 |
| index 000000000..c1241371c |
| --- /dev/null |
| +++ b/internal/host/rootinventory.go |
| @@ -0,0 +1,254 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +func (h ClaimRootHTTP) commitRootInventory(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| + return h.commitRootInventoryPending(ctx, q, false) |
| +} |
| +func (h ClaimRootHTTP) commitRootInventoryPending(ctx context.Context, q hostbox.JournalRequest, pending bool) (hostbox.JournalResponse, error) { |
| + var out hostbox.JournalResponse |
| + guard := h.Stop.Coordinator.Guard |
| + if guard.Root == nil || h.Journals == nil || h.InventoryActor == nil || h.Reconcile.Registry == nil || h.Reconcile.Census == nil { |
| + return out, hostbox.ErrUnsupported |
| + } |
| + if h.RequireNewWork != nil { |
| + if err := h.RequireNewWork(ctx); err != nil { |
| + return out, err |
| + } |
| + } |
| + actor, err := h.InventoryActor(ctx) |
| + if err != nil { |
| + return out, err |
| + } |
| + in, t := q.Inventory, q.Target |
| + if q.Op != "inventory-write" || !in.Complete || in.Box != actor.Box || in.Enrollment != actor.Enrollment || in.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) || t.Host != in.Host || t.Incarnation != in.Incarnation || t.Stamp != in.InstallationStamp || t.Box != actor.Box || t.Enrollment != actor.Enrollment || t.ClaimETag == "" || q.RecordKey == "" || q.RecordETag == "" { |
| + return out, hostbox.ErrChanged |
| + } |
| + observed, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, in.Host) |
| + if err != nil { |
| + return out, err |
| + } |
| + if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil { |
| + return out, hostbox.ErrChanged |
| + } |
| + holder := observed.Claim.Holder |
| + if observed.ETag != t.ClaimETag || holder.Incarnation != t.Incarnation || holder.Stamp != t.Stamp || holder.Session != t.Session || holder.Key != q.RecordKey || observed.Claim.Op.ID != t.Op || observed.Claim.Op.Phase != t.Phase { |
| + return out, hostbox.ErrChanged |
| + } |
| + installation := hostguard.RootInstallation{Empty: len(in.Entries) == 0, Host: in.Host, Incarnation: in.Incarnation, Stamp: in.InstallationStamp, Migration: 1, InventoryRevision: q.Revision + 1, Account: holder.Account, App: holder.App, Record: holder.Key, Box: actor.Box, Enrollment: actor.Enrollment} |
| + raw, err := json.Marshal(q) |
| + if err != nil { |
| + return out, err |
| + } |
| + replayed := false |
| + err = guard.CommitRootInventory(ctx, installation, raw, func(c *hostclaim.Claim) error { |
| + if !reflect.DeepEqual(c, observed.Claim) { |
| + return hostbox.ErrChanged |
| + } |
| + fresh, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, in.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.ETag != t.ClaimETag { |
| + return hostbox.ErrChanged |
| + } |
| + canonical, etag, err := rootClaimRecord(ctx, h.Stop.Coordinator.Evidence, c, h.Roots["data"]) |
| + if err != nil { |
| + return err |
| + } |
| + if etag != q.RecordETag { |
| + return hostbox.ErrChanged |
| + } |
| + owner := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", in.Host, in.Incarnation, in.InstallationStamp, actor.Box))) |
| + canonical.ClaimIncarnation, canonical.ClaimStamp, canonical.ClaimOwner = in.Incarnation, in.InstallationStamp, hex.EncodeToString(owner[:]) |
| + for _, entry := range in.Entries { |
| + if entry.Kind == "route" { |
| + if canonical.ClaimRowID != "" { |
| + return hostbox.ErrTypedInventory |
| + } |
| + canonical.ClaimRowID = entry.RowIdentity |
| + } |
| + } |
| + row, err := h.Reconcile.Registry.Get(in.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if row != nil && (row.Host != canonical.Host || row.Account != canonical.Account || row.App != canonical.App || row.DBPath != canonical.DBPath || row.ClaimOwnership != canonical.ClaimOwnership) { |
| + return hostbox.ErrTypedInventory |
| + } |
| + if row == nil { |
| + for _, e := range in.Entries { |
| + if e.Kind != "route" || e.Materialization != "intended" { |
| + return hostbox.ErrTypedInventory |
| + } |
| + } |
| + } else if err := hostbox.ValidateAgentInventory(in, &canonical, h.Roots["data"]); err != nil { |
| + return err |
| + } |
| + for _, e := range in.Entries { |
| + if e.Ownership != canonical.ClaimOwner { |
| + return hostbox.ErrTypedInventory |
| + } |
| + if e.Kind == "route" { |
| + if e.ManagedRoot != "registry" || e.RelativePath != in.Host || e.RowIdentity == "" { |
| + return hostbox.ErrTypedInventory |
| + } |
| + continue |
| + } |
| + if err := h.verifyInventoryPath(e); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| + }, func(priorRoot hostguard.RootInstallation) error { |
| + prior, revision, err := h.Journals.Inventory.Snapshot(ctx, in.Host, hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.InstallationStamp}) |
| + if err != nil { |
| + return err |
| + } |
| + replay, found, err := h.Journals.Replay(ctx, actor.Box+"/"+actor.Enrollment, q) |
| + if err != nil { |
| + return err |
| + } |
| + if found { |
| + if revision != q.Revision+1 || !reflect.DeepEqual(prior, in) { |
| + return hostbox.ErrChanged |
| + } |
| + out = replay |
| + replayed = true |
| + return nil |
| + } |
| + if revision == q.Revision+1 && reflect.DeepEqual(prior, in) && priorRoot.InventoryRevision == revision { |
| + out = hostbox.JournalResponse{V: 1, ID: q.ID, Revision: revision} |
| + replayed = true |
| + return nil |
| + } |
| + if revision != q.Revision { |
| + return hostbox.ErrChanged |
| + } |
| + if revision == 0 { |
| + if len(in.Entries) != 0 || hostbox.Serving(observed.Claim, hostbox.Selection{Box: actor.Box}) != nil { |
| + return hostbox.ErrChanged |
| + } |
| + selected := hostbox.RecordSelection{Host: in.Host, Key: holder.Key, ETag: q.RecordETag, Incarnation: in.Incarnation, Family: holder.Family} |
| + if err := h.Reconcile.Census.CheckLocal(ctx, observed, selected); err != nil { |
| + return err |
| + } |
| + } else { |
| + if priorRoot.InventoryRevision != revision { |
| + return hostbox.ErrChanged |
| + } |
| + for _, old := range prior.Entries { |
| + found := false |
| + for _, next := range in.Entries { |
| + if old.ArtifactID == next.ArtifactID { |
| + if old.Materialization == "intended" && next.Materialization == "materialized" { |
| + old.Materialization = next.Materialization |
| + old.Device = next.Device |
| + old.Inode = next.Inode |
| + old.Type = next.Type |
| + } |
| + if old != next { |
| + return hostbox.ErrChanged |
| + } |
| + found = true |
| + } |
| + } |
| + if !found { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + } |
| + return nil |
| + }, func(hostguard.RootInstallation) error { |
| + if replayed { |
| + return nil |
| + } |
| + if pending { |
| + out, err = h.Journals.CompletePendingInventory(ctx, actor.Box+"/"+actor.Enrollment, q) |
| + } else { |
| + out, err = h.Journals.Execute(ctx, actor.Box+"/"+actor.Enrollment, q) |
| + } |
| + return err |
| + }) |
| + return out, err |
| +} |
| + |
| +func (h ClaimRootHTTP) verifyInventoryPath(e hostbox.StopInventoryEntry) error { |
| + if e.Kind != "path" || e.ManagedRoot != "data" || !filepath.IsLocal(e.RelativePath) { |
| + return hostbox.ErrTypedInventory |
| + } |
| + path := filepath.Join(h.Roots["data"], e.RelativePath) |
| + if e.Materialization == "intended" { |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + if e.Materialization != "materialized" { |
| + return hostbox.ErrTypedInventory |
| + } |
| + f, err := safefile.OpenDir(path) |
| + if err != nil { |
| + f, err = safefile.OpenFile(path, os.O_RDONLY, 0) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + id, err := rootstate.Identity(f) |
| + if err != nil { |
| + return err |
| + } |
| + st, err := f.Stat() |
| + if err != nil { |
| + return err |
| + } |
| + kind := uint32(0100000) |
| + if st.IsDir() { |
| + kind = 0040000 |
| + } else if !st.Mode().IsRegular() { |
| + return hostbox.ErrTypedInventory |
| + } |
| + if id.Device != e.Device || id.Inode != e.Inode || kind != e.Type { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +func (h ClaimRootHTTP) recoverRootInventory(ctx context.Context) error { |
| + authority := h.Stop.Coordinator.Guard.Root |
| + if authority == nil { |
| + return nil |
| + } |
| + pending, err := authority.PendingInstallations() |
| + if err != nil { |
| + return err |
| + } |
| + for _, raw := range pending { |
| + var q hostbox.JournalRequest |
| + if err = json.Unmarshal(raw, &q); err != nil { |
| + return err |
| + } |
| + if _, err = h.commitRootInventoryPending(ctx, q, true); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/rootinventory_process_test.go b/internal/host/rootinventory_process_test.go| new file mode 100644 |
| index 000000000..ac1077f4e |
| --- /dev/null |
| +++ b/internal/host/rootinventory_process_test.go |
| @@ -0,0 +1,119 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "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/release" |
| +) |
| + |
| +func plan14InventoryProcessRoot(t *testing.T, dir string) (ClaimRootHTTP, *rollbackDisk) { |
| + t.Helper() |
| + store := release.NewDirStore(filepath.Join(dir, "deployment")) |
| + journal, err := hostbox.OpenLaunchJournal(filepath.Join(dir, "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { journal.Close() }) |
| + journals, err := hostbox.NewJournalService(journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + disk := &rollbackDisk{dir: filepath.Join(dir, "root")} |
| + guard := hostguard.Guard{DataDir: filepath.Join(dir, "data"), LockDir: filepath.Join(dir, "locks"), Claims: hostguard.StoreReader{Store: store}, Root: hostguard.NewRootAuthority(disk)} |
| + actor := hostbox.Actor{Box: "box-1", Enrollment: "en", Revision: 1, Region: "ie"} |
| + roots := map[string]string{"data": guard.DataDir, "opt": filepath.Join(dir, "opt"), "units": filepath.Join(dir, "units")} |
| + reg := restartTestRegistry(t) |
| + census := &hostbox.EmptyInstallationCensus{Inventory: journals.Inventory, Registry: reg, Launches: journal, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1", Roots: roots} |
| + return ClaimRootHTTP{Journals: journals, Launches: journal, Roots: roots, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Reconcile: hostbox.ReconcileAdapter{Registry: reg, Inventory: journals.Inventory, Census: census, Box: actor.Box, Enrollment: actor.Enrollment}, Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: guard, Evidence: store}}}, disk |
| +} |
| +func TestPlan14InventoryProcessCrash(t *testing.T) { |
| + if phase := os.Getenv("PLAN14_INVENTORY_CRASH"); phase != "" { |
| + root, disk := plan14InventoryProcessRoot(t, os.Getenv("PLAN14_INVENTORY_DIR")) |
| + writes := 0 |
| + disk.beforeWrite = func(string, []byte) { |
| + if phase == "database" && writes == 1 { |
| + os.Exit(73) |
| + } |
| + } |
| + disk.afterWrite = func(string, []byte) { |
| + writes++ |
| + if phase == "intent" && writes == 1 || phase == "snapshot" && writes == 2 { |
| + os.Exit(73) |
| + } |
| + } |
| + obs, err := hostclaim.Read(t.Context(), root.Stop.Coordinator.Evidence, recoveryHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h := obs.Claim.Holder |
| + _, etag, err := root.Stop.Coordinator.Evidence.Get(t.Context(), h.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, client := hostbox.RemoteJournals(root.commitRootInventory) |
| + _, err = client.WriteExpected(t.Context(), hostbox.StopInventory{Complete: true, Host: obs.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: "en", DirectoryRevision: "1"}, hostbox.InventoryExpectation{RecordKey: h.Key, RecordETag: etag, Target: hostguard.FenceTarget{Host: obs.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: "box-1", Enrollment: "en", ClaimETag: obs.ETag, Op: obs.Claim.Op.ID, Phase: obs.Claim.Op.Phase}}) |
| + t.Fatal("crash boundary not reached", phase, err) |
| + } |
| + for _, phase := range []string{"intent", "database", "snapshot"} { |
| + t.Run(phase, func(t *testing.T) { |
| + dir := t.TempDir() |
| + for _, name := range []string{"root", "data", "opt", "units", "locks", "child"} { |
| + if err := os.Mkdir(filepath.Join(dir, name), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f := newRecoveryFixture(t) |
| + obs := f.read() |
| + h := obs.Claim.Holder |
| + plan14PutRecord(t, f.claims, provision.Record{Schema: 1, Host: obs.Host, Account: h.Account, App: h.App, Channel: "stable", CreatedAt: f.now}) |
| + store := release.NewDirStore(filepath.Join(dir, "deployment")) |
| + keys, err := f.claims.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + body, _, err := f.claims.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = store.Put(t.Context(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14InventoryProcessCrash$") |
| + cmd.Env = append(os.Environ(), "PLAN14_INVENTORY_CRASH="+phase, "PLAN14_INVENTORY_DIR="+dir, "TMPDIR="+filepath.Join(dir, "child")) |
| + output, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 73 { |
| + t.Fatalf("child: %v %s", err, output) |
| + } |
| + root, _ := plan14InventoryProcessRoot(t, dir) |
| + root.Journals.Now = func() time.Time { return time.Now().Add(25 * time.Hour) } |
| + if err = root.recoverRootInventory(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + captured := hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp} |
| + view, err := root.Stop.Coordinator.Guard.Root.View(t.Context(), obs.Host, captured) |
| + if err != nil || view.Installation.InventoryRevision != 1 { |
| + t.Fatal(view, err) |
| + } |
| + _, revision, err := root.Journals.Inventory.Snapshot(t.Context(), obs.Host, captured) |
| + if err != nil || revision != 1 { |
| + t.Fatal(revision, err) |
| + } |
| + if err = root.recoverRootInventory(t.Context()); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootinventory_state_linux.go b/internal/host/rootinventory_state_linux.go| new file mode 100644 |
| index 000000000..9eaa954bb |
| --- /dev/null |
| +++ b/internal/host/rootinventory_state_linux.go |
| @@ -0,0 +1,140 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func (a *ClaimRoleAssembly) prepareStateRemote(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs, actor hostbox.Actor) error { |
| + if a.Claims == nil || a.Guard.FenceView == nil || o.Claim == nil || o.Claim.Holder == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + h := o.Claim.Holder |
| + _, etag, err := a.Claims.Get(ctx, h.Key) |
| + if err != nil { |
| + return err |
| + } |
| + selected := hostbox.RecordSelection{Host: o.Host, Key: h.Key, ETag: etag, Incarnation: h.Incarnation, Family: h.Family} |
| + captured := hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp} |
| + client := hostbox.ReconcileAdapter{Reconciler: hostbox.Reconciler{Guard: a.Guard, Claims: a.Claims}, Box: actor.Box, Enrollment: actor.Enrollment} |
| + for attempt := 0; attempt < 3; attempt++ { |
| + inventory, revision, err := a.Inventory.Snapshot(ctx, o.Host, captured) |
| + if err != nil { |
| + return err |
| + } |
| + if !inventory.Complete || inventory.Host != o.Host || inventory.Incarnation != h.Incarnation || inventory.InstallationStamp != h.Stamp || inventory.Box != actor.Box || inventory.Enrollment != actor.Enrollment || inventory.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) { |
| + return hostbox.ErrTypedInventory |
| + } |
| + var expected hostbox.InventoryExpectation |
| + var changed bool |
| + err = work.With(ctx, in.Identity, func() error { |
| + var err error |
| + expected, err = client.CaptureInventory(ctx, selected, captured, revision) |
| + if err != nil { |
| + return err |
| + } |
| + changed, err = a.prepareStateInventory(in, &inventory, actor) |
| + return err |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if !changed { |
| + return nil |
| + } |
| + revision, err = a.Inventory.WriteExpected(ctx, inventory, expected) |
| + if err != nil { |
| + return err |
| + } |
| + err = work.With(ctx, in.Identity, func() error { return client.RevalidateInventory(ctx, selected, captured, expected, revision) }) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + return hostbox.ErrChanged |
| +} |
| + |
| +func (a *ClaimRoleAssembly) prepareStateInventory(in ClaimLaunchInputs, inventory *hostbox.StopInventory, actor hostbox.Actor) (bool, error) { |
| + root := a.ManagedRoots["data"] |
| + relative, err := filepath.Rel(root, filepath.Dir(in.DBPath)) |
| + if err != nil || !filepath.IsLocal(relative) || relative == "." { |
| + return false, hostbox.ErrChanged |
| + } |
| + ownerDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", inventory.Host, inventory.Incarnation, inventory.InstallationStamp, actor.Box))) |
| + owner := hex.EncodeToString(ownerDigest[:]) |
| + index := -1 |
| + for i, e := range inventory.Entries { |
| + if e.Kind == "path" && e.ManagedRoot == "data" && e.RelativePath == relative { |
| + if index != -1 || e.Ownership != owner { |
| + return false, hostbox.ErrTypedInventory |
| + } |
| + index = i |
| + } |
| + } |
| + fd, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer func() { unix.Close(fd) }() |
| + components := strings.Split(filepath.ToSlash(relative), "/") |
| + for _, part := range components[:len(components)-1] { |
| + next, e := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return false, e |
| + } |
| + unix.Close(fd) |
| + fd = next |
| + } |
| + leaf := components[len(components)-1] |
| + var stat unix.Stat_t |
| + err = unix.Fstatat(fd, leaf, &stat, unix.AT_SYMLINK_NOFOLLOW) |
| + if err == nil { |
| + if index < 0 { |
| + return false, hostbox.ErrTypedInventory |
| + } |
| + e := inventory.Entries[index] |
| + if e.Materialization != "materialized" || stat.Mode&unix.S_IFMT != unix.S_IFDIR || uint64(stat.Dev) != e.Device || stat.Ino != e.Inode { |
| + return false, hostbox.ErrTypedInventory |
| + } |
| + return false, nil |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return false, err |
| + } |
| + if index >= 0 && inventory.Entries[index].Materialization == "materialized" { |
| + return false, hostbox.ErrChanged |
| + } |
| + if index < 0 { |
| + sum := sha256.Sum256([]byte(owner + "\x00state-dir\x00" + relative)) |
| + inventory.Entries = append(inventory.Entries, hostbox.StopInventoryEntry{Kind: "path", ArtifactID: hex.EncodeToString(sum[:]), ManagedRoot: "data", RelativePath: relative, Ownership: owner, Type: unix.S_IFDIR, Materialization: "intended"}) |
| + return true, nil |
| + } |
| + if err := unix.Mkdirat(fd, leaf, 0700); err != nil { |
| + return false, err |
| + } |
| + if err := unix.Fstatat(fd, leaf, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + return false, err |
| + } |
| + if err := unix.Fsync(fd); err != nil { |
| + return false, err |
| + } |
| + entry := &inventory.Entries[index] |
| + entry.Device = uint64(stat.Dev) |
| + entry.Inode = stat.Ino |
| + entry.Type = unix.S_IFDIR |
| + entry.Materialization = "materialized" |
| + return true, nil |
| +} |
diff --git a/internal/host/rootinventory_test.go b/internal/host/rootinventory_test.go| new file mode 100644 |
| index 000000000..f714abe04 |
| --- /dev/null |
| +++ b/internal/host/rootinventory_test.go |
| @@ -0,0 +1,165 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestPlan14RootInventoryEstablishment(t *testing.T) { |
| + for _, attack := range []string{"none", "record etag", "claim etag", "account", "revision", "foreign root"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + o := f.read() |
| + h := o.Claim.Holder |
| + rec := provision.Record{Schema: 1, Host: o.Host, Account: h.Account, App: h.App, Channel: "stable", CreatedAt: f.now} |
| + plan14PutRecord(t, f.claims, rec) |
| + _, etag, err := f.claims.Get(t.Context(), h.Key) |
| + if err != nil { |
| + t.Fatalf("%v: %v", err, errors.Unwrap(err)) |
| + } |
| + reg := restartTestRegistry(t) |
| + journals, err := hostbox.NewJournalService(f.journal) |
| + if err != nil { |
| + t.Fatalf("%v: %v", err, errors.Unwrap(err)) |
| + } |
| + guard := f.guard |
| + guard.Root = hostguard.NewRootAuthority(&rollbackMemory{files: map[string][]byte{}}) |
| + roots := map[string]string{"data": t.TempDir(), "opt": t.TempDir(), "units": t.TempDir()} |
| + actor := hostbox.Actor{Box: "box-1", Enrollment: "en", Revision: 1, Region: "ie"} |
| + census := &hostbox.EmptyInstallationCensus{Inventory: journals.Inventory, Registry: reg, Launches: f.journal, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1", Roots: roots} |
| + root := ClaimRootHTTP{Journals: journals, Launches: f.journal, Roots: roots, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Reconcile: hostbox.ReconcileAdapter{Registry: reg, Inventory: journals.Inventory, Census: census, Box: actor.Box, Enrollment: actor.Enrollment}, Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: guard, Evidence: f.claims}}} |
| + expected := hostbox.InventoryExpectation{RecordKey: h.Key, RecordETag: etag, Target: hostguard.FenceTarget{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: actor.Box, Enrollment: actor.Enrollment, ClaimETag: o.ETag, Op: o.Claim.Op.ID, Phase: o.Claim.Op.Phase}} |
| + in := hostbox.StopInventory{Complete: true, Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1"} |
| + switch attack { |
| + case "record etag": |
| + expected.RecordETag = "other" |
| + case "claim etag": |
| + expected.Target.ClaimETag = "other" |
| + case "account": |
| + expected.RecordKey = "apps/bap/jam/instances/" + o.Host + ".json" |
| + case "revision": |
| + expected.Revision = 8 |
| + case "foreign root": |
| + in.Entries = []hostbox.StopInventoryEntry{{Kind: "path", ManagedRoot: "opt", RelativePath: "secret", Ownership: "forged", ArtifactID: "forged", Materialization: "intended"}} |
| + } |
| + var request hostbox.JournalRequest |
| + _, client := hostbox.RemoteJournals(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| + request = q |
| + return root.commitRootInventory(ctx, q) |
| + }) |
| + _, err = client.WriteExpected(t.Context(), in, expected) |
| + if attack != "none" { |
| + if err == nil { |
| + t.Fatal("untrusted installation established") |
| + } |
| + if _, e := guard.Capture(o.Host); e == nil { |
| + t.Fatal("refusal wrote root installation") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("%v: %v", err, errors.Unwrap(err)) |
| + } |
| + v, err := guard.Root.View(t.Context(), o.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| + if err != nil || v.Installation.InventoryRevision != 1 || v.Installation.Record != h.Key { |
| + t.Fatalf("installation snapshot: %+v %v", v, err) |
| + } |
| + if _, err := root.commitRootInventory(t.Context(), request); err != nil { |
| + t.Fatal("lost response replay", err) |
| + } |
| + _, rev, err := journals.Inventory.Snapshot(t.Context(), o.Host, v.Captured) |
| + if err != nil || rev != 1 { |
| + t.Fatalf("replay changed revision: %d %v", rev, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14InventoryRegistrationCompletesWithoutAgentLock(t *testing.T) { |
| + for _, stale := range []bool{false, true} { |
| + t.Run(fmt.Sprint(stale), func(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + obs := f.read() |
| + holder := obs.Claim.Holder |
| + rec := provision.Record{Schema: 1, Host: obs.Host, Account: holder.Account, App: holder.App, Channel: "stable", CreatedAt: f.now} |
| + plan14PutRecord(t, f.claims, rec) |
| + reg := restartTestRegistry(t) |
| + journals, err := hostbox.NewJournalService(f.journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rootGuard := f.guard |
| + rootGuard.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| + roots := map[string]string{"data": f.guard.DataDir, "opt": t.TempDir(), "units": t.TempDir()} |
| + actor := hostbox.Actor{Box: "box-1", Enrollment: "en", Revision: 1, Region: "ie"} |
| + census := &hostbox.EmptyInstallationCensus{Inventory: journals.Inventory, Registry: reg, Launches: f.journal, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1", Roots: roots, SharedPaths: map[string][]string{"data": {"hostguard"}}} |
| + root := ClaimRootHTTP{Journals: journals, Launches: f.journal, Roots: roots, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Reconcile: hostbox.ReconcileAdapter{Registry: reg, Inventory: journals.Inventory, Census: census, Box: actor.Box, Enrollment: actor.Enrollment}, Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: rootGuard, Evidence: f.claims}}} |
| + _, remote := hostbox.RemoteJournals(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| + if q.Op == "inventory-write" { |
| + out, err := root.commitRootInventory(ctx, q) |
| + if stale && q.Revision == 1 && err == nil { |
| + rec.RecordSeq++ |
| + plan14PutRecord(t, f.claims, rec) |
| + } |
| + return out, err |
| + } |
| + return journals.Execute(ctx, actor.Box+"/"+actor.Enrollment, q) |
| + }) |
| + guard := f.guard |
| + guard.FenceView = rootGuard.Root.View |
| + source := hostbox.WorkSource{Guard: guard, Box: actor.Box, Region: "ie", Now: time.Now, Policy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }} |
| + adapter := hostbox.ReconcileAdapter{Registry: reg, Inventory: remote, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1", Census: census, Reconciler: hostbox.Reconciler{Claims: f.claims, Guard: guard, Box: actor.Box, Region: "ie", Now: time.Now, Writes: hostwrite.Config{Enabled: true, Selector: source}, Local: park.Local{Guard: guard, Claims: f.claims, DataDir: guard.DataDir, Box: actor.Box}, CheckRecord: func(context.Context, hostbox.RecordSelection) error { return nil }, CheckLocal: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil }}} |
| + row := registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Kind: "instance", DBPath: provision.NestedDBPath(guard.DataDir, rec.Host), Socket: provision.NestedSocket(rec.Host)} |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + writes := 0 |
| + err = adapter.Install(ctx, provision.ClaimRecord{Host: rec.Host, Key: holder.Key, Account: rec.Account, App: rec.App, Family: "instance", CreatedAt: rec.CreatedAt, Value: rec, Route: row}, func(owner registry.ClaimOwnership) error { |
| + writes++ |
| + row.ClaimOwnership = owner |
| + return reg.Upsert(row) |
| + }) |
| + if stale { |
| + if err == nil || writes != 0 { |
| + t.Fatalf("stale response applied: %v %d", err, writes) |
| + } |
| + return |
| + } |
| + if err != nil || writes != 1 { |
| + t.Fatalf("registration failed or blocked: %v (%v), effects=%d", err, errors.Unwrap(err), writes) |
| + } |
| + _, revision, err := journals.Inventory.Snapshot(t.Context(), rec.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| + if err != nil || revision != 3 { |
| + t.Fatalf("registration did not complete all phases: %d %v", revision, err) |
| + } |
| + if err := os.Mkdir(filepath.Join(guard.DataDir, "instances"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + work, err := source.Select(t.Context(), rec.Host, "spawn") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + roles := &ClaimRoleAssembly{Inventory: remote, ManagedRoots: roots, Guard: guard, Claims: f.claims} |
| + if err := roles.prepareStateRemote(ctx, f.read(), work, ClaimLaunchInputs{DBPath: row.DBPath}, actor); err != nil { |
| + t.Fatalf("state registration failed or blocked: %v", err) |
| + } |
| + _, revision, err = journals.Inventory.Snapshot(t.Context(), rec.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| + if err != nil || revision != 5 { |
| + t.Fatalf("state phases missing: %d %v", revision, err) |
| + } |
| + |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootrecord_claim.go b/internal/host/rootrecord_claim.go| new file mode 100644 |
| index 000000000..2ae14d342 |
| --- /dev/null |
| +++ b/internal/host/rootrecord_claim.go |
| @@ -0,0 +1,61 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func rootClaimRecord(ctx context.Context, store release.Store, c *hostclaim.Claim, dataDir string) (registry.Route, string, error) { |
| + if c == nil || c.Holder == nil { |
| + return registry.Route{}, "", os.ErrPermission |
| + } |
| + h := c.Holder |
| + var rt registry.Route |
| + var etag, inc string |
| + if h.Family == "sidecar" { |
| + if h.Key != provision.SidecarRecordKey(h.Account, h.App, h.Name) { |
| + return rt, "", os.ErrPermission |
| + } |
| + body, e, err := store.Get(ctx, h.Key) |
| + if err != nil { |
| + return rt, "", err |
| + } |
| + var rec provision.SidecarRecord |
| + if json.Unmarshal(body, &rec) != nil || provision.ValidateSidecarShape(rec) != nil || rec.Account != h.Account || rec.App != h.App || rec.Name != h.Name || rec.Host != c.Host { |
| + return rt, "", os.ErrPermission |
| + } |
| + inc, err = hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + return rt, "", err |
| + } |
| + rt = registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Kind: "sidecar", DBPath: provision.DefaultDBPath(dataDir, rec.Host)} |
| + etag = e |
| + } else { |
| + rec, e, err := provision.FindRecordByHost(ctx, store, c.Host) |
| + if err != nil { |
| + return rt, "", err |
| + } |
| + inc, err = hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + return rt, "", err |
| + } |
| + if h.Key != provision.RecordKey(rec.Account, rec.App, rec.Host) { |
| + return rt, "", os.ErrPermission |
| + } |
| + rt = registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Kind: rec.KindOrDefault()} |
| + etag = e |
| + if rec.DerivesPaths() { |
| + rt.DBPath = provision.NestedDBPath(dataDir, rec.Host) |
| + } |
| + } |
| + if etag == "" || rt.Account != h.Account || rt.App != h.App || inc != h.Incarnation { |
| + return registry.Route{}, "", os.ErrPermission |
| + } |
| + return rt, etag, nil |
| +} |
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go| index 0eada410c..f74ff68bd 100644 |
| --- a/internal/host/rootrestart.go |
| +++ b/internal/host/rootrestart.go |
| @@ -8,21 +8,26 @@ import ( |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| type rootRestartChecks struct { |
| - dispatch func(context.Context, registry.Route) (bool, error) |
| - resolve proofBindingResolver |
| - started *sync.Map |
| - attempts func() (rootstate.Store, error) |
| - rollback func(context.Context, proofBinding) (proofBinding, error) |
| - host func(context.Context, string) (proofBinding, error) |
| - ready func(registry.Route) func() error |
| - now func() time.Time |
| - sleep func(time.Duration) |
| + dispatch func(context.Context, registry.Route) (bool, error) |
| + resolve proofBindingResolver |
| + started *sync.Map |
| + attempts func() (rootstate.Store, error) |
| + rollback func(context.Context, proofBinding) (proofBinding, error) |
| + prepareRollback func(context.Context, proofBinding) (proofBinding, error) |
| + identity hostwrite.Identity |
| + effect func(func() error) error |
| + verifyOnly bool |
| + host func(context.Context, string) (proofBinding, error) |
| + ready func(registry.Route) func() error |
| + now func() time.Time |
| + sleep func(time.Duration) |
| } |
| func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartChecks { |
| @@ -39,6 +44,13 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck |
| return s.needsRestart(ctx, rt) |
| }, |
| attempts: func() (rootstate.Store, error) { return rootstate.Open(rootRestartState, true, false) }, |
| + prepareRollback: func(ctx context.Context, b proofBinding) (proofBinding, error) { |
| + s, err := source(ctx) |
| + if err != nil { |
| + return proofBinding{}, err |
| + } |
| + return s.prepareRollback(ctx, b) |
| + }, |
| rollback: func(ctx context.Context, b proofBinding) (proofBinding, error) { |
| s, err := source(ctx) |
| if err != nil { |
| @@ -98,7 +110,11 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| if before.ActiveState != "active" || before.MainPID <= 0 || before.MainPID != binding.PID || before.InvocationID != binding.Invocation { |
| return os.ErrPermission |
| } |
| - attempt, err := captureRollback(s, binding, c.now(), unitSettleTimeout(getenv)) |
| + target, err := filepath.EvalSymlinks(release.LiveLink(optDir, candidate.Host)) |
| + if err != nil { |
| + return err |
| + } |
| + attempt, err := captureRollback(s, binding, c.now(), unitSettleTimeout(getenv), target, c.identity) |
| if err != nil { |
| return err |
| } |
| @@ -144,28 +160,43 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| if err != nil { |
| return err |
| } |
| - if rollback { |
| - if err := sys.ResetFailed(rt.Unit); err != nil { |
| - return err |
| - } |
| - if err := sys.Restart(rt.Unit); err != nil { |
| - return err |
| - } |
| - } else { |
| - if before.ActiveState != "active" || before.MainPID != binding.PID || before.InvocationID != binding.Invocation { |
| - return os.ErrPermission |
| - } |
| - start, err := sys.StartTime(before.MainPID) |
| - if err != nil || start != binding.Start { |
| - return os.ErrPermission |
| - } |
| - current, err := c.host(context.Background(), rt.Host) |
| - if err != nil || current.Source != binding.Source || current.Version != binding.Version || current.Digest != binding.Digest || current.PID != binding.PID || current.Invocation != binding.Invocation { |
| - return os.ErrPermission |
| - } |
| - if err := sys.TryRestart(rt.Unit); err != nil { |
| - return err |
| + effect := c.effect |
| + if effect == nil { |
| + effect = func(fn func() error) error { return fn() } |
| + } |
| + if c.verifyOnly { |
| + before.InvocationID = "" |
| + } else if err := effect(func() error { |
| + if rollback { |
| + current, err := c.rollback(context.Background(), binding) |
| + if err != nil || current.Source != binding.Source { |
| + return os.ErrPermission |
| + } |
| + if err := sys.ResetFailed(rt.Unit); err != nil { |
| + return err |
| + } |
| + if err := sys.Restart(rt.Unit); err != nil { |
| + return err |
| + } |
| + } else { |
| + if before.ActiveState != "active" || before.MainPID != binding.PID || before.InvocationID != binding.Invocation { |
| + return os.ErrPermission |
| + } |
| + start, err := sys.StartTime(before.MainPID) |
| + if err != nil || start != binding.Start { |
| + return os.ErrPermission |
| + } |
| + current, err := c.host(context.Background(), rt.Host) |
| + if err != nil || current.Source != binding.Source || current.Version != binding.Version || current.Digest != binding.Digest || current.PID != binding.PID || current.Invocation != binding.Invocation { |
| + return os.ErrPermission |
| + } |
| + if err := sys.TryRestart(rt.Unit); err != nil { |
| + return err |
| + } |
| } |
| + return nil |
| + }); err != nil { |
| + return err |
| } |
| var observed proofBinding |
| checkedReady := func() error { |
| @@ -201,7 +232,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, |
| return os.ErrPermission |
| } |
| if settled != nil { |
| - if err := settled(binding.Route); err != nil { |
| + if err := effect(func() error { return settled(binding.Route) }); err != nil { |
| return errors.Join(&release.UnitNotUpError{Unit: rt.Unit, Reason: err.Error()}, err) |
| } |
| } |
| @@ -249,6 +280,9 @@ func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, ge |
| if before.ActiveState != "failed" && before.MainPID != 0 && !attempt.Failed { |
| return os.ErrPermission |
| } |
| + if attempt.Recovering && before.ActiveState == "active" && before.MainPID > 0 { |
| + c.verifyOnly = true |
| + } |
| attempt.Recovering = true |
| if err := writeRollbackState(s, rollbackJournalKey(host), attempt); err != nil { |
| return err |
| @@ -262,6 +296,21 @@ func rootRestartRoute(reg *registry.Registry, optDir string, settled func(regist |
| return rootRestartRouteWith(reg, systemdUnits{}, optDir, os.Getenv, settled, productionRootRestartChecks(systemdUnits{}, optDir)) |
| } |
| +func rootSelectedRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, settled func(registry.Route) error, checks rootRestartChecks, rollback bool) hostwrite.Callback { |
| + return func(ctx context.Context, selected hostwrite.Selection) error { |
| + if selected.Work == nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| + c := checks |
| + c.identity = selected.Identity |
| + c.effect = func(fn func() error) error { return selected.Work.With(ctx, selected.Identity, fn) } |
| + if rollback { |
| + return rollbackUnitRestart(reg, sys, optDir, getenv, c)(selected.Host) |
| + } |
| + return rootRestartRouteWith(reg, sys, optDir, getenv, settled, c)(selected.Host) |
| + } |
| +} |
| + |
| func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, settled func(registry.Route) error, checks rootRestartChecks) func(string) error { |
| restart := checkedUnitRestart(sys, optDir, getenv, settled, checks) |
| return func(host string) error { |
diff --git a/internal/host/rootrollback.go b/internal/host/rootrollback.go| index d7f996314..091bf2c2a 100644 |
| --- a/internal/host/rootrollback.go |
| +++ b/internal/host/rootrollback.go |
| @@ -11,6 +11,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| @@ -25,6 +26,8 @@ type rollbackAttempt struct { |
| Failed bool |
| Recovering bool |
| Settled bool |
| + Target string |
| + Identity hostwrite.Identity |
| } |
| type rollbackCapability struct { |
| @@ -70,7 +73,7 @@ func settleRollbackAttempt(s rootstate.Store, a rollbackAttempt) error { |
| return removeRollbackCapabilities(s, a.Host) |
| } |
| -func captureRollback(s rootstate.Store, b proofBinding, now time.Time, timeout time.Duration) (rollbackAttempt, error) { |
| +func captureRollback(s rootstate.Store, b proofBinding, now time.Time, timeout time.Duration, target string, identity hostwrite.Identity) (rollbackAttempt, error) { |
| if b.Source == "" || !b.Historical || b.Version == "" || b.Digest == "" || b.History == nil || b.History.Version != b.Version || b.History.SHA256 != b.Digest { |
| return rollbackAttempt{}, os.ErrPermission |
| } |
| @@ -88,7 +91,7 @@ func captureRollback(s rootstate.Store, b proofBinding, now time.Time, timeout t |
| if _, err := rand.Read(id); err != nil { |
| return rollbackAttempt{}, err |
| } |
| - a := rollbackAttempt{ID: hex.EncodeToString(id), Host: b.Route.Host, Created: now, Deadline: now.Add(2 * timeout)} |
| + a := rollbackAttempt{ID: hex.EncodeToString(id), Host: b.Route.Host, Created: now, Deadline: now.Add(2 * timeout), Target: target, Identity: identity} |
| if err := writeRollbackState(s, rollbackCapabilityKey(a.Host, a.ID), rollbackCapability{Attempt: a, Binding: b}); err != nil { |
| return rollbackAttempt{}, err |
| } |
| @@ -103,7 +106,7 @@ func loadRollback(s rootstate.Store, host string, now time.Time) (rollbackAttemp |
| if err != nil { |
| return rollbackAttempt{}, proofBinding{}, errors.Join(err, removeRollbackCapabilities(s, host)) |
| } |
| - if a.Settled || a.Recovering || now.Before(a.Created) || now.After(a.Deadline.Add(rollbackMargin)) { |
| + if a.Settled || now.Before(a.Created) || now.After(a.Deadline.Add(rollbackMargin)) { |
| return a, proofBinding{}, errors.Join(os.ErrPermission, settleRollbackAttempt(s, a)) |
| } |
| b, err := s.Read(rollbackCapabilityKey(host, a.ID)) |
| @@ -111,7 +114,7 @@ func loadRollback(s rootstate.Store, host string, now time.Time) (rollbackAttemp |
| return a, proofBinding{}, err |
| } |
| var cap rollbackCapability |
| - if json.Unmarshal(b, &cap) != nil || cap.Attempt.ID != a.ID || cap.Attempt.Host != host || !cap.Attempt.Deadline.Equal(a.Deadline) || !cap.Attempt.Created.Equal(a.Created) || cap.Binding.Route.Host != host { |
| + if json.Unmarshal(b, &cap) != nil || cap.Attempt.ID != a.ID || cap.Attempt.Host != host || cap.Attempt.Target != a.Target || cap.Attempt.Identity != a.Identity || !cap.Attempt.Deadline.Equal(a.Deadline) || !cap.Attempt.Created.Equal(a.Created) || cap.Binding.Route.Host != host { |
| return a, proofBinding{}, os.ErrPermission |
| } |
| return a, cap.Binding, nil |
diff --git a/internal/host/rootrollback_process_test.go b/internal/host/rootrollback_process_test.go| new file mode 100644 |
| index 000000000..3663986ec |
| --- /dev/null |
| +++ b/internal/host/rootrollback_process_test.go |
| @@ -0,0 +1,213 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type rollbackDisk struct { |
| + rootstate.Store |
| + dir string |
| + afterWrite func(string, []byte) |
| + beforeWrite func(string, []byte) |
| +} |
| + |
| +func (s *rollbackDisk) Read(k string) ([]byte, error) { return os.ReadFile(filepath.Join(s.dir, k)) } |
| +func (s *rollbackDisk) Write(k string, b []byte) error { |
| + if s.beforeWrite != nil { |
| + s.beforeWrite(k, b) |
| + } |
| + f, err := os.CreateTemp(s.dir, "pending-") |
| + if err != nil { |
| + return err |
| + } |
| + n := f.Name() |
| + defer os.Remove(n) |
| + if _, err = f.Write(b); err == nil { |
| + err = f.Sync() |
| + } |
| + err = errors.Join(err, f.Close()) |
| + if err != nil { |
| + return err |
| + } |
| + if err = os.Rename(n, filepath.Join(s.dir, k)); err != nil { |
| + return err |
| + } |
| + d, err := os.Open(s.dir) |
| + if err != nil { |
| + return err |
| + } |
| + err = errors.Join(d.Sync(), d.Close()) |
| + if err != nil { |
| + return err |
| + } |
| + if s.afterWrite != nil { |
| + s.afterWrite(k, b) |
| + } |
| + return nil |
| +} |
| +func (s *rollbackDisk) Remove(k string) error { |
| + err := os.Remove(filepath.Join(s.dir, k)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + return err |
| +} |
| +func (s *rollbackDisk) Names() ([]string, error) { |
| + es, err := os.ReadDir(s.dir) |
| + var out []string |
| + for _, e := range es { |
| + out = append(out, e.Name()) |
| + } |
| + return out, err |
| +} |
| +func (s *rollbackDisk) Lock(context.Context, string) (func(), error) { return func() {}, nil } |
| +func (s *rollbackDisk) Close() error { return nil } |
| + |
| +type rollbackProcessFixture struct { |
| + Props unitProps |
| + Exe, Opt, Bake, Store string |
| + Effects []string |
| +} |
| + |
| +func TestPlan14RollbackProcessCrash(t *testing.T) { |
| + if boundary := os.Getenv("PLAN14_CRASH_BOUNDARY"); boundary != "" { |
| + dir := os.Getenv("PLAN14_CRASH_DIR") |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + storeDir := filepath.Join(dir, "deployment") |
| + store := release.NewDirStore(storeDir) |
| + rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + plan14PutRecord(t, store, *rec) |
| + disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")} |
| + if err := os.Mkdir(disk.dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.attempts = func() (rootstate.Store, error) { return disk, nil } |
| + crash := func() { |
| + b, err := json.Marshal(rollbackProcessFixture{Props: u.props, Exe: u.exe, Opt: u.opt, Bake: e.bake, Store: storeDir, Effects: u.effects}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "fixture.json"), b, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Exit(73) |
| + } |
| + disk.afterWrite = func(k string, b []byte) { |
| + if k != rollbackJournalKey(rt.Host) { |
| + return |
| + } |
| + var a rollbackAttempt |
| + if json.Unmarshal(b, &a) != nil { |
| + return |
| + } |
| + if boundary == "captured" && !a.Failed && !a.Recovering && !a.Settled { |
| + crash() |
| + } |
| + if boundary == "failed journal" && a.Failed && !a.Recovering && !a.Settled { |
| + crash() |
| + } |
| + if boundary == "rollback pending" && a.Recovering && !a.Settled { |
| + crash() |
| + } |
| + if boundary == "settled" && a.Settled { |
| + crash() |
| + } |
| + } |
| + u.crashNew = true |
| + u.afterEffect = func() { |
| + if boundary == "forward restart" && len(u.effects) == 1 { |
| + crash() |
| + } |
| + if boundary == "rollback running" && len(u.effects) == 3 { |
| + crash() |
| + } |
| + } |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| + _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| + t.Fatal("crash boundary not reached") |
| + } |
| + for _, boundary := range []string{"captured", "forward restart", "failed journal", "rollback pending", "rollback running", "settled"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + dir := t.TempDir() |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPlan14RollbackProcessCrash$", "-test.count=1") |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "TMPDIR="+dir, "PLAN14_CRASH_BOUNDARY="+boundary, "PLAN14_CRASH_DIR="+dir, "CARLOS_HOST_CLAIM=") |
| + out, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 73 { |
| + t.Fatalf("subprocess did not crash at %s: %v\n%s", boundary, err, out) |
| + } |
| + body, err := os.ReadFile(filepath.Join(dir, "fixture.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f rollbackProcessFixture |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := &plan14RestartUnits{props: f.Props, exe: f.Exe, opt: f.Opt, effects: f.Effects} |
| + disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")} |
| + source := &proofBindingSource{store: release.NewDirStore(f.Store), sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}, healthy: func(acct, app, ver, digest string) *release.HealthyBuild { |
| + for _, b := range release.ReadHealthy(f.Bake, acct, app) { |
| + if b.Version == ver && b.SHA256 == digest { |
| + return &b |
| + } |
| + } |
| + return nil |
| + }} |
| + a, err := readRollbackAttempt(disk, observedHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := a.Created.Add(time.Second) |
| + c := rootRestartChecks{started: &sync.Map{}, attempts: func() (rootstate.Store, error) { return disk, nil }, prepareRollback: source.prepareRollback, rollback: source.rollback, host: source.forHost, ready: func(registry.Route) func() error { return func() error { return nil } }, now: func() time.Time { return now }, sleep: func(d time.Duration) { now = now.Add(d) }} |
| + before := len(u.effects) |
| + err = recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}) |
| + if boundary == "captured" { |
| + if err == nil || len(u.effects) != before { |
| + t.Fatalf("unstarted update restarted: %v %v", err, u.effects) |
| + } |
| + } else { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if u.exe != release.VersionPath(u.opt, "bab", "v1", "console") || u.props.MainPID <= 0 { |
| + t.Fatalf("no recovered process: %+v", u) |
| + } |
| + if (boundary == "rollback running" || boundary == "settled") && len(u.effects) != before { |
| + t.Fatalf("restarted twice: %v", u.effects) |
| + } |
| + } |
| + a, err = readRollbackAttempt(disk, observedHost) |
| + if err != nil || !a.Settled { |
| + t.Fatalf("unsettled recovery: %+v %v", a, err) |
| + } |
| + if _, err := disk.Read(rollbackCapabilityKey(a.Host, a.ID)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("capability survived recovery") |
| + } |
| + before = len(u.effects) |
| + if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil || len(u.effects) != before { |
| + t.Fatalf("settled replay: %v %v", err, u.effects) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootrollback_resume.go b/internal/host/rootrollback_resume.go| new file mode 100644 |
| index 000000000..65560359d |
| --- /dev/null |
| +++ b/internal/host/rootrollback_resume.go |
| @@ -0,0 +1,113 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func recoverRootRestarts(ctx context.Context, sys unitSystem, optDir string, getenv func(string) string, checks rootRestartChecks, writes hostwrite.Config) error { |
| + if checks.attempts == nil || checks.prepareRollback == nil { |
| + return os.ErrPermission |
| + } |
| + s, err := checks.attempts() |
| + if err != nil { |
| + return err |
| + } |
| + defer s.Close() |
| + names, err := s.Names() |
| + if err != nil { |
| + return err |
| + } |
| + var failures []error |
| + for _, name := range names { |
| + if !strings.HasSuffix(name, ".attempt") { |
| + continue |
| + } |
| + body, err := s.Read(name) |
| + var a rollbackAttempt |
| + if err != nil || json.Unmarshal(body, &a) != nil || name != rollbackJournalKey(a.Host) { |
| + return errors.Join(err, os.ErrPermission) |
| + } |
| + if err := resumeRootRestart(ctx, sys, optDir, getenv, checks, writes, s, a); err != nil { |
| + failures = append(failures, err) |
| + } |
| + } |
| + return errors.Join(failures...) |
| +} |
| + |
| +func resumeRootRestart(ctx context.Context, sys unitSystem, optDir string, getenv func(string) string, checks rootRestartChecks, writes hostwrite.Config, s rootstate.Store, expected rollbackAttempt) (result error) { |
| + unlock, err := s.Lock(ctx, rollbackHostKey(expected.Host)+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + current, err := readRollbackAttempt(s, expected.Host) |
| + if err != nil || current.ID != expected.ID { |
| + return errors.Join(err, os.ErrPermission) |
| + } |
| + if current.Settled { |
| + return settleRollbackAttempt(s, current) |
| + } |
| + a, b, err := loadRollback(s, expected.Host, checks.now()) |
| + if a.ID != expected.ID { |
| + return errors.Join(err, os.ErrPermission) |
| + } |
| + defer func() { result = errors.Join(result, settleRollbackAttempt(s, a)) }() |
| + if err != nil { |
| + return err |
| + } |
| + w, err := writes.Select(ctx, a.Host, "release") |
| + if err != nil { |
| + return err |
| + } |
| + checks.identity = a.Identity |
| + checks.effect = func(fn func() error) error { return w.With(ctx, a.Identity, fn) } |
| + err = checks.effect(func() error { |
| + var err error |
| + b, err = checks.prepareRollback(ctx, b) |
| + if err != nil { |
| + return err |
| + } |
| + p, err := sys.Show(b.Route.Unit) |
| + if err != nil { |
| + return err |
| + } |
| + if p.ActiveState == "inactive" { |
| + return errUnitStopped |
| + } |
| + live, err := filepath.EvalSymlinks(release.LiveLink(optDir, a.Host)) |
| + old := release.VersionPath(optDir, b.Route.Account, b.Version, b.Route.App) |
| + if err != nil || (live != a.Target && live != old) { |
| + return os.ErrPermission |
| + } |
| + if a.Recovering && p.ActiveState == "active" && p.MainPID > 0 && live == old { |
| + checks.verifyOnly = true |
| + } else if p.ActiveState != "failed" && p.MainPID != 0 && !a.Failed { |
| + return os.ErrPermission |
| + } |
| + a.Recovering = true |
| + if err := writeRollbackState(s, rollbackJournalKey(a.Host), a); err != nil { |
| + return err |
| + } |
| + if live != old { |
| + return release.PointLive(optDir, a.Host, b.Route.Account, b.Version, b.Route.App) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + b, err = checks.rollback(ctx, b) |
| + if err != nil { |
| + return err |
| + } |
| + return restartBoundUnit(sys, optDir, getenv, b, true, nil, checks) |
| +} |
diff --git a/internal/host/rootrollback_resume_test.go b/internal/host/rootrollback_resume_test.go| new file mode 100644 |
| index 000000000..4f6139509 |
| --- /dev/null |
| +++ b/internal/host/rootrollback_resume_test.go |
| @@ -0,0 +1,91 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPlan14RollbackCrashResume(t *testing.T) { |
| + for _, boundary := range []string{"forward restart", "rollback pending", "rollback running", "operator stopped", "changed link"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + u.crashNew = true |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| + crashed := errors.New("root process exited") |
| + func() { |
| + defer func() { |
| + if got := recover(); got != crashed { |
| + t.Fatalf("crash boundary: %v", got) |
| + } |
| + }() |
| + _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, func(host string) error { |
| + if err := forward(host); err == nil { |
| + t.Fatal("failed update succeeded") |
| + } |
| + panic(crashed) |
| + }, release.BakeDir(e.bake), release.WithRestartFailurePropagation()) |
| + }() |
| + s, err := c.attempts() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a, err := readRollbackAttempt(s, rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "rollback pending" || boundary == "rollback running" { |
| + a.Recovering = true |
| + if err := writeRollbackState(s, rollbackJournalKey(rt.Host), a); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PointLive(u.opt, rt.Host, rt.Account, "v1", rt.App); err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "rollback running" { |
| + if err := u.Restart(rt.Unit); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + if boundary == "operator stopped" { |
| + u.props.ActiveState, u.props.SubState = "inactive", "dead" |
| + } |
| + if boundary == "changed link" { |
| + if err := os.Remove(release.LiveLink(u.opt, rt.Host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before := len(u.effects) |
| + c.started = &sync.Map{} |
| + err = recoverRootRestarts(context.Background(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}) |
| + if boundary == "operator stopped" || boundary == "changed link" { |
| + if err == nil || len(u.effects) != before { |
| + t.Fatalf("unsafe resume: %v, %v", err, u.effects) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if u.exe != release.VersionPath(u.opt, rt.Account, "v1", rt.App) || u.props.MainPID <= 0 { |
| + t.Fatalf("resume did not restore the old process: %+v", u) |
| + } |
| + if boundary == "rollback running" && len(u.effects) != before { |
| + t.Fatal("resumed verification restarted an already recovered unit") |
| + } |
| + a, err = readRollbackAttempt(s, rt.Host) |
| + if err != nil || !a.Settled { |
| + t.Fatalf("resume did not settle: %+v, %v", a, err) |
| + } |
| + if _, err := s.Read(rollbackCapabilityKey(rt.Host, a.ID)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("resume retained capability") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootrollback_test.go b/internal/host/rootrollback_test.go| index 31f7eb33e..45f3b7d9d 100644 |
| --- a/internal/host/rootrollback_test.go |
| +++ b/internal/host/rootrollback_test.go |
| @@ -38,7 +38,7 @@ func (m *rollbackMemory) Lock(context.Context, string) (func(), error) { return |
| func (m *rollbackMemory) Close() error { return nil } |
| func TestPlan14RollbackCapability(t *testing.T) { |
| - for _, attack := range []string{"crash", "unit identity", "etag", "missing", "stale", "later process", "settled", "operator stopped", "wrong link", "bad executable", "failed restart result", "missing journal", "different attempt", "spent", "capture write"} { |
| + for _, attack := range []string{"crash", "unit identity", "etag", "missing", "stale", "later process", "settled", "operator stopped", "wrong link", "bad executable", "failed restart result", "missing journal", "different attempt", "capability attempt", "spent", "capture write"} { |
| t.Run(attack, func(t *testing.T) { |
| e, rt, u, c, pub := plan14AdoptionFixture(t) |
| u.crashNew = true |
| @@ -90,6 +90,17 @@ func TestPlan14RollbackCapability(t *testing.T) { |
| delete(mem.files, key) |
| case "different attempt": |
| a.ID = "different" |
| + case "capability attempt": |
| + var capability rollbackCapability |
| + raw, _ := mem.Read(rollbackCapabilityKey(host, a.ID)) |
| + if e := json.Unmarshal(raw, &capability); e != nil { |
| + t.Fatal(e) |
| + } |
| + capability.Attempt.ID = "another-attempt" |
| + raw, _ = json.Marshal(capability) |
| + if e := mem.Write(rollbackCapabilityKey(host, a.ID), raw); e != nil { |
| + t.Fatal(e) |
| + } |
| case "spent": |
| a.Recovering = true |
| } |
| @@ -114,7 +125,7 @@ func TestPlan14RollbackCapability(t *testing.T) { |
| rollbackErr = rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c)(host) |
| return rollbackErr |
| })) |
| - positive := attack == "crash" || attack == "later process" || attack == "failed restart result" |
| + positive := attack == "crash" || attack == "later process" || attack == "failed restart result" || attack == "spent" |
| if err == nil { |
| t.Fatal("forward failure disappeared") |
| } |
diff --git a/internal/host/rootroute.go b/internal/host/rootroute.go| index 617e2a5f0..c1a908d1b 100644 |
| --- a/internal/host/rootroute.go |
| +++ b/internal/host/rootroute.go |
| @@ -13,6 +13,9 @@ import ( |
| ) |
| func rootRecordRoute(rec *provision.Record) (registry.Route, error) { |
| + if rec.Unit == "" { |
| + return registry.Route{}, os.ErrPermission |
| + } |
| rt := registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Kind: rec.KindOrDefault(), Unit: rec.Unit, |
| Addr: rec.Addr, Addrs: strings.Join(rec.Addrs, ","), Channel: rec.Channel, Region: rec.Region, Pool: rec.Pool} |
| if rec.DerivesPaths() { |
| @@ -47,6 +50,9 @@ func (s *proofBindingSource) needsRestart(ctx context.Context, candidate registr |
| if err != nil { |
| return false, err |
| } |
| + if rec.Unit == "" { |
| + return false, nil |
| + } |
| if _, err := s.claimIdentity(ctx, rec); err != nil { |
| return false, err |
| } |
diff --git a/internal/hostbox/inventory_expected.go b/internal/hostbox/inventory_expected.go| new file mode 100644 |
| index 000000000..9f762dcbf |
| --- /dev/null |
| +++ b/internal/hostbox/inventory_expected.go |
| @@ -0,0 +1,43 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +type InventoryExpectation struct { |
| + Revision uint64 |
| + RecordKey, RecordETag string |
| + Target hostguard.FenceTarget |
| +} |
| + |
| +func (d *StopInventoryDB) Snapshot(ctx context.Context, host string, captured hostguard.Captured) (StopInventory, uint64, error) { |
| + if d == nil { |
| + return StopInventory{}, 0, ErrUnsupported |
| + } |
| + target := hostguard.FenceTarget{Host: host, Incarnation: captured.Incarnation, Stamp: captured.Stamp} |
| + var out JournalResponse |
| + var err error |
| + if d.remote != nil { |
| + out, err = journalCall(ctx, d.remote, JournalRequest{Op: "inventory-version", Target: target}) |
| + } else { |
| + out, err = d.inventoryVersion(ctx, target) |
| + } |
| + return out.Inventory, out.Revision, err |
| +} |
| + |
| +func (d *StopInventoryDB) WriteExpected(ctx context.Context, in StopInventory, expected InventoryExpectation) (uint64, error) { |
| + if d == nil || d.remote == nil || expected.Target.Host != in.Host || expected.Target.Incarnation != in.Incarnation || expected.Target.Stamp != in.InstallationStamp { |
| + return 0, ErrTypedInventory |
| + } |
| + out, err := journalCall(ctx, d.remote, JournalRequest{Op: "inventory-write", Inventory: in, Revision: expected.Revision, Target: expected.Target, RecordKey: expected.RecordKey, RecordETag: expected.RecordETag}) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if out.Revision != expected.Revision+1 { |
| + return 0, ErrChanged |
| + } |
| + return out.Revision, nil |
| +} |
| + |
| +func (d *StopInventoryDB) Remote() bool { return d != nil && d.remote != nil } |
diff --git a/internal/hostbox/inventory_expected_test.go b/internal/hostbox/inventory_expected_test.go| new file mode 100644 |
| index 000000000..8f8fdf980 |
| --- /dev/null |
| +++ b/internal/hostbox/inventory_expected_test.go |
| @@ -0,0 +1,50 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPlan14InventoryCarriesCapturedRevision(t *testing.T) { |
| + calls := 0 |
| + _, db := RemoteJournals(func(_ context.Context, q JournalRequest) (JournalResponse, error) { |
| + calls++ |
| + if q.Op != "inventory-write" || q.Revision != 7 || q.RecordKey != "record" || q.RecordETag != "etag" || q.Target.ClaimETag != "claim" { |
| + t.Fatalf("uncaptured inventory request: %+v", q) |
| + } |
| + return JournalResponse{V: 1, ID: q.ID, Revision: 8}, nil |
| + }) |
| + expected := InventoryExpectation{Revision: 7, RecordKey: "record", RecordETag: "etag", Target: hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 1, ClaimETag: "claim"}} |
| + revision, err := db.WriteExpected(t.Context(), StopInventory{Host: "shop.example", Incarnation: "inc", InstallationStamp: 1}, expected) |
| + if err != nil || revision != 8 || calls != 1 { |
| + t.Fatalf("inventory commit: %d %d %v", revision, calls, err) |
| + } |
| +} |
| + |
| +func TestPlan14InventoryIdentityCoversRecordExpectations(t *testing.T) { |
| + q, err := journalPrepare(JournalRequest{Op: "inventory-write", RecordKey: "record", RecordETag: "etag"}, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if q.V != 3 { |
| + t.Fatalf("inventory expectations use protocol %d", q.V) |
| + } |
| + for _, field := range []string{"key", "etag"} { |
| + changed := q |
| + if field == "key" { |
| + changed.RecordKey = "other" |
| + } else { |
| + changed.RecordETag = "other" |
| + } |
| + if validateJournalRequest(changed) == nil { |
| + t.Fatal("record expectation not bound to request identity") |
| + } |
| + } |
| + q.V = 2 |
| + q.ID = journalRequestID(q) |
| + if validateJournalRequest(q) == nil { |
| + t.Fatal("old protocol accepted unhashed record expectations") |
| + } |
| +} |
diff --git a/internal/hostbox/inventory_registration.go b/internal/hostbox/inventory_registration.go| new file mode 100644 |
| index 000000000..f45045503 |
| --- /dev/null |
| +++ b/internal/hostbox/inventory_registration.go |
| @@ -0,0 +1,195 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func (a ReconcileAdapter) CaptureInventory(ctx context.Context, s RecordSelection, c hostguard.Captured, revision uint64) (InventoryExpectation, error) { |
| + obs, err := hostclaim.Read(ctx, a.Reconciler.Claims, s.Host) |
| + if err != nil { |
| + return InventoryExpectation{}, err |
| + } |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return InventoryExpectation{}, ErrChanged |
| + } |
| + h := obs.Claim.Holder |
| + if h.Incarnation != c.Incarnation || h.Stamp != c.Stamp || h.Key != s.Key { |
| + return InventoryExpectation{}, ErrChanged |
| + } |
| + _, etag, err := a.Reconciler.Claims.Get(ctx, s.Key) |
| + if err != nil { |
| + return InventoryExpectation{}, err |
| + } |
| + if etag != s.ETag { |
| + return InventoryExpectation{}, ErrChanged |
| + } |
| + if revision != 0 { |
| + if err := a.inventoryRevision(ctx, s.Host, c, revision); err != nil { |
| + return InventoryExpectation{}, err |
| + } |
| + } |
| + return InventoryExpectation{Revision: revision, RecordKey: s.Key, RecordETag: etag, Target: hostguard.FenceTarget{Host: s.Host, Incarnation: c.Incarnation, Stamp: c.Stamp, Session: h.Session, Box: a.Box, Enrollment: a.Enrollment, ClaimETag: obs.ETag, Op: obs.Claim.Op.ID, Phase: obs.Claim.Op.Phase}}, nil |
| +} |
| +func (a ReconcileAdapter) inventoryRevision(ctx context.Context, host string, c hostguard.Captured, revision uint64) error { |
| + if a.Reconciler.Guard.FenceView == nil { |
| + return ErrUnsupported |
| + } |
| + view, err := a.Reconciler.Guard.FenceView(ctx, host, c) |
| + if err != nil { |
| + return err |
| + } |
| + if view.Schema != 1 || view.Host != host || view.Captured != c || view.Installation.InventoryRevision != revision { |
| + return ErrChanged |
| + } |
| + return nil |
| +} |
| +func (a ReconcileAdapter) RevalidateInventory(ctx context.Context, s RecordSelection, c hostguard.Captured, e InventoryExpectation, revision uint64) error { |
| + fresh, err := a.CaptureInventory(ctx, s, c, revision) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Target != e.Target { |
| + return ErrChanged |
| + } |
| + return nil |
| +} |
| +func (a ReconcileAdapter) prepareRemoteInventory(ctx context.Context, s RecordSelection, c hostguard.Captured) error { |
| + _, revision, err := a.Inventory.Snapshot(ctx, s.Host, c) |
| + if err != nil { |
| + return err |
| + } |
| + if revision != 0 { |
| + return nil |
| + } |
| + var expected InventoryExpectation |
| + err = a.Reconciler.Guard.WithLocal(ctx, s.Host, func() error { var err error; expected, err = a.CaptureInventory(ctx, s, c, 0); return err }) |
| + if err != nil { |
| + return err |
| + } |
| + in := StopInventory{Complete: true, Host: s.Host, Incarnation: c.Incarnation, InstallationStamp: c.Stamp, Box: a.Box, Enrollment: a.Enrollment, DirectoryRevision: a.DirectoryRevision} |
| + revision, err = a.Inventory.WriteExpected(ctx, in, expected) |
| + if err != nil { |
| + return err |
| + } |
| + return a.Reconciler.Guard.WithLocal(ctx, s.Host, func() error { return a.RevalidateInventory(ctx, s, c, expected, revision) }) |
| +} |
| +func (a ReconcileAdapter) installRemote(ctx context.Context, rec provision.ClaimRecord, s RecordSelection, r Reconciler, write func(registry.ClaimOwnership) error) error { |
| + observed, err := hostclaim.Read(ctx, r.Claims, s.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if !observed.Absent { |
| + if err := r.check(ctx, observed, s); err != nil { |
| + return err |
| + } |
| + } |
| + r.CheckLocal = func(context.Context, hostclaim.Observed, RecordSelection) error { return nil } |
| + r.PrepareInventory = a.prepareRemoteInventory |
| + return r.installWithWork(ctx, s, func(work hostwrite.Work) error { |
| + captured, err := r.Guard.Capture(s.Host) |
| + if err != nil { |
| + return err |
| + } |
| + for attempt := 0; attempt < 3; attempt++ { |
| + err = a.registerRemote(ctx, s, captured, work, write) |
| + if !errors.Is(err, ErrChanged) { |
| + return err |
| + } |
| + } |
| + return ErrChanged |
| + }) |
| +} |
| +func (a ReconcileAdapter) registerRemote(ctx context.Context, s RecordSelection, c hostguard.Captured, work hostwrite.Work, write func(registry.ClaimOwnership) error) error { |
| + in, revision, err := a.Inventory.Snapshot(ctx, s.Host, c) |
| + if err != nil { |
| + return err |
| + } |
| + if !in.Complete || in.Host != s.Host || in.Incarnation != c.Incarnation || in.InstallationStamp != c.Stamp || in.Box != a.Box || in.Enrollment != a.Enrollment || in.DirectoryRevision != a.DirectoryRevision { |
| + return ErrTypedInventory |
| + } |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", s.Host, c.Incarnation, c.Stamp, a.Box))) |
| + owner := registry.ClaimOwnership{ClaimIncarnation: c.Incarnation, ClaimStamp: c.Stamp, ClaimOwner: hex.EncodeToString(sum[:])} |
| + index := -1 |
| + for i, e := range in.Entries { |
| + if e.Kind == "route" && e.RelativePath == s.Host { |
| + if index != -1 || e.Ownership != owner.ClaimOwner || e.RowIdentity == "" { |
| + return ErrTypedInventory |
| + } |
| + index = i |
| + owner.ClaimRowID = e.RowIdentity |
| + } |
| + } |
| + if index < 0 { |
| + var id [16]byte |
| + if _, err := rand.Read(id[:]); err != nil { |
| + return err |
| + } |
| + owner.ClaimRowID = hex.EncodeToString(id[:]) |
| + index = len(in.Entries) |
| + in.Entries = append(in.Entries, StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: s.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "intended"}) |
| + var expected InventoryExpectation |
| + err = work.With(ctx, hostwrite.Identity{}, func() error { var err error; expected, err = a.CaptureInventory(ctx, s, c, revision); return err }) |
| + if err != nil { |
| + return err |
| + } |
| + revision, err = a.Inventory.WriteExpected(ctx, in, expected) |
| + if err != nil { |
| + return err |
| + } |
| + if err = work.With(ctx, hostwrite.Identity{}, func() error { return a.RevalidateInventory(ctx, s, c, expected, revision) }); err != nil { |
| + return err |
| + } |
| + } |
| + var expected InventoryExpectation |
| + err = work.With(ctx, hostwrite.Identity{}, func() error { |
| + var err error |
| + expected, err = a.CaptureInventory(ctx, s, c, revision) |
| + if err != nil { |
| + return err |
| + } |
| + row, err := a.Registry.Get(s.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if row == nil && in.Entries[index].Materialization == "materialized" { |
| + return ErrTypedInventory |
| + } |
| + if row != nil && row.ClaimOwnership != owner { |
| + return registry.ErrClaimOwnership |
| + } |
| + if err = write(owner); err != nil { |
| + return err |
| + } |
| + row, err = a.Registry.Get(s.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if row == nil || row.ClaimOwnership != owner { |
| + return registry.ErrClaimOwnership |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if in.Entries[index].Materialization == "materialized" { |
| + return nil |
| + } |
| + in.Entries[index].Materialization = "materialized" |
| + revision, err = a.Inventory.WriteExpected(ctx, in, expected) |
| + if err != nil { |
| + return err |
| + } |
| + return work.With(ctx, hostwrite.Identity{}, func() error { return a.RevalidateInventory(ctx, s, c, expected, revision) }) |
| +} |
diff --git a/internal/hostbox/journalservice.go b/internal/hostbox/journalservice.go| index 22ad04408..3034cc50e 100644 |
| --- a/internal/hostbox/journalservice.go |
| +++ b/internal/hostbox/journalservice.go |
| @@ -50,6 +50,9 @@ func journalPrepare(req JournalRequest, now time.Time) (JournalRequest, error) { |
| return req, err |
| } |
| req.V = 2 |
| + if req.RecordKey != "" || req.RecordETag != "" { |
| + req.V = 3 |
| + } |
| req.Nonce = hex.EncodeToString(nonce[:]) |
| req.IssuedAt = now.UnixNano() |
| req.ID = journalRequestID(req) |
| @@ -61,6 +64,8 @@ func journalPrepare(req JournalRequest, now time.Time) (JournalRequest, error) { |
| // ID is retained by the transport across retries. Root commits the request and |
| // its result in the same FULL-synchronous transaction as the journal change. |
| type JournalRequest struct { |
| + RecordKey string `json:"record_key,omitempty"` |
| + RecordETag string `json:"record_etag,omitempty"` |
| Revision uint64 `json:"inventory_revision"` |
| V int `json:"v"` |
| ID string `json:"request_id"` |
| @@ -204,6 +209,10 @@ func journalRequestV2(q JournalRequest) []byte { |
| return raw |
| } |
| func journalRequestHash(req JournalRequest) [sha256.Size]byte { |
| + if req.V == 3 { |
| + raw, _ := json.Marshal([]any{"carlos-journal-request-v3", string(journalRequestV2(req)), req.RecordKey, req.RecordETag}) |
| + return sha256.Sum256(raw) |
| + } |
| if req.V == 2 { |
| return sha256.Sum256(journalRequestV2(req)) |
| } |
| @@ -219,7 +228,7 @@ func journalRequestDigest(req JournalRequest) string { |
| } |
| func validateJournalRequest(q JournalRequest) error { |
| // The frozen request hash does not carry this root-only response field. |
| - if q.Launch.WritersClosed { |
| + if q.Launch.WritersClosed || (q.V != 3 && (q.RecordKey != "" || q.RecordETag != "")) || (q.V == 3 && (q.Op != "inventory-write" || q.RecordKey == "" || q.RecordETag == "")) { |
| return ErrTypedJournal |
| } |
| id, err := hex.DecodeString(q.ID) |
| @@ -234,7 +243,7 @@ func validateJournalRequest(q JournalRequest) error { |
| return nil |
| default: |
| nonce, err := hex.DecodeString(q.Nonce) |
| - if q.V != 2 || err != nil || len(nonce) != 16 || q.IssuedAt <= 0 { |
| + if (q.V != 2 && q.V != 3) || err != nil || len(nonce) != 16 || q.IssuedAt <= 0 { |
| return ErrTypedJournal |
| } |
| } |
| @@ -377,6 +386,15 @@ func (s *JournalService) Replay(ctx context.Context, peer string, q JournalReque |
| } |
| func (s *JournalService) Execute(ctx context.Context, peer string, q JournalRequest) (JournalResponse, error) { |
| + return s.execute(ctx, peer, q, false) |
| +} |
| +func (s *JournalService) CompletePendingInventory(ctx context.Context, peer string, q JournalRequest) (JournalResponse, error) { |
| + if q.V != 3 || q.Op != "inventory-write" { |
| + return JournalResponse{}, ErrTypedJournal |
| + } |
| + return s.execute(ctx, peer, q, true) |
| +} |
| +func (s *JournalService) execute(ctx context.Context, peer string, q JournalRequest, pendingInventory bool) (JournalResponse, error) { |
| out := JournalResponse{V: 1, ID: q.ID} |
| if err := validateJournalRequest(q); err != nil { |
| return out, err |
| @@ -417,6 +435,7 @@ func (s *JournalService) Execute(ctx context.Context, peer string, q JournalRequ |
| return out, e |
| } |
| if prior.Revision == q.Revision+1 && prior.LastRequest == q.ID { |
| + out.Revision = prior.Revision |
| return out, nil |
| } |
| } |
| @@ -432,7 +451,7 @@ func (s *JournalService) Execute(ctx context.Context, peer string, q JournalRequ |
| // this transaction deletes, so a pruned request can never execute again |
| // whatever the clock does, and a forward clock jump cannot raise it past |
| // real time: it covers only requests that actually committed. |
| - if q.V == 2 { |
| + if (q.V == 2 || q.V == 3) && !pendingInventory { |
| age := now.Sub(time.Unix(0, q.IssuedAt)) |
| if q.IssuedAt <= rejectBefore || age > JournalRequestWindow || age < -JournalRequestWindow { |
| return out, ErrJournalUncertain |
| @@ -474,6 +493,7 @@ func (s *JournalService) Execute(ctx context.Context, peer string, q JournalRequ |
| return out, ErrChanged |
| } |
| err = s.Inventory.WriteStopInventory(ctx, q.Inventory) |
| + out.Revision = prior.Revision + 1 |
| default: |
| return out, ErrUnsupported |
| } |
diff --git a/internal/hostbox/launchjournal.go b/internal/hostbox/launchjournal.go| index 813ace59f..217a9eeaf 100644 |
| --- a/internal/hostbox/launchjournal.go |
| +++ b/internal/hostbox/launchjournal.go |
| @@ -358,3 +358,5 @@ func (j *LaunchJournal) RecordFile(ctx context.Context, t hostguard.FenceTarget, |
| return nil |
| }) |
| } |
| + |
| +func (j *LaunchJournal) Remote() bool { return j != nil && j.remote != nil } |
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go| index 5d5ed5b96..90dc5f678 100644 |
| --- a/internal/hostbox/reconcile.go |
| +++ b/internal/hostbox/reconcile.go |
| @@ -18,13 +18,14 @@ type RecordSelection struct { |
| DryRun bool |
| } |
| type Reconciler struct { |
| - HomeRegion string |
| - Claims release.Store |
| - Guard hostguard.Guard |
| - Local park.Local |
| - Writes hostwrite.Config |
| - Box, Region string |
| - Now func() time.Time |
| + PrepareInventory func(context.Context, RecordSelection, hostguard.Captured) error |
| + HomeRegion string |
| + Claims release.Store |
| + Guard hostguard.Guard |
| + Local park.Local |
| + Writes hostwrite.Config |
| + Box, Region string |
| + Now func() time.Time |
| // CheckRecord directly checks the selected record ETag and ownership acquisition. |
| CheckRecord func(context.Context, RecordSelection) error |
| // CheckLocal includes complete census and journal recovery before initialization. |
| @@ -70,11 +71,18 @@ func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected |
| return r.CheckLocal(ctx, obs, selected) |
| } |
| func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write func() error) error { |
| + return r.installWithWork(ctx, selected, func(work hostwrite.Work) error { return work.With(ctx, hostwrite.Identity{}, write) }) |
| +} |
| +func (r Reconciler) installWithWork(ctx context.Context, selected RecordSelection, write func(hostwrite.Work) error) error { |
| if !r.Writes.Enabled { |
| if selected.DryRun { |
| return nil |
| } |
| - return write() |
| + work, err := r.Writes.Select(ctx, selected.Host, "reconcile") |
| + if err != nil { |
| + return err |
| + } |
| + return write(work) |
| } |
| if write == nil || r.Claims == nil { |
| return ErrUnsupported |
| @@ -112,6 +120,11 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write |
| if err != nil { |
| return err |
| } |
| + if r.PrepareInventory != nil { |
| + if err := r.PrepareInventory(ctx, selected, captured); err != nil { |
| + return err |
| + } |
| + } |
| selector, ok := r.Writes.Selector.(interface { |
| SelectAt(context.Context, string, string, hostguard.Captured) (hostwrite.Work, error) |
| }) |
| @@ -130,5 +143,5 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write |
| if err := local.RecordInventory(ctx, selected.Host, captured, selected.Layout, selected.Paths, check); err != nil { |
| return err |
| } |
| - return work.With(ctx, hostwrite.Identity{}, write) |
| + return write(work) |
| } |
diff --git a/internal/hostbox/reconcile_adapter.go b/internal/hostbox/reconcile_adapter.go| index 3fe755848..a6d5eaf83 100644 |
| --- a/internal/hostbox/reconcile_adapter.go |
| +++ b/internal/hostbox/reconcile_adapter.go |
| @@ -78,6 +78,9 @@ func (a ReconcileAdapter) Install(ctx context.Context, rec provision.ClaimRecord |
| } |
| return nil |
| } |
| + if a.Inventory.remote != nil { |
| + return a.installRemote(ctx, rec, selected, reconciler, write) |
| + } |
| return reconciler.Install(ctx, selected, func() error { |
| captured, err := a.Reconciler.Guard.Capture(rec.Host) |
| if err != nil { |
diff --git a/internal/hostguard/coldhandover.go b/internal/hostguard/coldhandover.go| index df473c2bc..1b3ac00d5 100644 |
| --- a/internal/hostguard/coldhandover.go |
| +++ b/internal/hostguard/coldhandover.go |
| @@ -67,7 +67,13 @@ func (g Guard) CompleteColdHandover(ctx context.Context, barrier FenceTarget, al |
| return err |
| } |
| if alreadyComplete && r.Incarnation == barrier.Incarnation && r.ActiveStamp == barrier.Stamp && r.ActiveFence == nil { |
| - return d.sync(barrier.Host) |
| + if g.Root == nil { |
| + return d.sync(barrier.Host) |
| + } |
| + if err := g.refreshRootInventory(ctx, &r); err != nil { |
| + return err |
| + } |
| + return d.write(r) |
| } |
| if r.ActiveFence == nil || *r.ActiveFence != barrier || r.Incarnation != barrier.Incarnation { |
| return ErrFenceTarget |
| @@ -81,6 +87,9 @@ func (g Guard) CompleteColdHandover(ctx context.Context, barrier FenceTarget, al |
| } |
| r.ActiveFence = nil |
| r.barrier = nil |
| + if err := g.refreshRootInventory(ctx, &r); err != nil { |
| + return err |
| + } |
| return d.write(r) |
| }) |
| } |
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go| index 0c6bfe101..a029793b7 100644 |
| --- a/internal/hostguard/guard.go |
| +++ b/internal/hostguard/guard.go |
| @@ -156,6 +156,9 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f |
| if err != nil { |
| return err |
| } |
| + if r.pendingInstallation != nil { |
| + return ErrFenced |
| + } |
| if r.root && (r.installation.Account != c.Holder.Account || r.installation.App != c.Holder.App) { |
| return ErrRecordIncarnation |
| } |
diff --git a/internal/hostguard/plan14_root_fences_test.go b/internal/hostguard/plan14_root_fences_test.go| index 97b60de5d..eb2144b92 100644 |
| --- a/internal/hostguard/plan14_root_fences_test.go |
| +++ b/internal/hostguard/plan14_root_fences_test.go |
| @@ -4,10 +4,12 @@ package hostguard |
| import ( |
| "context" |
| + "encoding/json" |
| "errors" |
| "os" |
| "path/filepath" |
| "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -16,8 +18,9 @@ import ( |
| type rootTestStore struct { |
| rootstate.Store |
| - dir string |
| - mu sync.Mutex |
| + dir string |
| + mu sync.Mutex |
| + onLock func() |
| } |
| func (s *rootTestStore) Read(n string) ([]byte, error) { return os.ReadFile(filepath.Join(s.dir, n)) } |
| @@ -34,6 +37,9 @@ func (s *rootTestStore) Names() ([]string, error) { |
| } |
| func (s *rootTestStore) Lock(context.Context, string) (func(), error) { |
| s.mu.Lock() |
| + if s.onLock != nil { |
| + s.onLock() |
| + } |
| return s.mu.Unlock, nil |
| } |
| func (s *rootTestStore) Close() error { return nil } |
| @@ -163,3 +169,267 @@ func TestPlan14InventoryRegistrationUnderAgentGuardDeadlocks(t *testing.T) { |
| t.Fatalf("expected the root registration to wait for the agent's held legacy lock: %v, requested=%v", err, requested) |
| } |
| } |
| + |
| +func TestPlan14FenceViewRechecksAfterUnlockedRead(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + root := f.g |
| + root.Root = NewRootAuthority(&rootTestStore{dir: t.TempDir()}) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "console" |
| + in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "console", Record: "record@etag", Box: target.Box, Enrollment: target.Enrollment, Migration: 1} |
| + if err := root.EstablishRoot(t.Context(), in, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(f.g.DataDir, "hostguard", target.Host+".json") |
| + before, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + claimBefore, err := json.Marshal(f.obs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + agent := f.g |
| + fencedBeforeAdmission := false |
| + agent.FenceView = func(ctx context.Context, host string, c Captured) (FenceView, error) { |
| + view, err := root.Root.View(ctx, host, c) |
| + if err != nil { |
| + return FenceView{}, err |
| + } |
| + // Root attempts its fence after the snapshot, before admission. With |
| + // the read inside the legacy lock, it cannot commit until admission |
| + // finishes. Moving only the read outside permits this same-tuple race. |
| + wait, cancel := context.WithTimeout(ctx, 100*time.Millisecond) |
| + defer cancel() |
| + _, err = root.BeginFence(wait, target) |
| + if err == nil { |
| + fencedBeforeAdmission = true |
| + } else if !errors.Is(err, context.DeadlineExceeded) { |
| + return FenceView{}, err |
| + } |
| + return view, nil |
| + } |
| + effects := 0 |
| + err = agent.With(t.Context(), target.Host, captured, allow, func() error { effects++; return nil }) |
| + if fencedBeforeAdmission { |
| + if !errors.Is(err, ErrFenced) || effects != 0 { |
| + t.Errorf("stale root snapshot admitted an already fenced session: err=%v effects=%d", err, effects) |
| + } |
| + } else { |
| + if err != nil || effects != 1 { |
| + t.Fatalf("unfenced admission: err=%v effects=%d", err, effects) |
| + } |
| + if _, err := root.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + view, err := root.Root.View(t.Context(), target.Host, captured) |
| + if err != nil || view.Pending == nil || *view.Pending != target || view.Installation != in { |
| + t.Fatalf("fence changed installation identity: %+v %v", view, err) |
| + } |
| + after, err := os.ReadFile(path) |
| + if err != nil || string(before) != string(after) { |
| + t.Fatalf("fence changed agent installation record: %v", err) |
| + } |
| + claimAfter, err := json.Marshal(f.obs) |
| + if err != nil || string(claimBefore) != string(claimAfter) { |
| + t.Fatalf("fence changed claim observation: %v", err) |
| + } |
| + if err := root.With(t.Context(), target.Host, captured, allow, func() error { t.Fatal("root admitted fenced work"); return nil }); !errors.Is(err, ErrFenced) { |
| + t.Fatalf("root fence check: %v", err) |
| + } |
| +} |
| + |
| +func TestPlan14FenceViewWhileRootWaitsForAgent(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + root := f.g |
| + store := &rootTestStore{dir: t.TempDir()} |
| + root.Root = NewRootAuthority(store) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "console" |
| + in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "console", Record: "record@etag", Box: target.Box, Enrollment: target.Enrollment, Migration: 1} |
| + if err := root.EstablishRoot(t.Context(), in, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + unlock, err := acquire(ctx, f.g.LockDir, target.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { |
| + if unlock != nil { |
| + unlock() |
| + } |
| + }() |
| + locked := make(chan struct{}, 1) |
| + var acquisitions atomic.Int32 |
| + store.onLock = func() { acquisitions.Add(1); locked <- struct{}{} } |
| + done := make(chan error, 1) |
| + go func() { _, err := root.BeginFence(ctx, target); done <- err }() |
| + select { |
| + case <-locked: |
| + case <-ctx.Done(): |
| + t.Fatal(ctx.Err()) |
| + } |
| + viewDone := make(chan error, 1) |
| + go func() { _, err := root.Root.View(ctx, target.Host, captured); viewDone <- err }() |
| + select { |
| + case err := <-viewDone: |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case <-ctx.Done(): |
| + t.Fatal("fence view waited on root holding a lock needed by the agent") |
| + } |
| + if n := acquisitions.Load(); n != 1 { |
| + t.Fatalf("view acquired a root host lock: %d acquisitions", n) |
| + } |
| + unlock() |
| + unlock = nil |
| + select { |
| + case err := <-done: |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case <-ctx.Done(): |
| + t.Fatal("root update did not finish after agent unlocked") |
| + } |
| +} |
| + |
| +func TestPlan14RootInventoryCommitRetainsDenials(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "console" |
| + store := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthority(store) |
| + in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "console", Record: "record", Box: target.Box, Enrollment: target.Enrollment, Migration: 1, InventoryRevision: 1} |
| + effects := 0 |
| + commit := func() error { effects++; return nil } |
| + if err := f.g.CommitRootInstallation(t.Context(), in, allow, commit); err != nil || effects != 1 { |
| + t.Fatalf("initial root inventory: %v %d", err, effects) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + in.InventoryRevision++ |
| + if err := f.g.CommitRootInstallation(t.Context(), in, allow, commit); !errors.Is(err, ErrFenced) || effects != 1 { |
| + t.Fatalf("fenced inventory commit: %v %d", err, effects) |
| + } |
| + view, err := f.g.Root.View(t.Context(), target.Host, captured) |
| + if err != nil || view.Installation.InventoryRevision != 1 || view.Pending == nil || *view.Pending != target { |
| + t.Fatalf("inventory erased fence: %+v %v", view, err) |
| + } |
| +} |
| + |
| +func TestPlan14ColdHandoverRefreshesRootInventory(t *testing.T) { |
| + f, prior, captured := fenceFixture(t) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app" |
| + store := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthorityWithInventory(store, func(_ context.Context, host string, c Captured) (uint64, error) { |
| + if host != prior.Host || c.Stamp != 17 { |
| + return 0, ErrStampMismatch |
| + } |
| + return 2, nil |
| + }) |
| + in := RootInstallation{Host: prior.Host, Incarnation: prior.Incarnation, Stamp: prior.Stamp, Account: "bab", App: "app", Record: "record", Box: prior.Box, Enrollment: prior.Enrollment, Migration: 1, InventoryRevision: 5} |
| + if err := f.g.EstablishRoot(t.Context(), in, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), prior); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Now().UTC() |
| + if err := f.g.CompleteFence(t.Context(), prior, []byte("verified stop"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + next := prior |
| + next.FenceID = "cold-next" |
| + next.Stamp = 17 |
| + next.Session = 17 |
| + f.obs.Claim.Holder.Stamp, f.obs.Claim.Holder.Session = 17, 17 |
| + if err := f.g.BeginColdHandover(t.Context(), prior, next, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(t.Context(), prior.Host, prior.Box, nil); err == nil { |
| + t.Fatal("activation cleared cold barrier") |
| + } |
| + if err := f.g.CompleteColdHandover(t.Context(), next, false, allow, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + view, err := f.g.Root.View(t.Context(), prior.Host, Captured{Incarnation: prior.Incarnation, Stamp: 17}) |
| + if err != nil || view.Installation.InventoryRevision != 2 || view.Barrier != nil { |
| + t.Fatalf("successor snapshot: %+v %v", view, err) |
| + } |
| + effects := 0 |
| + if err := f.g.With(t.Context(), prior.Host, view.Captured, allow, func() error { effects++; return nil }); err != nil || effects != 1 { |
| + t.Fatalf("successor refused: %v", err) |
| + } |
| + if err := f.g.With(t.Context(), prior.Host, captured, allow, func() error { effects++; return nil }); err == nil || effects != 1 { |
| + t.Fatal("predecessor replay admitted") |
| + } |
| + receipt, err := f.g.FenceReceiptFor(t.Context(), prior) |
| + if err != nil || !receipt.EvidenceAt.Equal(at) { |
| + t.Fatalf("old stop evidence lost: %+v %v", receipt, err) |
| + } |
| +} |
| + |
| +func TestPlan14InstallationCommitRecovery(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + store := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthority(store) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "console" |
| + in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "console", Record: "record", Box: target.Box, Enrollment: target.Enrollment, Migration: 1, InventoryRevision: 1} |
| + crash := errors.New("simulated crash after inventory commit") |
| + intent := []byte("typed inventory request") |
| + err := f.g.CommitRootInventory(t.Context(), in, intent, allow, nil, func(RootInstallation) error { return crash }) |
| + if !errors.Is(err, crash) { |
| + t.Fatal(err) |
| + } |
| + f.g.Root = NewRootAuthority(store) |
| + if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { t.Fatal("pending installation admitted"); return nil }); err == nil { |
| + t.Fatal("admitted") |
| + } |
| + pending, err := f.g.Root.PendingInstallations() |
| + if err != nil || len(pending) != 1 || string(pending[0]) != string(intent) { |
| + t.Fatal(pending, err) |
| + } |
| + if _, err = f.g.Root.View(t.Context(), target.Host, captured); err == nil { |
| + t.Fatal("pending view available") |
| + } |
| + if err = f.g.CommitRootInventory(t.Context(), in, []byte("other"), allow, nil, func(RootInstallation) error { t.Fatal("changed intent committed"); return nil }); err == nil { |
| + t.Fatal("changed intent accepted") |
| + } |
| + if err = f.g.CommitRootInventory(t.Context(), in, intent, allow, nil, func(RootInstallation) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = f.g.With(t.Context(), target.Host, captured, allow, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + pending, err = f.g.Root.PendingInstallations() |
| + if err != nil || len(pending) != 0 { |
| + t.Fatal(pending, err) |
| + } |
| +} |
| + |
| +func TestPlan14RootNoInstallationNeedsExplicitEmptyState(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app" |
| + store := &rootTestStore{dir: t.TempDir()} |
| + f.g.Root = NewRootAuthority(store) |
| + effects := 0 |
| + effect := func() error { effects++; return nil } |
| + if err := f.g.WithNoInstallation(t.Context(), target.Host, captured.Incarnation, allow, effect); err == nil || effects != 0 { |
| + t.Fatal("missing root record authorized absence", err) |
| + } |
| + in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "app", Record: "record", Box: target.Box, Enrollment: target.Enrollment, Migration: 1, Empty: true} |
| + if err := f.g.EstablishRoot(t.Context(), in, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.WithNoInstallation(t.Context(), target.Host, captured.Incarnation, allow, effect); err != nil || effects != 1 { |
| + t.Fatal("explicit empty refused", err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.WithNoInstallation(t.Context(), target.Host, captured.Incarnation, allow, effect); err == nil || effects != 1 { |
| + t.Fatal("empty bypassed fence", err) |
| + } |
| +} |
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go| index 0254e13d9..9524e9a9b 100644 |
| --- a/internal/hostguard/record.go |
| +++ b/internal/hostguard/record.go |
| @@ -26,17 +26,19 @@ type Captured struct { |
| } |
| type record struct { |
| - root bool |
| - installation RootInstallation |
| - rootRetired []Captured |
| - barrier *FenceTarget |
| - V int `json:"v"` |
| - Host string `json:"host"` |
| - Incarnation string `json:"incarnation"` |
| - ActiveStamp uint64 `json:"active_stamp"` |
| - RetiredStamps []uint64 `json:"retired_stamps"` |
| - ActiveFence *FenceTarget `json:"active_fence,omitempty"` |
| - FenceJournal []FenceReceipt `json:"fence_journal,omitempty"` |
| + root bool |
| + installation RootInstallation |
| + rootRetired []Captured |
| + pendingInventory []byte |
| + pendingInstallation *RootInstallation |
| + barrier *FenceTarget |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + ActiveStamp uint64 `json:"active_stamp"` |
| + RetiredStamps []uint64 `json:"retired_stamps"` |
| + ActiveFence *FenceTarget `json:"active_fence,omitempty"` |
| + FenceJournal []FenceReceipt `json:"fence_journal,omitempty"` |
| } |
| func (r record) validate(host string) error { |
diff --git a/internal/hostguard/retirement.go b/internal/hostguard/retirement.go| index 2c5403ad6..a8b62b324 100644 |
| --- a/internal/hostguard/retirement.go |
| +++ b/internal/hostguard/retirement.go |
| @@ -80,6 +80,20 @@ func (g Guard) WithNoInstallation(ctx context.Context, host, incarnation string, |
| if err := check(c); err != nil { |
| return err |
| } |
| + if g.Root != nil { |
| + captured := Captured{Incarnation: incarnation, Stamp: c.Holder.Stamp} |
| + view, err := g.Root.View(ctx, host, captured) |
| + if err != nil { |
| + return err |
| + } |
| + if err = view.admit(host, captured, c.Holder.Session); err != nil { |
| + return err |
| + } |
| + if !view.Installation.Empty || view.Installation.Account != c.Holder.Account || view.Installation.App != c.Holder.App { |
| + return ErrStampMismatch |
| + } |
| + return fn() |
| + } |
| _, err = d.read(host) |
| if !errors.Is(err, ErrNoRecord) { |
| if err != nil { |
diff --git a/internal/hostguard/root.go b/internal/hostguard/root.go| index 0597fd949..572ba71de 100644 |
| --- a/internal/hostguard/root.go |
| +++ b/internal/hostguard/root.go |
| @@ -7,25 +7,32 @@ import ( |
| "errors" |
| "io" |
| "os" |
| + "strings" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| type RootInstallation struct { |
| + Empty bool |
| Host, Incarnation, Account, App, Record, Box, Enrollment string |
| - Stamp, Migration uint64 |
| + Stamp, Migration, InventoryRevision uint64 |
| +} |
| +type RootAuthority struct { |
| + store rootstate.Store |
| + inventoryRevision func(context.Context, string, Captured) (uint64, error) |
| } |
| -type RootAuthority struct{ store rootstate.Store } |
| func NewRootAuthority(store rootstate.Store) *RootAuthority { return &RootAuthority{store: store} } |
| type rootRecord struct { |
| - Schema int |
| - Installation RootInstallation |
| - Retired []Captured |
| - Barrier *FenceTarget |
| - State record |
| + Schema int |
| + Installation RootInstallation |
| + Retired []Captured |
| + Barrier *FenceTarget |
| + PendingInventory []byte `json:",omitempty"` |
| + PendingInstallation *RootInstallation `json:",omitempty"` |
| + State record |
| } |
| func (a *RootAuthority) read(host string) (record, error) { |
| @@ -50,6 +57,11 @@ func (a *RootAuthority) read(host string) (record, error) { |
| r.installation = saved.Installation |
| r.rootRetired = saved.Retired |
| r.barrier = saved.Barrier |
| + r.pendingInventory = saved.PendingInventory |
| + r.pendingInstallation = saved.PendingInstallation |
| + if (len(r.pendingInventory) > 0) != (r.pendingInstallation != nil) { |
| + return record{}, ErrMalformedRecord |
| + } |
| if err := r.validate(host); err != nil { |
| return record{}, err |
| } |
| @@ -60,7 +72,7 @@ func (a *RootAuthority) write(r record) error { |
| return ErrMalformedRecord |
| } |
| r.installation.Host, r.installation.Incarnation, r.installation.Stamp = r.Host, r.Incarnation, r.ActiveStamp |
| - b, err := json.Marshal(rootRecord{Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, State: r}) |
| + b, err := json.Marshal(rootRecord{Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, PendingInventory: r.pendingInventory, PendingInstallation: r.pendingInstallation, State: r}) |
| if err != nil { |
| return err |
| } |
| @@ -77,7 +89,17 @@ func (g Guard) Capture(host string) (Captured, error) { |
| return Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}, nil |
| } |
| func (g Guard) EstablishRoot(ctx context.Context, in RootInstallation, check func(*hostclaim.Claim) error) error { |
| - if g.Root == nil || check == nil || in.Host == "" || in.Incarnation == "" || in.Stamp == 0 || in.Migration == 0 || in.Account == "" || in.App == "" || in.Record == "" || in.Box == "" || in.Enrollment == "" { |
| + return g.CommitRootInstallation(ctx, in, check, func() error { return nil }) |
| +} |
| + |
| +func (g Guard) CommitRootInstallation(ctx context.Context, in RootInstallation, check func(*hostclaim.Claim) error, commit func() error) error { |
| + if commit == nil { |
| + return ErrInvalidInput |
| + } |
| + return g.CommitRootInventory(ctx, in, nil, check, nil, func(RootInstallation) error { return commit() }) |
| +} |
| +func (g Guard) CommitRootInventory(ctx context.Context, in RootInstallation, intent []byte, check func(*hostclaim.Claim) error, validate func(RootInstallation) error, commit func(RootInstallation) error) error { |
| + if g.Root == nil || check == nil || commit == nil || in.Host == "" || in.Incarnation == "" || in.Stamp == 0 || in.Migration == 0 || in.Account == "" || in.App == "" || in.Record == "" || in.Box == "" || in.Enrollment == "" { |
| return ErrInvalidInput |
| } |
| return g.locked(ctx, in.Host, func(d *recordDir) error { |
| @@ -104,6 +126,25 @@ func (g Guard) EstablishRoot(ctx context.Context, in RootInstallation, check fun |
| return ErrRetiredStamp |
| } |
| } |
| + if r.Incarnation == in.Incarnation && r.ActiveStamp == in.Stamp { |
| + prior := r.installation |
| + if validate != nil { |
| + if err := validate(prior); err != nil { |
| + return err |
| + } |
| + } |
| + if prior.Account != in.Account || prior.App != in.App || prior.Record != in.Record || prior.Box != in.Box || prior.Enrollment != in.Enrollment || prior.Migration != in.Migration || prior.InventoryRevision > in.InventoryRevision { |
| + return ErrRecordIncarnation |
| + } |
| + } |
| + if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, want) { |
| + return ErrFenced |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target.matchesSession(c, want) { |
| + return ErrFenced |
| + } |
| + } |
| if r.barrier != nil { |
| return ErrFenced |
| } |
| @@ -114,7 +155,72 @@ func (g Guard) EstablishRoot(ctx context.Context, in RootInstallation, check fun |
| r.retire() |
| r.RetiredStamps = nil |
| } |
| + if r.pendingInstallation != nil && (*r.pendingInstallation != in || !bytes.Equal(r.pendingInventory, intent)) { |
| + return ErrFenced |
| + } |
| + prior := r.installation |
| + if validate != nil { |
| + if err := validate(prior); err != nil { |
| + return err |
| + } |
| + } |
| + if len(intent) > 0 { |
| + r.pendingInventory = append([]byte(nil), intent...) |
| + pending := in |
| + r.pendingInstallation = &pending |
| + if r.ActiveStamp == 0 { |
| + r.ActiveStamp = in.Stamp |
| + r.installation = in |
| + r.installation.InventoryRevision = 0 |
| + } |
| + if err := d.write(r); err != nil { |
| + return err |
| + } |
| + } |
| + if err := commit(prior); err != nil { |
| + return err |
| + } |
| + r.pendingInventory, r.pendingInstallation = nil, nil |
| r.Incarnation, r.ActiveStamp, r.installation = in.Incarnation, in.Stamp, in |
| return d.write(r) |
| }) |
| } |
| + |
| +func NewRootAuthorityWithInventory(store rootstate.Store, revision func(context.Context, string, Captured) (uint64, error)) *RootAuthority { |
| + return &RootAuthority{store: store, inventoryRevision: revision} |
| +} |
| +func (g Guard) refreshRootInventory(ctx context.Context, r *record) error { |
| + if g.Root == nil || g.Root.inventoryRevision == nil { |
| + return nil |
| + } |
| + revision, err := g.Root.inventoryRevision(ctx, r.Host, Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}) |
| + if err != nil { |
| + return err |
| + } |
| + if revision == 0 { |
| + return ErrNoRecord |
| + } |
| + r.installation.InventoryRevision = revision |
| + return nil |
| +} |
| + |
| +func (a *RootAuthority) PendingInstallations() ([][]byte, error) { |
| + names, err := a.store.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out [][]byte |
| + for _, name := range names { |
| + if !strings.HasSuffix(name, ".json") { |
| + continue |
| + } |
| + r, err := a.read(strings.TrimSuffix(name, ".json")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if r.pendingInstallation != nil { |
| + out = append(out, append([]byte(nil), r.pendingInventory...)) |
| + } |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/hostguard/rootview.go b/internal/hostguard/rootview.go| index de1c027ef..6deb07b0f 100644 |
| --- a/internal/hostguard/rootview.go |
| +++ b/internal/hostguard/rootview.go |
| @@ -22,6 +22,9 @@ func (a *RootAuthority) View(ctx context.Context, host string, captured Captured |
| if err != nil { |
| return FenceView{}, err |
| } |
| + if r.pendingInstallation != nil { |
| + return FenceView{}, ErrFenced |
| + } |
| known := r.Incarnation == captured.Incarnation && r.ActiveStamp == captured.Stamp && captured.Stamp != 0 |
| for _, old := range r.rootRetired { |
| if old == captured { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 18846db42..9b993a90f 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -472,7 +472,16 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| - {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "6847b909af286f4174197073a630c835edfeb1bd3aef864b2d19d36ae4dbb5c0"}, |
| + {"internal/host/rootinventory.go", "ClaimRootHTTP.commitRootInventoryPending", "key-site", "key:RecordKey", "01d68d11920d03f181586c30f00dbfc63f3db6f25f91619646f3e67e10a54cdd"}, |
| + {"internal/host/rootrecord_claim.go", "rootClaimRecord", "key-site", "key:RecordKey,key:SidecarRecordKey", "e7f73adcaa45e4dfefc5743f43e9ab83567ac95a74ae0557e789478a58e64da4"}, |
| + {"internal/hostbox/inventory_expected.go", "StopInventoryDB.WriteExpected", "key-site", "key:RecordKey", "8c2824998dcb510ed54c08a74846352c7918fe1ba2d7ffb3dd05596ca81b329b"}, |
| + {"internal/hostbox/inventory_expected.go", "type InventoryExpectation", "key-site", "key:RecordKey", "c9d359aac6c5e352f31acfbd66548e09be10a5e621b5bf15da61b4ad3fd9feff"}, |
| + {"internal/hostbox/inventory_registration.go", "ReconcileAdapter.CaptureInventory", "key-site", "key:RecordKey", "75b520c9ebd7816e4e0ce9fa0d767c35282f745055ff923cef54f35be109cac5"}, |
| + {"internal/hostbox/journalservice.go", "journalPrepare", "key-site", "key:RecordKey", "c41aa5bc3cdc1eaca61d8cd65ddaab5a93863378bc8553f2b4a02bcc8df55cf4"}, |
| + {"internal/hostbox/journalservice.go", "journalRequestHash", "key-site", "key:RecordKey", "8c07978658bb060c28ad88477dcbb10d8591ef194cac283cb54eb6a4e833ac76"}, |
| + {"internal/hostbox/journalservice.go", "type JournalRequest", "key-site", "key:RecordKey", "14baf4c57a342a8b2dadaa95c965de4650ee352183de3a8c56768f47642ff5ab"}, |
| + {"internal/hostbox/journalservice.go", "validateJournalRequest", "key-site", "key:RecordKey", "724be9634772f00bf0d9da2c39c8049763c9aad6f3075c10f31c97ab69c15103"}, |
| + {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "4896c3a5c903e6a99386aaf089e36a0dc9616aaa9f98c9e5720c5fd7467141bc"}, |
| {"internal/host/proofbinding.go", "type proofBinding", "key-site", "key:RecordKey", "b5455029fb4bb12ca54c0c1f2594fb1633a7717e7796a8ba5ec37794954f8e26"}, |
| {"internal/host/rootroute.go", "proofBindingSource.claimIdentity", "key-site", "key:RecordKey", "ad6e0fc3f2cd360473a5d0a2267d2dc7944b45d80947613f791db8d573f8fee6"}, |
| {"internal/provision/rootrecord.go", "FindRecordByHost", "key-site", "key:ParseRecordKey,lit:instances", "10c8254cf896be034334d634151d169760a909ac1ba4c1fc334785e86e4b84d4"}, |
| @@ -697,7 +706,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/genselect/select.go", "requestLocation", "key-site", "lit:instances", "12ef5b8f94be7575ec7ab5f169736320ee6938440387fe5ea99b96f2a528cca0"}, |
| {"internal/host/bootstrap.go", "const tmpfilesConf", "key-site", "lit:instances", "71d83f240cb1c3ab6f9dd2954b1f3e93b90133362ec12193746f60e75aeced7d"}, |
| {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "36ff18685872be8adfbf02d05676990bf9097c0fcc9695d3cae2f905ec59665c"}, |
| - {"internal/host/claimlaunch_production.go", "claimLaunchFor", "key-site", "lit:instances", "2b56f3c99b28fe7c2def2eb963db5de76f5ee69ea8834c84124c74801a214034"}, |
| + {"internal/host/claimlaunch_production.go", "claimLaunchFor", "key-site", "lit:instances", "315c1a49a1f29cbb8e9a8ce52dd2e45c8c691c54569dbaec90487fb05278e8c8"}, |
| {"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"}, |
| {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"}, |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
diff --git a/internal/provision/testdata/plan14-root-reads.tsv b/internal/provision/testdata/plan14-root-reads.tsv| index a4702fdbb..2e2102b6a 100644 |
| --- a/internal/provision/testdata/plan14-root-reads.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads.tsv |
| @@ -10,7 +10,7 @@ |
| @coverage infra/modules/region-remote-host b6aafdcf20cf9b3718aba933d68ea8b010a65aa9fbc95466aa0b1879518668f2 |
| @coverage infra/modules/scanner-host 9a6676a7b23210c3fcd09db0112934ef25d83f5285b0b37df90641b1acec1cff |
| @coverage infra/modules/scanner-host/files ade16019eab3cc2b79e106c1f0b6557654ef9a852ead0f3b11f0505071cbbe03 |
| -@coverage internal/activator e436d85cde6ac8e62e16bdb47ea98a561389979b4fa89de828c576ead68e18d7 |
| +@coverage internal/activator c4f350352205f7c1117f17fc38fe616df5eeda1831ff2b700b4334694699daab |
| @coverage internal/agentsock c9296c72cfa80c882fc6130f8bd92fd89b1c12e8201ba1ce5d41d02309de4b22 |
| @coverage internal/alerting 03853352406e9e2b94758da45ff3db20dd81e4552b884da13ef2141a725bd828 |
| @coverage internal/analytics 748e81b163ff36bf7feb180b14167106b74d6bc14b4618f57ff20358190d55f5 |
| @@ -51,17 +51,17 @@ |
| @coverage internal/geo 5886c699c4902a1162ee9640471b8d5e3d2fc18f1dd0d43b70b0f8a459d66b02 |
| @coverage internal/geo/data 27dcfc47493fe7ae61cffa81a700a9ac09e8253fdc88e20da486cc651805af68 |
| @coverage internal/geoip 14994dadea58daebdb9772d8591e916353803fb495de7443a4167682c84cbd37 |
| -@coverage internal/host ce0e6ccf69d5650d42cc5d892247d23e8bd58e81f9db6928d4e8ed6ca8821a72 |
| +@coverage internal/host 13ffdfcafd7b4c7140709742d2ec375e48e4664909fc2cdcb758b3f44f605907 |
| @coverage internal/host/units d45d8d20dc5c3fc7d00e6106d2a0f3475ef04c6cd988fa0b0717fa04b0dd119f |
| @coverage internal/hostactors e5e2e7daed861dba7b57151a6ba023d6e545dfc53d8c6f894d3217cf320bcaf7 |
| -@coverage internal/hostbox 1d39f4bed60bb3e36ec7d9e9420f9caa8e183ead87b7a2177d496756a7809c9e |
| +@coverage internal/hostbox d594aab417712986e964c171629e95c4357b8fdb29ad8f6fe9d971a4de8f4b75 |
| @coverage internal/hostclaim 242add93e1bc7c1d409710db0b0bbb4cccb3dd0ee85e89f217549f1aacfb41ce |
| @coverage internal/hostevidence d85c7894b2c959aaac24176846e0a5b6a28861003d1207d830827fd90b6c4d78 |
| @coverage internal/hostexec 94f7eae800d6f985552854757c6f011501c17696eeb564af1c15e2dc145d8c73 |
| @coverage internal/hostfence 2705d961a8ffd650e1096c9a87085eef267c2de9c223c1993f249b7711a6c2b2 |
| @coverage internal/hostfiles 08ab5139a8b38d1baee773a7a1056e0b6fb9fbb4e3b5cbb2be0e968cc8bea86e |
| @coverage internal/hostfloor 5bf88a24c5671c612befb8bd1f8b719aa2037e812a34488853b906a95d1650c7 |
| -@coverage internal/hostguard 229f8f4d704e84ae611f7b655f3258354e5b636d449c7cb7841962f5f14f487f |
| +@coverage internal/hostguard 9c9ce197927f5ed7f2decd6625e83eed5b1d45e668b178b1c840d9fd9c249394 |
| @coverage internal/hostops 36b1499a44f3367ebbcc4cb4f4129978af3cc1a6e2b9e27e55ae18f17afec25c |
| @coverage internal/hostops/fencerelease b8cf08bdb7dfdc622f4f91d089570e0994141e03beef68a070202d985c423513 |
| @coverage internal/hostops/internal/fencerelease 36ec37feda681a0caf19d6b9452f27eb6a45fd8323154920c8fd29fafbac68ca |
| @@ -422,10 +422,10 @@ internal/activator/recoveryprobe.go recoveryHoldVerdict legacy accepted-legacy a |
| internal/activator/replicakey.go ExecBackend.instanceEnvLayer legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@149 d6fe490310b26c25d8a713a44c47f7c1d4b441db7112052d747c41851f2550bb |
| internal/activator/replicakey.go ExecBackend.replicaKeyGate legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Host@1004,registry:Host@349,registry:Host@415,registry:Host@491,registry:Host@553,registry:Host@615,registry:Host@679,registry:Replica@135,whole:Route@1540 10771f4ce4686d6be04f623c802bb99c8bd64a5dde0bac50faad200f846acaea |
| internal/activator/rolelaunch_linux.go RoleLaunch.RoleCgroup legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@267,file:ReadFile@1288,file:Stat@961 03f994fc611176db75091fd197ef33df5d36300aedfdab7b7fc9d9a69e6d189c |
| -internal/activator/rolelaunch_linux.go RoleLaunch.prepareRoleReservation legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Fstat@1281 e67944a6e36f50616f7f0ccb33278819c09d523a065a328f43f03dfcde4cc988 |
| +internal/activator/rolelaunch_linux.go RoleLaunch.prepareRoleReservation authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Fstat@1303 98b6a20b75988424b6aac40038310c898f805339208be5c15c0719afc48d055f |
| internal/activator/rolelaunch_linux.go roleProcessStart legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:ReadFile@62 4e531f73ee7b0a21c9a5815f0080e2e0da3614b4e6109919e5a968801eec2cff |
| internal/activator/rolelaunch_root_linux.go RoleLaunch.PeerInRole legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@678,file:ReadFile@1266,file:Stat@1041 5dda814082177327d991ac949755f753b4a784f82e07f441c0ffbb390a09389c |
| -internal/activator/rolelaunch_root_linux.go RoleLaunch.startIsolated legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Fstat@1843 af92ef3e57250bea6b0a8bd27adc1ce2b1a237dfc03cde6c253582a201877465 |
| +internal/activator/rolelaunch_root_linux.go RoleLaunch.startIsolated authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Fstat@1956 3cc027068ba3bc3e15f4c39a82ffae9cbc4647c42d584ce20ed5bee336fb822a |
| internal/activator/servingretry.go ServingRetries.Wrap legacy accepted-legacy accepted-legacy;rev3=J1,J4;basis=route-and-input file:Prepare@128,file:Prepare@3924,file:Prepare@4054,file:Read@2487,file:Read@3130,file:Read@4653,file:Read@5618,file:Read@6283,whole:Route@1029,whole:Route@1356,whole:Route@1937,whole:Route@4072,whole:Route@5020,whole:Route@6149,whole:Route@771 81bdd6508e923164fb63aacb5a98c3f2cb4a6c846c5bbb83c014b6d47a01d6de |
| internal/activator/servingretry.go ServingRetries.settle legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@202,file:Read@724 79ab057db1358a6916477b1a472437abd8693ae6bcd2c1a7fc76bdd388059df5 |
| internal/activator/staged.go verifyStaged legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Lstat@252,file:Open@409 bc8aab8c06b2e28330ff81094dbd2066d632e8abaf678fb29a82738834ee41fe |
| @@ -1075,7 +1075,7 @@ internal/host/claimbackfill.go newClaimBackfill authority C,G,J4 root-input;kept |
| internal/host/claimcensus.go claimUnitCensus authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@569 82ed33ee9375b7d41d68637cedece421876874f535ebcf801b812f9e3b1bd10e |
| internal/host/claimcleanup_identity.go claimCleanupEnrollment.configureSigner authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@268 2a4b3bd51f544a5bdd11033389f9d6585785567555d3c12695e335799c756cd6 |
| internal/host/claimcleanup_identity.go verifyClaimCleanupEnrollment authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@173 7a48c65538d68dd362442152e099d8542890ba3241afbbbff01a9c153379ca14 |
| -internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,G,J1,J4 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:Open@4480,file:Read@4059,file:Read@8762,file:ReadFile@3603,registry:Host@8789,whole:Route@8877 3505005d000744ff292e7dd70cf4323a5837793d9c00b0a08f52e4997cbfcc62 |
| +internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@4059,file:Read@8922,file:ReadFile@3603,registry:Host@8949,whole:Route@9037 b47c334a3c683920f237333dba2bbd927137d5ff4a8bfd0ad6320f0d21d2a9a2 |
| internal/host/claimconstruction.go claimConstructionProcessStart authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@68 0681ee9abf64de4aca4b4ba035e1732ed2560ca41564eea6cb854a76bee99f45 |
| internal/host/claimconstruction.go refusingRootWake authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Prepare@641,file:Read@123,file:Read@202 20f7779f6e5cb16b383707ff2c16f3ca0318673e48d20245aed65bfba52ba453 |
| internal/host/claimhandover_crossbox.go claimCrossBoxHandover.After authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Exec@2009,file:Exec@716 a0fb9c27f5be5fce8204611f6421522529754474cf52f980582f7afab141d3e2 |
| @@ -1095,7 +1095,7 @@ internal/host/claimjournal_migration_linux.go quiesceLegacyClaimJournals authori |
| internal/host/claimjournal_migration_linux.go recoverLegacyClaimEdgeMask authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@215,file:ReadFile@77 4c7bc64a8133109837f42d0a7ffc687e6f6b357b3eaf0951bf370a49984d2a21 |
| internal/host/claimjournal_migration_linux.go var claimSystemctl authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:CommandContext@89 b86a40d4a1d0bcd00d410afe2aac4d2de0c409dffb50b3fc8313cf65faef9b76 |
| internal/host/claimjournal_proof.go legacyJournalProof authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@817 c5a5935a1a60cc5ab6a0a2754e8f1d5f3c3cfb9d10812062c88826c32633cc9a |
| -internal/host/claimjournal_transport.go ClaimRootHTTP.serveJournal authority C,G,J1,J4 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:Read@2896,whole:Route@4050 735c1af3378975ac37c45af29f67a21e409fff8132c0b0a0e2f9f60a6fe7ab4c |
| +internal/host/claimjournal_transport.go ClaimRootHTTP.serveJournal authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@3192,whole:Route@4346 a0a69ae74dc2ff7b9242af03f85c434099ce9ac7b4372b00766cb149c7b25ea5 |
| internal/host/claimlaunch_execpermit.go ClaimLaunchRoot.execPermit authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@157 230b6384c7630684301d13c193b7326ae2506b38bd37f0b41edaa1ba22a89d8d |
| internal/host/claimlaunch_kernel_linux.go claimLaunchProcessIdentity authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@94 fffe76e36b957979be55a4a713e2d7caefa67d807d91b33f7f579971f7e0a781 |
| internal/host/claimlaunch_kernel_linux.go linuxClaimLaunchKernel.Observe authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Lstat@430,file:Stat@1515,file:Stat@1611,file:Stat@1710 8b495df60193fd107461f8b62b7f08824635865d962a8e87aea7452d3678c128 |
| @@ -1104,7 +1104,7 @@ internal/host/claimlaunch_kernel_linux.go linuxClaimLaunchKernel.VerifyDigest au |
| internal/host/claimlaunch_kernel_linux.go linuxClaimLaunchKernel.member authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadFile@83 7dd1b95b872be0ca4e34b46b7fb44da144b229c63ba1c4e9c9b4baec650c2ae8 |
| internal/host/claimlaunch_kernel_linux.go linuxClaimLaunchKernel.tenant authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:ReadDir@145,file:ReadFile@274 deb213b7734b39951b20add506b59c302d03beb79e2bc555472b5c2a2d5e1728 |
| internal/host/claimlaunch_observations.go claimUnitStopKernel.VerifyWritersStopped authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@363 5201a4976dde028154d0c53d6918c7525ae49e6f435f2e5579be9a1b93c4124c |
| -internal/host/claimlaunch_production.go claimLaunchFor authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only data-literal@414,file:CommandContext@605 2b56f3c99b28fe7c2def2eb963db5de76f5ee69ea8834c84124c74801a214034 |
| +internal/host/claimlaunch_production.go claimLaunchFor authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering data-literal@414,file:CommandContext@605 315c1a49a1f29cbb8e9a8ce52dd2e45c8c691c54569dbaec90487fb05278e8c8 |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.launchLocked authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@2311,file:Read@2470,file:Read@678 6dbaa00f1def80584e3839acfc943b127b83f7d05645029e94db79adf599634f |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.launchRecord authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@121 8b69f45e05a4ba250626baf7919702071c5ae4d1153355aca2d326bcace900d3 |
| internal/host/claimlaunch_root.go ClaimLaunchRoot.stop authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@400 1e1d9bb9b5903b5c23826ae980657d55fcf7c667ac9e121668ca176953c4a2b6 |
| @@ -1153,8 +1153,8 @@ internal/host/claimretire_local.go claimArtifactRetirement.Retire authority C,G, |
| internal/host/claimretire_transport.go ClaimRootHTTP.retire authority C,G,J1 root-route;kept=C,G;scope=protected-decision-only registry:ClaimOwnership@1081,registry:Host@1062,registry:Host@792,whole:ClaimOwnership@1085,whole:ClaimOwnership@894,whole:Route@1745,whole:Route@854 ca0192180f63ffee816fb7689886a692fef1da95beab2108df8c0d50f9c0d93b |
| internal/host/claimretire_transport.go retirementAcknowledgement authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Read@1184 5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416 |
| internal/host/claimroleassembly.go ClaimRoleAssembly.PreflightLaunch authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only data-literal@200 d646fe2e2006222a6e4540e68f3b59c2cf9e3f930955c84e7dd631bb220fc307 |
| -internal/host/claimroleassembly_linux.go ClaimRoleAssembly.Launch authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Command@5036,file:EvalSymlinks@4585,file:Fstat@3478,file:Lstat@2269,file:OpenFile@3073,file:OpenFile@3260,file:Read@711 80fd24253e2e3d438f8d0482176a762f1d4842b495a72977bf2ce9477ece6d9d |
| -internal/host/claimroleassembly_linux.go ClaimRoleAssembly.PrepareState authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Fstatat@2028,file:Fstatat@3115,file:Open@1512,file:Openat@1799 a6c5b050c6835e32fb9185dae174fc6bc7c25fd8fd025f12a42364781fa9959c |
| +internal/host/claimroleassembly_linux.go ClaimRoleAssembly.Launch authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Command@5874,file:EvalSymlinks@5423,file:Fstat@3506,file:Lstat@2269,file:OpenFile@3096,file:OpenFile@3283,file:Read@711 dba5b3cddcae20279d207770a24975abcbd42d476ae4fc75024d973594ac0e5b |
| +internal/host/claimroleassembly_linux.go ClaimRoleAssembly.PrepareState authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Fstatat@2113,file:Fstatat@3200,file:Open@1597,file:Openat@1884 c2e5c097df3037882e05a55cf28163bf40770b8334245be8ffbbe782f89f93f3 |
| internal/host/claimroleassembly_linux.go verifiedClaimExecutable authority C,G,J4 root-input;kept=C,G;scope=protected-decision-only file:Fstat@1122,file:Fstat@579,file:OpenFile@376,file:Read@949 6178b861dc61b13474d0277c1255299b412f434fccf4a2b56f802f3de62ceffc |
| internal/host/claimroleassembly_other.go ClaimRoleAssembly.Launch authority C,G,J1 root-route;kept=C,G;scope=protected-decision-only candidate:Observed@50 56a02a9a72d51bd5ce67d2d2c5775a98e0a8254d9d24ef3173d6334289dfe882 |
| internal/host/claimroleassembly_other.go ClaimRoleAssembly.Prelaunch authority C,G,J1 root-route;kept=C,G;scope=protected-decision-only candidate:Observed@53 b87076a49aa8c34c44dee38d16532d0856dcee8a6dce01d7fe97a2ce3ee5e0f3 |
| @@ -1598,12 +1598,12 @@ internal/hostbox/journalmigration_linux.go lockJournalMigration authority C,J2,J |
| internal/hostbox/journalmigration_linux.go readLegacyJournals authority C,J2,J4 root-input file:Open@1047,file:QueryContext@2719,file:QueryContext@3464,file:QueryRowContext@1586,file:QueryRowContext@2359 972898fd9c0f9634d628f1021754201bd9e9406c6c6191aa8a23971324ad94d4 |
| internal/hostbox/journalnamespace_linux.go prepareJournalDirectory authority C,J2,J4 root-input file:Fstat@1026,file:Fstat@436,file:Fstatat@1357,file:Open@177,file:Openat@585,file:Openat@843,file:ReadDir@1242 b3ded41f0522ce5ed56c0f70e72a8c39801a3b0614333ea069836765520b9991 |
| internal/hostbox/journalpath.go checkJournalPath authority C,J2,J4 root-input file:Lstat@113 9d3d1f12d3be28d9cf596bd90d0686143c9e97c6351a90bcbeb451acfb8d803c |
| -internal/hostbox/journalservice.go JournalService.Execute authority C,J2,J4 root-input file:Create@2488,file:QueryRowContext@1536,file:QueryRowContext@557 0648996d36492e326a93f8f39e460f03940ea64a8015a9e598776929753ee15b |
| +internal/hostbox/journalservice.go JournalService.Execute authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering a5622243ef57969e005b807ace595685b4c193b2010c479be1ada735ea55efc7 |
| internal/hostbox/journalservice.go JournalService.Replay authority C,J2,J4 root-input file:QueryRowContext@386 b85410173b1ace21db20284955168e54df04b012e60c70204ce722bfbeeb3aea |
| internal/hostbox/journalservice.go JournalService.read authority C,J2,J4 root-input file:Read@200 9e7b8a79a002161623cd52a56a01b922ad3b86d666f3f1e173b0493bc4bfe564 |
| internal/hostbox/journalservice.go NewJournalService authority C,J2,J4 root-input file:Exec@1218,file:Exec@1551,file:Exec@1692,file:Exec@823,file:Open@1834,file:QueryRow@1037,file:QueryRow@1373 baf45a21c6cacdf3378995f0e8c609a9f617a9f89d3f90cbb26d589dd4a8c886 |
| internal/hostbox/journalservice.go journalIdentity authority C,J2,J4 root-input file:Lstat@115 27de3aa321e85b6c76b2e75d629974be97f06635f2948c35a22b5a40803a4851 |
| -internal/hostbox/journalservice.go journalPrepare authority C,J2,J4 root-input file:Read@184,file:Read@333 df47d751a968e11580aec660916232ce39492f01c1f8fd75078245b0f8107b98 |
| +internal/hostbox/journalservice.go journalPrepare authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@184,file:Read@333 c41aa5bc3cdc1eaca61d8cd65ddaab5a93863378bc8553f2b4a02bcc8df55cf4 |
| internal/hostbox/journalservice.go journalPrune authority C,J2,J4 root-input file:QueryContext@220 3286ed98e12bb3b08684f8eb0d898ea4d7f5296a69881255ecbdfb307a10ff80 |
| internal/hostbox/journalservice.go type journalExecutor diagnostic H3 schema-only file:QueryContext@100,file:QueryRowContext@166 9cdb4670a36531ffab430aa87ae570ee84379bbaf9c670f55f4858e974ecbbb2 |
| internal/hostbox/journaltrust_linux.go checkTrustedJournalDir authority C,J2,J4 root-input file:Fstat@918,file:Open@177,file:Openat@397,file:Openat@640 ef25a95d3636857f07b25596aadd06af67bf88e7f800b82dfc43d98014109c1d |
| @@ -1617,9 +1617,9 @@ internal/hostbox/launchjournal.go LaunchJournal.SessionLaunches authority C,J2,J |
| internal/hostbox/launchjournal.go LaunchJournal.mutate authority C,J2,J4 root-input file:QueryRowContext@336 7ce6fc570958de43ef0666e4fca26249c255f5f96849f309faf46f2cf060c53b |
| internal/hostbox/launchjournal.go OpenLaunchJournal authority C,J2,J4 root-input file:Exec@582,file:Lstat@665,file:Open@203 365f4535365ab3942ef14f0326f808bb5b91d54770b97d709f3e75afd098dd41 |
| internal/hostbox/park.go Regional.observe authority C,J2,J4 root-input file:Read@353 a75a92dcaff677ca3d7826b6c0bea3963e9a4dfc5e6c70d7349eb2da5f8e6b73 |
| -internal/hostbox/reconcile.go Reconciler.Install authority C,J2,J4,J1 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:Read@266 c6013e40767c232b8b5edaea49cf543b522375c5eb1e34d15a7aff2267bab6eb |
| +internal/hostbox/reconcile.go Reconciler.Install authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering ba4a5f023efc34276beb2fd2edfbcb09bb10e3933d3a48f6e13f3f0a5ff884f8 |
| internal/hostbox/reconcile.go activationReader.Read authority C,J2,J4 root-input file:Read@183,file:Read@26 5451ef2cfc1cad30fd9fde526b28749580de2a169867fd5cabe2e95bf7fa89e0 |
| -internal/hostbox/reconcile_adapter.go ReconcileAdapter.Install authority C,J2,J4,J1 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:Read@1961,file:Read@3177,registry:ClaimOwner@2988,registry:ClaimOwner@3493,registry:ClaimOwnership@3830,registry:ClaimOwnership@4050,registry:ClaimRowID@3084,registry:ClaimRowID@3231,registry:ClaimRowID@3384,registry:ClaimRowID@3464,whole:ClaimOwnership@3834,whole:ClaimOwnership@3852,whole:ClaimOwnership@3918,whole:ClaimOwnership@4054,whole:ClaimOwnership@4072,whole:Route@3711,whole:Route@3816,whole:Route@3959,whole:Route@4036,whole:Route@988 eda5a243703517f6b765a5ae81e1e373002a2b352cc7253bda22da2f3adf8f56 |
| +internal/hostbox/reconcile_adapter.go ReconcileAdapter.Install authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@2060,file:Read@3276,registry:ClaimOwner@3087,registry:ClaimOwner@3592,registry:ClaimOwnership@3929,registry:ClaimOwnership@4149,registry:ClaimRowID@3183,registry:ClaimRowID@3330,registry:ClaimRowID@3483,registry:ClaimRowID@3563,whole:ClaimOwnership@3933,whole:ClaimOwnership@3951,whole:ClaimOwnership@4017,whole:ClaimOwnership@4153,whole:ClaimOwnership@4171,whole:Route@3810,whole:Route@3915,whole:Route@4058,whole:Route@4135,whole:Route@988 31784172f6657c92da55d86773aea69b87cde05fc5e7f750652f7edf4ced19f2 |
| internal/hostbox/reconcile_adapter.go ReconcileAdapter.Retire authority C,J2,J4 root-route registry:ClaimIncarnation@1309,registry:ClaimIncarnation@322,registry:ClaimIncarnation@3471,registry:ClaimIncarnation@3493,registry:ClaimOwner@1366,registry:ClaimOwner@1434,registry:ClaimOwner@2190,registry:ClaimOwner@2596,registry:ClaimOwner@3551,registry:ClaimOwner@3567,registry:ClaimOwner@3803,registry:ClaimOwnership@147,registry:ClaimRowID@3585,registry:ClaimRowID@3826,registry:ClaimStamp@1338,registry:ClaimStamp@3517,registry:ClaimStamp@3533,registry:ClaimStamp@362,registry:Host@1257,registry:Host@2095,registry:Host@2641,registry:Host@2698,registry:Host@288,registry:Host@3390,registry:Host@3771,registry:Name@3788,registry:Registration@3607,registry:Registration@3847,whole:ClaimOwnership@151,whole:Route@1069,whole:Route@1152,whole:Route@231,whole:Timer@3456 1c66bdfafb81fc5a563de6a2e3b19be56137b50cfc1bb5804bce2caa25fb840e |
| internal/hostbox/regionalworker.go RegionalWorker.Census authority C,J2,J4 root-input file:Read@1361 477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085 |
| internal/hostbox/regionalworker.go RegionalWorker.runHost authority C,J2,J4 root-input file:Exec@2481,file:Read@2264,file:Read@227,file:Read@3122,file:Read@513 ac4a0f827f5096b35870f9c4adc4199a43c64bc60aea29b7b7bc9ab36b80710c |
| @@ -4197,27 +4197,41 @@ internal/host/proofbinding.go proofBindingSource.forHost authority B3,J1 canonic |
| internal/host/proofbinding.go proofBindingSource.resolve authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Host@134,whole:Route@223,whole:Route@236 20b927104656ee5b93d0ce9b9fe6eae7dc0a0b19dfa77e6e73e4b960e87f831c |
| internal/host/proofbinding.go proofRouteReady authority B3,J1 kept=B3;scope=canonical-binding-and-readiness;implementation-incomplete registry:Addr@231,registry:Addrs@198,registry:Addrs@243,registry:Addrs@82,registry:Socket@256,whole:Route@119,whole:Route@226,whole:Route@306 b20c7bd1b2848a8f86457f9cf6153880fca078ca4bb4c335c7da042e306a830b |
| internal/host/proofobserve.go proofObserver.recordSettled authority B3,J1 kept=B3;scope=canonical-binding-and-readiness;implementation-incomplete registry:Account@227,registry:App@252,whole:Route@144,whole:Route@237,whole:Route@262 e0676272c20544b61b4150ce08d06032a9948b9ccc924adf0d63a7d92fec46fd |
| -internal/host/rootrestart.go checkedUnitRestart authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Host@1167,registry:Host@1703,registry:Host@510,registry:Unit@1585,registry:Unit@711,whole:Route@1593,whole:Route@638,whole:Route@719 a2355ed66e0ec7876b68062f9b8488afcc7ad0845cc8a163e5572163ebb12194 |
| -internal/host/rootrestart.go productionRootRestartChecks authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:Open@531,whole:Route@1224,whole:Route@469,whole:Route@957 2334f2179699968016be1342722b66623212da70006750eedb690bbc42e3a8c1 |
| -internal/host/rootrestart.go restartBoundUnit authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:EvalSymlinks@632,registry:Account@1711,registry:Account@2858,registry:App@1747,registry:App@2898,registry:Host@1196,registry:Host@1613,registry:Host@2012,registry:Host@2629,registry:Host@483,registry:Host@679,registry:Unit@1465,registry:Unit@2315,registry:Unit@3160,registry:Unit@393,registry:Unit@765,registry:Unit@830,whole:Route@1717,whole:Route@1753,whole:Route@198,whole:Route@2866,whole:Route@2906,whole:Route@3087,whole:Route@316 28559399e6e4ac594de69e3c4c305b9985550b08fb0989eb9a68f005be7d3e4d |
| +internal/host/rootrestart.go checkedUnitRestart authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:EvalSymlinks@1023,registry:Host@1070,registry:Host@1304,registry:Host@1840,registry:Host@510,registry:Unit@1722,registry:Unit@711,whole:Route@1730,whole:Route@638,whole:Route@719 40254316e78b3f70c8f4b346684907e053f65414a4ff360913dbcb9faf7f28de |
| +internal/host/rootrestart.go productionRootRestartChecks authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:Open@531,whole:Route@1163,whole:Route@1430,whole:Route@469 9c025f69737f8372029353f0041e33703a8184bef910ecc2c4514081968d0b4c |
| +internal/host/rootrestart.go restartBoundUnit authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:EvalSymlinks@632,registry:Account@2114,registry:Account@3261,registry:App@2150,registry:App@3301,registry:Host@1545,registry:Host@2016,registry:Host@2415,registry:Host@3032,registry:Host@483,registry:Host@679,registry:Unit@1100,registry:Unit@1168,registry:Unit@1818,registry:Unit@2718,registry:Unit@3595,registry:Unit@393,whole:Route@198,whole:Route@2120,whole:Route@2156,whole:Route@316,whole:Route@3269,whole:Route@3309,whole:Route@3519 fd2b8bc3569c8cc532952fb9cc7b47952671d04a092edb9428e6abda643a9b76 |
| internal/host/rootrestart.go rootRestartRouteWith authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete whole:Route@363,whole:Route@491,whole:Route@590 d93f43a503ec8b3da6e7d803d0f4c620b112855e144757096f5015f1b9e44c1f |
| internal/host/unitsettle.go systemdUnits.StartTime authority B3,J1 kept=B3;scope=canonical-binding-and-readiness;implementation-incomplete file:ReadFile@119 8806107d8806e1162ca493d8da84ae79cd17c32454be577517dc421c18858c20 |
| internal/rootstate/file_linux.go OpenTrustedFile authority A,B3 kept=B3;scope=canonical-binding-and-readiness;implementation-incomplete file:Open@174 8439fc9a19ef3b4b6b70fb0c82cdb2b98da20aaa7c1b14a2f47dd80e9424f262 |
| internal/provision/rootrecord.go FindRecordByHost authority B3,J1 kept=B3;scope=canonical-binding-and-readiness;implementation-incomplete 10c8254cf896be034334d634151d169760a909ac1ba4c1fc334785e86e4b84d4 |
| -internal/host/rootrestart.go rollbackUnitRestart authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Unit@904,whole:Route@912 80241c74e4bc690ce6223e1ae879e5e55d0a3875594f3f18412259ae106f0b84 |
| +internal/host/rootrestart.go rollbackUnitRestart authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery registry:Unit@904,whole:Route@912 9aad87500c41ee085c67a14b7c303cc036e30269d955f68e0fa79f952e579f9e |
| internal/host/rootrestart.go rootRestartRoute authority B3,J1 kept=B3;scope=canonical-binding-and-readiness;implementation-incomplete e053df9a9fdf7860e92ff9938632ac2de2593c6b1d4e044c9ccbd93d71c123d9 |
| -internal/host/agent.go runAdoptUnderRootRelease authority B3,C,D,E1,E2,E3,E4,F,J1 main adoption body under outer root release serialization; existing authorities retained; draft incomplete whole:Route@10234,whole:Route@16534,whole:Route@16570,whole:Route@7662 3152a11fb471e90388becbbda2b1b451d4dab48592190ef3c7e59b638e0da471 |
| +internal/host/agent.go runAdoptUnderRootRelease authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering whole:Route@10418,whole:Route@16956,whole:Route@16992,whole:Route@7846 94a765d694be40f81a3a56fcf7bc2a0acc89b06b137af66a53fa1f7bb586d9a0 |
| internal/host/proofbinding.go productionProofBindingSource authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:Read@692 e8a5f225e2286be5ff7dc8224c673d94d218ab365c13432f11c4d81f646c8eca |
| -internal/host/proofbinding.go proofBindingSource.binding authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:EvalSymlinks@1950,registry:Unit@3750,registry:Unit@423,registry:Unit@506,registry:Unit@578,whole:Route@4187,whole:Route@4194 6847b909af286f4174197073a630c835edfeb1bd3aef864b2d19d36ae4dbb5c0 |
| +internal/host/proofbinding.go proofBindingSource.binding authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:EvalSymlinks@1967,registry:Unit@3899,registry:Unit@440,registry:Unit@523,registry:Unit@595,whole:Route@4336,whole:Route@4343 4896c3a5c903e6a99386aaf089e36a0dc9616aaa9f98c9e5720c5fd7467141bc |
| internal/host/proofbinding.go proofBindingSource.rollback authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Host@122,whole:Route@124 43b49f858f0b360e5ccc1e8342eb8979b02832f5055b5a267c1499e70b5dd85a |
| -internal/host/rootrollback.go captureRollback authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:Read@734,registry:Host@371,registry:Host@853,whole:Route@373,whole:Route@855 924ed7ca528c1f9f9eb53f9872c8a20f2a0d400cbe565d58b3cc25ea6a7741b0 |
| -internal/host/rootrollback.go loadRollback authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:Read@467,registry:Host@765,whole:Route@777 e7c0dbfde8be208b5f6558389e2d16e8ee614e0b5cfe1a98b8d13f2100a265c5 |
| +internal/host/rootrollback.go captureRollback authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:Read@778,registry:Host@415,registry:Host@897,whole:Route@417,whole:Route@899 312fc498a54f65c1f3f4268c702acaf44c270d7e8ff3c7f47f237f3045453713 |
| +internal/host/rootrollback.go loadRollback authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:Read@451,registry:Host@821,whole:Route@833 50456b9628792e42612600079cad3caa7cbfcd92b9000dee648622adb674f550 |
| internal/host/rootrollback.go readRollbackAttempt authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:Read@118 3a41d88c6ce8e92d45bf08b25017698538a59d8cadef5ab81662ed63786d95fb |
| internal/host/rootroute.go proofBindingSource.claimIdentity authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete file:Exec@750 ad6e0fc3f2cd360473a5d0a2267d2dc7944b45d80947613f791db8d573f8fee6 |
| -internal/host/rootroute.go proofBindingSource.needsRestart authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Host@162,registry:Unit@463,whole:Route@392,whole:Route@403 a7e59e27e4e737abd193405d209ed5cf1321576b487a66458b0831d598219192 |
| -internal/host/rootroute.go rootRecordRoute authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Backing@477,registry:Health@856,registry:Kind@615,registry:Kind@640,registry:Socket@466,registry:Socket@888,registry:Unit@457,registry:Unit@560,registry:Unit@601,whole:Route@996 fa0904367baf3e9db943b2be50d147516c1e220df787e32c796f9cd6913dbcf4 |
| +internal/host/rootroute.go proofBindingSource.needsRestart authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery registry:Host@162,registry:Unit@507,whole:Route@436,whole:Route@447 4dc5fc2788d6b575e1d17829e37995b147a160ed8974ad4ea5868fe69e9876fc |
| +internal/host/rootroute.go rootRecordRoute authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery registry:Backing@545,registry:Health@924,registry:Kind@683,registry:Kind@708,registry:Socket@534,registry:Socket@956,registry:Unit@525,registry:Unit@628,registry:Unit@669,whole:Route@1064 765c71d7baeb75901a8af796fe909841ca9e0b700febf9a62305f909fd821dd6 |
| internal/host/rootroute.go sameProofCandidate authority B3,J1 canonical record/unit/executable authority and one-attempt rollback; draft incomplete registry:Account@110,registry:Account@89,registry:Addr@273,registry:Addr@291,registry:Addrs@301,registry:Addrs@320,registry:App@123,registry:App@140,registry:Backing@391,registry:Backing@412,registry:Health@331,registry:Health@351,registry:Host@149,registry:Host@167,registry:Kind@363,registry:Kind@381,registry:Socket@241,registry:Socket@261,registry:Unit@178,registry:Unit@212,registry:Unit@230 a817b55fd4f4c480d0230399bc1a161833150f4d5844de4a96e5a2b981c0320e |
| internal/hostguard/recoveryfence.go Guard.recordNames authority C,J1,J4 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:ReadDir@119 e44562b3d64921be270d315e0434c60ebef5dda5087f48344fe70a35fed4ae91 |
| -internal/hostguard/root.go RootAuthority.read authority C,J1,J4 root installation/fence backend and lock-free agent projection; establishment blocked; draft incomplete file:Read@175 04404ade39ee41e19d51c7b48c51f7b4cadb7e3ef0ab16ef23efa338d374a594 |
| +internal/hostguard/root.go RootAuthority.read authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@175 29b70745b38714684b66b3626772df0ecef5b6dae6f6daa42d9c43347447c703 |
| internal/rootstate/file_linux.go Identity authority A,B3 descriptor-checked root rollback capability identity/removal file:Fstat@81 eb8b39ca4c1fff35019f0e9cb93a33378173c543060d24517bf1e9ab607df2a5 |
| internal/rootstate/state_linux.go directory.Remove authority A,B3 descriptor-checked root rollback capability identity/removal file:Read@200 884759a5ab53bca8c61f5942a458fc6c4bd4431f0697bff34041d806ad4aeed8 |
| +internal/host/proofbinding.go proofBindingSource.prepareRollback authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery registry:Host@129,whole:Route@131 b407f57b45306fbeb3367fcf7d0f68c21254a355977e4232dc70cd1d59be3fb8 |
| +internal/host/rootinstallation.go rootInstallationHostApp authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@482,registry:Account@1082,registry:App@1111 caf21fb3208bb812f635bf639cbc609c6a1fcc9e43caa75d23ab8fb680387597 |
| +internal/host/rootinventory.go ClaimRootHTTP.commitRootInventoryPending authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@1038,file:Read@2122,registry:Account@3080,registry:Account@3095,registry:App@3116,registry:App@3127,registry:ClaimIncarnation@2605,registry:ClaimOwner@2655,registry:ClaimOwner@3600,registry:ClaimOwnership@3178,registry:ClaimOwnership@3200,registry:ClaimRowID@2819,registry:ClaimRowID@2896,registry:ClaimStamp@2633,registry:DBPath@3144,registry:DBPath@3158,registry:Host@3050,registry:Host@3062,whole:ClaimOwnership@3182,whole:ClaimOwnership@3210,whole:Route@3035,whole:Route@3273,whole:Route@3486 01d68d11920d03f181586c30f00dbfc63f3db6f25f91619646f3e67e10a54cdd |
| +internal/host/rootinventory.go ClaimRootHTTP.verifyInventoryPath authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Lstat@315,file:OpenFile@556,file:Stat@728 a9071854c0b1a88a6a1034219b2b0a4d4c414676be4f6ced9121c2326deef805 |
| +internal/host/rootinventory_state_linux.go ClaimRoleAssembly.prepareStateInventory authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Fstatat@1313,file:Fstatat@2308,file:Open@799,file:Openat@1086 8122e1b079c30af97474d28a384302d0b2cec24db656987dc170bb6601de6ddf |
| +internal/host/rootrecord_claim.go rootClaimRecord authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering registry:Account@1601,registry:App@1628,registry:DBPath@1522,whole:Route@1132,whole:Route@1258,whole:Route@1352,whole:Route@1383,whole:Route@1729,whole:Route@385,whole:Route@482,whole:Route@721,whole:Route@860,whole:Route@878 e7f73adcaa45e4dfefc5743f43e9ab83567ac95a74ae0557e789478a58e64da4 |
| +internal/host/rootrollback_resume.go recoverRootRestarts authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:Read@519 f6e859968866130a6291b1402f0d665504c84dd8d256f468858905bb0ceee720 |
| +internal/host/rootrollback_resume.go resumeRootRestart authority B3,J1 trusted deployment record and root unit binding; durable per-attempt rollback and recovery file:EvalSymlinks@1268,registry:Account@1361,registry:Account@1881,registry:App@1389,registry:App@1909,registry:Unit@1139,whole:Route@1141,whole:Route@1363,whole:Route@1391,whole:Route@1883,whole:Route@1911 3b3b3ffd83b49c2b290f6c942c3350a1957a5614d31a8397eebcb8ca93cbe450 |
| +internal/hostbox/inventory_registration.go ReconcileAdapter.CaptureInventory authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@167 75b520c9ebd7816e4e0ce9fa0d767c35282f745055ff923cef54f35be109cac5 |
| +internal/hostbox/inventory_registration.go ReconcileAdapter.installRemote authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@190 9bb343f98d9abd01b0df5cf1004f9f90c43ad52f317c053299efa4bdbbff4994 |
| +internal/hostbox/inventory_registration.go ReconcileAdapter.registerRemote authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@1055,registry:ClaimOwner@1364,registry:ClaimOwner@874,registry:ClaimOwnership@2291,registry:ClaimOwnership@2509,registry:ClaimRowID@1106,registry:ClaimRowID@1257,registry:ClaimRowID@1335,registry:ClaimRowID@966,whole:ClaimOwnership@2295,whole:ClaimOwnership@2313,whole:ClaimOwnership@2379,whole:ClaimOwnership@2513,whole:ClaimOwnership@2531,whole:Route@2172,whole:Route@2277,whole:Route@2420,whole:Route@2495 9b785e96a658a10c2521aceb75776c1869c6566e51ae371f13635afb975700bc |
| +internal/hostbox/journalservice.go JournalService.execute authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Create@2579,file:QueryRowContext@1592,file:QueryRowContext@580 cf1e786c49cf930110ae55b687dc838f7424986c041d8f110145bfa1b77d6b60 |
| +internal/hostbox/reconcile.go Reconciler.installWithWork authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Read@392 372be465c9b6285a04fa923222e810ffb7808f2d4ea730e659042c99ad8ca99a |
| +internal/host/claimconstruction.go var openClaimRootAuthority authority C,J1 root installation and fence authority; registration and recovery; agent RPC lock ordering file:Open@71 77bde06b9fa9565a32c3add3de28e02d853397aeab892dab202c2985f8cf64c8 |