carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev b43690732152572d0c4814ecc72022b57e853ddc parent 0f686e4
56 files changed, +2908 −127
  • approved_moves_copy_test.go +1 −0
  • internal/activator/rolelaunch.go +1 −0
  • internal/activator/rolelaunch_linux.go +1 −1
  • internal/activator/rolelaunch_root_linux.go +5 −0
  • internal/activator/rolelaunch_root_linux_test.go +54 −0
  • internal/host/agent.go +9 −2
  • internal/host/claimassembly.go +2 −1
  • internal/host/claimconstruction.go +10 −3
  • internal/host/claimjournal_transport.go +10 −0
  • internal/host/claimlaunch_production.go +1 −1
  • internal/host/claimretire_transport.go +2 −1
  • internal/host/claimroleassembly.go +3 −0
  • internal/host/claimroleassembly_linux.go +64 −6
  • internal/host/claimstop_run.go +1 −1
  • internal/host/plan14_backend_source_test.go +104 −0
  • internal/host/plan14_canonical_restart_test.go +28 −5
  • internal/host/plan14_recovery_source_test.go +63 −0
  • internal/host/plan14_release_lock_test.go +51 −0
  • internal/host/plan14_root_launch_test.go +82 −0
  • internal/host/plan9fix4_claimjournal_migration_linux_test.go +5 −0
  • internal/host/proofbinding.go +8 −1
  • internal/host/proofbinding_test.go +5 −2
  • internal/host/registry_privilege_test.go +2 −1
  • internal/host/registryowner_test.go +3 −1
  • internal/host/rootfenceview.go +6 −0
  • internal/host/rootfenceview_test.go +185 −0
  • internal/host/rootinstallation.go +46 −0
  • internal/host/rootinstallation_test.go +74 −0
  • internal/host/rootinventory.go +254 −0
  • internal/host/rootinventory_process_test.go +119 −0
  • internal/host/rootinventory_state_linux.go +140 −0
  • internal/host/rootinventory_test.go +165 −0
  • internal/host/rootrecord_claim.go +61 −0
  • internal/host/rootrestart.go +81 −32
  • internal/host/rootrollback.go +7 −4
  • internal/host/rootrollback_process_test.go +213 −0
  • internal/host/rootrollback_resume.go +113 −0
  • internal/host/rootrollback_resume_test.go +91 −0
  • internal/host/rootrollback_test.go +13 −2
  • internal/host/rootroute.go +6 −0
  • internal/hostbox/inventory_expected.go +43 −0
  • internal/hostbox/inventory_expected_test.go +50 −0
  • internal/hostbox/inventory_registration.go +195 −0
  • internal/hostbox/journalservice.go +23 −3
  • internal/hostbox/launchjournal.go +2 −0
  • internal/hostbox/reconcile.go +22 −9
  • internal/hostbox/reconcile_adapter.go +3 −0
  • internal/hostguard/coldhandover.go +10 −1
  • internal/hostguard/guard.go +3 −0
  • internal/hostguard/plan14_root_fences_test.go +272 −2
  • internal/hostguard/record.go +13 −11
  • internal/hostguard/retirement.go +14 −0
  • internal/hostguard/root.go +115 −9
  • internal/hostguard/rootview.go +3 −0
  • internal/provision/plan9_b8_census_test.go +11 −2
  • internal/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