WIP claim Plan 14 track E checkpoint 15 (not for merge): census-backed root restart consumption wired into both adoption entry points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+1164
−13
internal/host/agent.go+16 −10internal/host/plan14_restart_consumption_process_test.go+184 −0internal/host/plan14_restart_consumption_test.go+550 −0internal/host/rootrestart_requests.go+237 −0internal/host/rootrestart_requests_source.go+130 −0internal/provision/plan14_e_write_census_test.go+3 −0internal/provision/testdata/plan14-root-reads-e.tsv+14 −3internal/release/rootrestart_history.go+30 −0
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 335.390s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.192s
ok github.com/carlosframework/platform/cmd/moveprobe 93.238s
ok github.com/carlosframework/platform/cmd/zddprobe 0.382s
ok github.com/carlosframework/platform/internal/activator 227.539s
ok github.com/carlosframework/platform/internal/agentsock 0.014s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.297s
ok github.com/carlosframework/platform/internal/apperr 0.073s
ok github.com/carlosframework/platform/internal/applog 0.129s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.521s
? 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.691s
ok github.com/carlosframework/platform/internal/changelog 0.292s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 363.557s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.646s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
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.299s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 26.092s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.157s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.039s
ok github.com/carlosframework/platform/internal/historyreadback 0.123s
ok github.com/carlosframework/platform/internal/host 100.094s
ok github.com/carlosframework/platform/internal/hostactors 0.275s
ok github.com/carlosframework/platform/internal/hostbox 2.126s
ok github.com/carlosframework/platform/internal/hostclaim 0.492s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 4.903s
ok github.com/carlosframework/platform/internal/hostfence 0.032s
ok github.com/carlosframework/platform/internal/hostfiles 10.563s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 1.005s
ok github.com/carlosframework/platform/internal/hostops 1.425s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.014s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.439s
ok github.com/carlosframework/platform/internal/hostresolve 0.129s
ok github.com/carlosframework/platform/internal/hostwrite 0.015s
ok github.com/carlosframework/platform/internal/infraview 0.014s
ok github.com/carlosframework/platform/internal/instancectl 1.815s
ok github.com/carlosframework/platform/internal/instanceexec 2.718s
ok github.com/carlosframework/platform/internal/instancefs 0.019s
ok github.com/carlosframework/platform/internal/instancekey 0.017s
ok github.com/carlosframework/platform/internal/instanceupdates 0.020s
ok github.com/carlosframework/platform/internal/layout 1.424s
ok github.com/carlosframework/platform/internal/lease 9.466s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.019s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.160s
ok github.com/carlosframework/platform/internal/maintenance 0.089s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.546s
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 20.955s
ok github.com/carlosframework/platform/internal/moves/archive 0.771s
ok github.com/carlosframework/platform/internal/objstore 0.170s
ok github.com/carlosframework/platform/internal/ops 0.163s
ok github.com/carlosframework/platform/internal/park 0.204s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.279s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.046s
ok github.com/carlosframework/platform/internal/relay 7.228s
ok github.com/carlosframework/platform/internal/release 20.414s
ok github.com/carlosframework/platform/internal/rollout 3.747s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.008s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.278s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.582s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.548s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.218s
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.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.551s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/13 23:40:03 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE3B35C84D20A5, 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.021s
minio contract: PASS
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 05565a072..6ba845156 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4413,11 +4413,14 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // parsed below and is fatal when unusable. A restart request must not fail |
| // on a key it never needs. |
| if *restartOnly { |
| - routes, err := reg.List() |
| - if err != nil { |
| - log.Fatalf("adopt: list routes: %v", err) |
| - } |
| - restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| + logRestartRequestError(productionRestartRequestPass(ctx, store, claims.Writes, func() error { |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Fatalf("adopt: list routes: %v", err) |
| + } |
| + restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| + return nil |
| + })) |
| return nil |
| } |
| // Parsed here, before config sync, exactly as the full pass always has |
| @@ -4463,11 +4466,14 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // with an unstageable release would otherwise never reach this line — and |
| // a wedged instance is most worth cycling precisely on a box where |
| // something else is already wrong. |
| - if routes, err := reg.List(); err != nil { |
| - log.Printf("adopt: list routes for the restart pass: %v", err) |
| - } else { |
| - restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| - } |
| + logRestartRequestError(productionRestartRequestPass(ctx, store, claims.Writes, func() error { |
| + if routes, err := reg.List(); err != nil { |
| + log.Printf("adopt: list routes for the restart pass: %v", err) |
| + } else { |
| + restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| + } |
| + return nil |
| + })) |
| // Step 2 (spec §4d): the grant-drift capability converge, BETWEEN config |
| // sync and release adoption. Before release.Pass because it repairs the |
diff --git a/internal/host/plan14_restart_consumption_process_test.go b/internal/host/plan14_restart_consumption_process_test.go| new file mode 100644 |
| index 000000000..cc0b0c7f5 |
| --- /dev/null |
| +++ b/internal/host/plan14_restart_consumption_process_test.go |
| @@ -0,0 +1,184 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "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/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| +) |
| + |
| +type restartRequestProcessFixture struct { |
| + Unit rollbackProcessFixture |
| + Claim *hostclaim.Claim |
| + Installations, Journal string |
| +} |
| + |
| +func TestPlan14RootRestartProductionCrash(t *testing.T) { |
| + if boundary := os.Getenv("PLAN14_REQUEST_CRASH"); boundary != "" { |
| + dir := os.Getenv("PLAN14_REQUEST_DIR") |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + storeDir := filepath.Join(dir, "deployment") |
| + store := release.NewDirStore(storeDir) |
| + keys, err := f.e.store.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range keys { |
| + raw, _, err := f.e.store.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(t.Context(), key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + crash := func() { |
| + state := restartRequestProcessFixture{Unit: rollbackProcessFixture{Props: f.u.props, Exe: f.u.exe, Opt: f.u.opt, Bake: f.e.bake, Store: storeDir, Effects: f.u.effects}, Claim: f.claim, |
| + Installations: f.p.source.state.(*rollbackDisk).dir, Journal: f.p.journal.Store.(*rollbackDisk).dir} |
| + raw, err := json.Marshal(state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "fixture.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Exit(74) |
| + } |
| + disk := f.p.journal.Store.(*rollbackDisk) |
| + classify := func(raw []byte) string { |
| + var r struct { |
| + Pending *release.RestartIntent |
| + ScopeID string `json:"scope_id"` |
| + } |
| + if json.Unmarshal(raw, &r) != nil { |
| + return "" |
| + } |
| + if r.Pending == nil { |
| + if r.ScopeID != release.RestartScopeNone { |
| + return "consumed" |
| + } |
| + return "baseline" |
| + } |
| + if r.Pending.Targets[0].After != "" { |
| + return "completed" |
| + } |
| + if r.Pending.Targets[0].Before != "" { |
| + return "intent" |
| + } |
| + return "pending" |
| + } |
| + disk.afterWrite = func(_ string, raw []byte) { |
| + if classify(raw) == boundary { |
| + crash() |
| + } |
| + } |
| + if boundary == "intent-visible" { |
| + disk.beforeWrite = func(name string, raw []byte) { |
| + if classify(raw) != "intent" { |
| + return |
| + } |
| + file, err := os.CreateTemp(disk.dir, "visible-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = file.Write(raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = file.Sync(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = file.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.Rename(file.Name(), filepath.Join(disk.dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Exit before syncing the parent of the visible rename. |
| + crash() |
| + } |
| + } |
| + f.u.afterEffect = func() { |
| + if boundary == "effect" { |
| + crash() |
| + } |
| + } |
| + _ = f.run(t) |
| + t.Fatal("crash boundary not reached") |
| + } |
| + for _, boundary := range []string{"pending", "intent", "intent-visible", "effect", "completed", "consumed"} { |
| + 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=^TestPlan14RootRestartProductionCrash$", "-test.count=1") |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "TMPDIR="+dir, "PLAN14_REQUEST_DIR="+dir, "PLAN14_REQUEST_CRASH="+boundary, "CARLOS_HOST_CLAIM=") |
| + out, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 74 { |
| + t.Fatalf("child: %v\n%s", err, out) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, "fixture.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var saved restartRequestProcessFixture |
| + if err = json.Unmarshal(raw, &saved); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := &plan14RestartUnits{props: saved.Unit.Props, exe: saved.Unit.Exe, opt: saved.Unit.Opt, effects: saved.Unit.Effects} |
| + store := release.NewDirStore(saved.Unit.Store) |
| + state := &rollbackDisk{dir: saved.Installations} |
| + guard := hostguard.Guard{DataDir: t.TempDir(), LockDir: t.TempDir(), Root: hostguard.NewRootAuthority(state), Claims: rootViewClaimReader{obs: hostclaim.Observed{Host: observedHost, ETag: "claim", Claim: saved.Claim}}} |
| + source := &proofBindingSource{store: store, sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}, healthy: func(acct, app, version, digest string) *release.HealthyBuild { |
| + for _, b := range release.ReadHealthy(saved.Unit.Bake, acct, app) { |
| + if b.Version == version && b.SHA256 == digest { |
| + return &b |
| + } |
| + } |
| + return nil |
| + }} |
| + now, sleep := settleClock() |
| + p := rootRestartRequests{journal: &release.RootRestarts{Store: &rollbackDisk{dir: saved.Journal}}, source: rootRestartRequestSource{state: state, store: store, census: func() (rootparticipation.Census, error) { |
| + return rootparticipation.Census{Box: "box", Enrollment: "en", Migration: 1, Complete: true}, nil |
| + }}, |
| + sys: u, optDir: u.opt, getenv: func(string) string { return "5s" }, checks: rootRestartChecks{host: source.forHost, ready: func(registry.Route) func() error { return func() error { return nil } }, now: now, sleep: sleep}, |
| + writes: hostwrite.Config{Enabled: true, Selector: hostbox.WorkSource{Guard: guard, Box: "box", Region: "ie", Now: time.Now, Policy: func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + return hostbox.Serving(c, s) |
| + }, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }}}} |
| + err = p.run(t.Context()) |
| + if boundary == "intent" || boundary == "intent-visible" { |
| + if err == nil || len(u.effects) != 0 { |
| + t.Fatalf("ambiguous replay: %v %v", err, u.effects) |
| + } |
| + u.props.InvocationID += "reconciled" |
| + u.props.MainPID++ |
| + if err = p.run(t.Context()); err != nil || len(u.effects) != 0 { |
| + t.Fatalf("reconcile: %v %v", err, u.effects) |
| + } |
| + } else if err != nil || len(u.effects) != 1 { |
| + t.Fatalf("resume: %v %v", err, u.effects) |
| + } |
| + count := len(u.effects) |
| + if err = p.run(t.Context()); err != nil || len(u.effects) != count { |
| + t.Fatalf("replay: %v %v", err, u.effects) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan14_restart_consumption_test.go b/internal/host/plan14_restart_consumption_test.go| new file mode 100644 |
| index 000000000..993549068 |
| --- /dev/null |
| +++ b/internal/host/plan14_restart_consumption_test.go |
| @@ -0,0 +1,550 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "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/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| +) |
| + |
| +type restartConsumptionFixture struct { |
| + p rootRestartRequests |
| + e *proofRootEnv |
| + u *plan14RestartUnits |
| + claim *hostclaim.Claim |
| + marks string |
| +} |
| + |
| +func newRestartConsumptionFixture(t *testing.T) restartConsumptionFixture { |
| + t.Helper() |
| + e, rt, u, checks, _ := plan14AdoptionFixture(t) |
| + rec, err := provision.GetRecord(t.Context(), e.store, rt.Account, rt.App, rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Environment = "relay" |
| + rec.CreatedAt = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + plan14PutRecord(t, e.store, *rec) |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := &hostclaim.Claim{Host: rt.Host, Holder: &hostclaim.Holder{Key: provision.RecordKey(rt.Account, rt.App, rt.Host), Account: rt.Account, App: rt.App, Incarnation: inc, Stamp: 1, Session: 1, WriterBox: "box"}} |
| + c.Op.Kind, c.Op.Exec = hostclaim.KindServing, "box" |
| + state := &rollbackDisk{dir: t.TempDir()} |
| + g := hostguard.Guard{DataDir: t.TempDir(), LockDir: t.TempDir(), Claims: rootViewClaimReader{obs: hostclaim.Observed{Host: rt.Host, ETag: "claim", Claim: c}}, Root: hostguard.NewRootAuthority(state)} |
| + in := hostguard.RootInstallation{Host: rt.Host, Incarnation: inc, Stamp: 1, Account: rt.Account, App: rt.App, Record: c.Holder.Key, Box: "box", Enrollment: "en", Migration: 1, InventoryRevision: 1} |
| + if err := g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + writes := hostwrite.Config{Enabled: true, Selector: hostbox.WorkSource{Guard: g, Box: "box", Region: "ie", Now: time.Now, |
| + Policy: func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + return hostbox.Serving(c, s) |
| + }, |
| + Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }, |
| + }} |
| + p := rootRestartRequests{journal: &release.RootRestarts{Store: &rollbackDisk{dir: t.TempDir()}}, |
| + source: rootRestartRequestSource{state: state, store: e.store, census: func() (rootparticipation.Census, error) { |
| + return rootparticipation.Census{Schema: 1, Box: "box", Enrollment: "en", Migration: 1, Revision: 1, Complete: true}, nil |
| + }}, |
| + writes: writes, sys: u, checks: checks, getenv: func(string) string { return "5s" }, optDir: u.opt, |
| + } |
| + return restartConsumptionFixture{p: p, e: e, u: u, claim: c, marks: restartMarksPath(t.TempDir())} |
| +} |
| + |
| +func (f restartConsumptionFixture) request(t *testing.T, kind, value string) { |
| + t.Helper() |
| + if _, err := release.AppendRestartScope(t.Context(), f.e.store, "bab", "console", release.RestartScope{Kind: kind, Value: value}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f restartConsumptionFixture) run(t *testing.T) error { |
| + t.Helper() |
| + return restartRequestPass(func() (bool, error) { return false, nil }, func() error { |
| + routes, err := f.e.root.reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + restartPass(t.Context(), routes, f.e.store, f.marks, func(host string) error { return f.u.TryRestart("console.service") }) |
| + return nil |
| + }, func() error { return f.p.run(t.Context()) }) |
| +} |
| + |
| +func TestPlan14RootRestartProductionConsumption(t *testing.T) { |
| + for _, attack := range []string{"none", "delete-marks", "rename-parent", "forge-marks", "delete-row", "forge-selector", "forge-unit"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeEnvironment, "relay") |
| + if err := f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("first request: %v %v", err, f.u.effects) |
| + } |
| + if err := os.WriteFile(f.marks, []byte(`{"schema":1,"apps":{},"scopes":{}}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "delete-marks": |
| + if err := os.Remove(f.marks); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename-parent": |
| + if err := os.Rename(filepath.Dir(f.marks), filepath.Dir(f.marks)+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(f.marks) + ".old") }) |
| + case "forge-marks": |
| + if err := os.WriteFile(f.marks, []byte(`{"schema":1,"apps":{"bab|console":"forged"},"scopes":{"bab|console":"forged"}}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "delete-row": |
| + if err := f.e.root.reg.Delete(observedHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "forge-selector", "forge-unit": |
| + r, err := f.e.root.reg.Get(observedHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Environment, r.Unit, r.Hibernate = "forged", "unrelated.service", true |
| + if err := f.e.root.reg.Upsert(*r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("replay: %v %v", err, f.u.effects) |
| + } |
| + f.request(t, release.RestartScopeEnvironment, "forged") |
| + if err := f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("widened: %v %v", err, f.u.effects) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + if err := f.run(t); err != nil || len(f.u.effects) != 2 { |
| + t.Fatalf("fresh request: %v %v", err, f.u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionHeldRollover(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.claim.Op.Kind = hostclaim.KindLanding |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + if err := f.e.root.reg.Delete(observedHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.run(t); err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("held: %v %v", err, f.u.effects) |
| + } |
| + for i := 0; i < 34; i++ { |
| + f.request(t, release.RestartScopeHost, "other.example.com") |
| + } |
| + f.claim.Op.Kind = hostclaim.KindServing |
| + if err := f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("lost pending: %v %v", err, f.u.effects) |
| + } |
| + if err := f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("replayed pending: %v %v", err, f.u.effects) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionBindingChange(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.claim.Op.Kind = hostclaim.KindLanding |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + _ = f.run(t) |
| + rec, err := provision.GetRecord(t.Context(), f.e.store, "bab", "console", observedHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Environment = "replacement" |
| + plan14PutRecord(t, f.e.store, *rec) |
| + f.claim.Op.Kind = hostclaim.KindServing |
| + if err := f.run(t); err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("changed binding: %v %v", err, f.u.effects) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionAdmissionAfterIntent(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + disk := f.p.journal.Store.(*rollbackDisk) |
| + disk.afterWrite = func(_ string, raw []byte) { |
| + var r struct{ Pending *release.RestartIntent } |
| + if json.Unmarshal(raw, &r) == nil && r.Pending != nil && r.Pending.Targets[0].Before != "" { |
| + f.claim.Op.Kind = hostclaim.KindLanding |
| + } |
| + } |
| + if err := f.run(t); err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("move crossed dispatch intent: %v %v", err, f.u.effects) |
| + } |
| + disk.afterWrite = nil |
| + f.claim.Op.Kind = hostclaim.KindServing |
| + if err := f.run(t); err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("ambiguous intent replayed: %v %v", err, f.u.effects) |
| + } |
| + // A verified successor completes the captured request without another call. |
| + f.u.props.InvocationID += "successor" |
| + f.u.props.MainPID++ |
| + if err := f.run(t); err != nil || len(f.u.effects) != 0 { |
| + t.Fatalf("successor: %v %v", err, f.u.effects) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionUnknown(t *testing.T) { |
| + for _, attack := range []string{"census-error", "census-incomplete", "root-corrupt", "record-missing", "retired-pending", "flag-off", "foreign-census", "inactive"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + switch attack { |
| + case "census-error": |
| + f.p.source.census = func() (rootparticipation.Census, error) { return rootparticipation.Census{}, os.ErrPermission } |
| + case "census-incomplete": |
| + f.p.source.census = func() (rootparticipation.Census, error) { return rootparticipation.Census{Box: "box"}, nil } |
| + case "foreign-census": |
| + f.p.source.census = func() (rootparticipation.Census, error) { |
| + return rootparticipation.Census{Box: "other", Complete: true}, nil |
| + } |
| + case "root-corrupt": |
| + if err := f.p.source.state.Write(observedHost+".json", []byte(`{}`)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "record-missing": |
| + if err := f.e.store.Delete(t.Context(), provision.RecordKey("bab", "console", observedHost)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "retired-pending": |
| + f.claim.Op.Kind = hostclaim.KindLanding |
| + _ = f.run(t) |
| + f.claim.Op.Kind = hostclaim.KindServing |
| + if err := f.p.source.state.Remove(observedHost + ".json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "flag-off": |
| + f.p.writes.Enabled = false |
| + case "inactive": |
| + f.u.props.ActiveState = "inactive" |
| + } |
| + if err := f.run(t); err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("unknown granted: %v %v", err, f.u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartRequestCensusGate(t *testing.T) { |
| + for _, mode := range []string{"absent", "present", "unreadable"} { |
| + t.Run(mode, func(t *testing.T) { |
| + old, root := 0, 0 |
| + err := restartRequestPass(func() (bool, error) { |
| + if mode == "unreadable" { |
| + return false, os.ErrPermission |
| + } |
| + return mode == "absent", nil |
| + }, func() error { old++; return nil }, func() error { root++; return nil }) |
| + if mode == "unreadable" { |
| + if err == nil || old+root != 0 { |
| + t.Fatal(err, old, root) |
| + } |
| + return |
| + } |
| + if err != nil || (mode == "absent" && (old != 1 || root != 0)) || (mode == "present" && (old != 0 || root != 1)) { |
| + t.Fatal(err, old, root) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type restartRequestSyncFault struct { |
| + *rollbackDisk |
| + stage string |
| +} |
| + |
| +func (s *restartRequestSyncFault) Write(name string, raw []byte) error { |
| + var r struct { |
| + Pending *release.RestartIntent |
| + ScopeID string `json:"scope_id"` |
| + } |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + return err |
| + } |
| + stage := "baseline" |
| + if r.Pending != nil { |
| + stage = "pending" |
| + if r.Pending.Targets[0].Before != "" { |
| + stage = "intent" |
| + } |
| + if r.Pending.Targets[0].After != "" { |
| + stage = "completed" |
| + } |
| + } else if r.ScopeID != release.RestartScopeNone { |
| + stage = "consumed" |
| + } |
| + if stage != s.stage { |
| + return s.rollbackDisk.Write(name, raw) |
| + } |
| + f, err := os.CreateTemp(s.dir, "unsynced-") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(f.Name()) |
| + if _, err = f.Write(raw); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if err = f.Sync(); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if err = f.Close(); err != nil { |
| + return err |
| + } |
| + if err = os.Rename(f.Name(), filepath.Join(s.dir, name)); err != nil { |
| + return err |
| + } |
| + return os.ErrPermission |
| +} |
| + |
| +func TestPlan14RootRestartProductionSyncFailure(t *testing.T) { |
| + for _, stage := range []string{"baseline", "pending", "intent", "completed", "consumed"} { |
| + t.Run(stage, func(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + fault := &restartRequestSyncFault{rollbackDisk: f.p.journal.Store.(*rollbackDisk), stage: stage} |
| + f.p.journal.Store = fault |
| + if err := f.run(t); err == nil { |
| + t.Fatal("failed sync accepted") |
| + } |
| + want := 0 |
| + if stage == "completed" || stage == "consumed" { |
| + want = 1 |
| + } |
| + if len(f.u.effects) != want { |
| + t.Fatal(f.u.effects) |
| + } |
| + // The read must repair durability before dispatch or completion. |
| + _ = f.run(t) |
| + if len(f.u.effects) != want { |
| + t.Fatal("repeat under failed sync", f.u.effects) |
| + } |
| + fault.stage = "" |
| + err := f.run(t) |
| + if stage == "intent" { |
| + if err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("ambiguous repeat: %v %v", err, f.u.effects) |
| + } |
| + f.u.props.InvocationID += "verified" |
| + f.u.props.MainPID++ |
| + if err = f.run(t); err != nil || len(f.u.effects) != 0 { |
| + t.Fatalf("repair: %v %v", err, f.u.effects) |
| + } |
| + } else if err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("repair: %v %v", err, f.u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionCanonicalSelection(t *testing.T) { |
| + for _, kind := range []string{"hibernate", "exec", "sidecar"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if kind == "sidecar" { |
| + // A valid root sidecar installation cannot hide another app's units. |
| + in, err := f.p.source.installation(t.Context(), observedHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in.Host = provision.SidecarHost("worker", in.App, in.Account) |
| + in.Record = provision.SidecarRecordKey(in.Account, in.App, "worker") |
| + claim := *f.claim |
| + holder := *claim.Holder |
| + claim.Holder = &holder |
| + claim.Host = in.Host |
| + holder.Key = in.Record |
| + g := hostguard.Guard{DataDir: t.TempDir(), LockDir: t.TempDir(), Root: hostguard.NewRootAuthority(f.p.source.state), Claims: rootViewClaimReader{obs: hostclaim.Observed{Host: in.Host, ETag: "sidecar", Claim: &claim}}} |
| + if err = g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + rec, err := provision.GetRecord(t.Context(), f.e.store, "bab", "console", observedHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if kind == "exec" { |
| + rec.Unit = "" |
| + } else { |
| + yes := true |
| + rec.Hibernate = &yes |
| + rec.Addr = "" |
| + rec.Unit = "carlos-app@" + rec.Host + ".service" |
| + } |
| + plan14PutRecord(t, f.e.store, *rec) |
| + } |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := 0 |
| + if kind == "sidecar" { |
| + want = 1 |
| + } |
| + if len(f.u.effects) != want { |
| + t.Fatal(kind, f.u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionReplacedInstallation(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + f.claim.Op.Kind = hostclaim.KindLanding |
| + _ = f.run(t) |
| + in, err := f.p.source.installation(t.Context(), observedHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.claim.Holder.Stamp++ |
| + in.Stamp++ |
| + w := f.p.writes.Selector.(hostbox.WorkSource) |
| + if err = w.Guard.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.claim.Op.Kind = hostclaim.KindServing |
| + if err = f.run(t); err == nil || len(f.u.effects) != 0 { |
| + t.Fatalf("captured request crossed installation: %v %v", err, f.u.effects) |
| + } |
| +} |
| + |
| +type restartRequestErrorUnits struct { |
| + *plan14RestartUnits |
| + after bool |
| +} |
| + |
| +func (u restartRequestErrorUnits) TryRestart(unit string) error { |
| + if u.after { |
| + if err := u.plan14RestartUnits.TryRestart(unit); err != nil { |
| + return err |
| + } |
| + } |
| + return os.ErrPermission |
| +} |
| + |
| +func TestPlan14RootRestartProductionDispatchError(t *testing.T) { |
| + for _, after := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "no-effect", true: "effect-then-error"}[after], func(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + f.p.sys = restartRequestErrorUnits{f.u, after} |
| + if err := f.run(t); err == nil { |
| + t.Fatal("lost dispatch error") |
| + } |
| + b := release.RestartBinding{Box: "box", Account: "bab", App: "console"} |
| + pending, err := f.p.journal.Observe(b, release.RestartSnapshot{}) |
| + if err != nil || pending == nil { |
| + t.Fatalf("ambiguous consumed: %+v %v", pending, err) |
| + } |
| + f.p.sys = f.u |
| + err = f.run(t) |
| + if after { |
| + if err != nil || len(f.u.effects) != 1 { |
| + t.Fatal(err, f.u.effects) |
| + } |
| + } else { |
| + if err == nil || len(f.u.effects) != 0 { |
| + t.Fatal(err, f.u.effects) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootRestartProductionDefiniteFailure(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + f.u.afterEffect = func() { f.u.props.ActiveState = "failed"; f.u.props.MainPID = 0 } |
| + if err := f.run(t); err == nil || len(f.u.effects) != 1 { |
| + t.Fatal(err, f.u.effects) |
| + } |
| + if err := f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("failed request repeated: %v %v", err, f.u.effects) |
| + } |
| + f.u.afterEffect = nil |
| + f.u.props.ActiveState = "active" |
| + f.u.props.MainPID = 8 |
| + f.u.props.InvocationID += "recovered" |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + if err := f.run(t); err != nil || len(f.u.effects) != 2 { |
| + t.Fatalf("new request lost: %v %v", err, f.u.effects) |
| + } |
| +} |
| + |
| +type restartRequestShowError struct{ *plan14RestartUnits } |
| + |
| +func (u restartRequestShowError) Show(unit string) (unitProps, error) { |
| + if len(u.effects) > 0 { |
| + return unitProps{}, os.ErrPermission |
| + } |
| + return u.plan14RestartUnits.Show(unit) |
| +} |
| + |
| +func TestPlan14RootRestartProductionObservationError(t *testing.T) { |
| + f := newRestartConsumptionFixture(t) |
| + if err := f.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.request(t, release.RestartScopeHost, observedHost) |
| + f.p.sys = restartRequestShowError{f.u} |
| + if err := f.run(t); err == nil || len(f.u.effects) != 1 { |
| + t.Fatal(err, f.u.effects) |
| + } |
| + b := release.RestartBinding{Box: "box", Account: "bab", App: "console"} |
| + pending, err := f.p.journal.Observe(b, release.RestartSnapshot{}) |
| + if err != nil || pending == nil { |
| + t.Fatalf("observation failure consumed: %+v %v", pending, err) |
| + } |
| + f.p.sys = f.u |
| + if err = f.run(t); err != nil || len(f.u.effects) != 1 { |
| + t.Fatalf("observed successor replayed: %v %v", err, f.u.effects) |
| + } |
| +} |
diff --git a/internal/host/rootrestart_requests.go b/internal/host/rootrestart_requests.go| new file mode 100644 |
| index 000000000..338bcbb53 |
| --- /dev/null |
| +++ b/internal/host/rootrestart_requests.go |
| @@ -0,0 +1,237 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type rootRestartRequests struct { |
| + journal *release.RootRestarts |
| + source rootRestartRequestSource |
| + writes hostwrite.Config |
| + sys unitSystem |
| + checks rootRestartChecks |
| + getenv func(string) string |
| + optDir string |
| +} |
| + |
| +// runAdoptUnderRootRelease owns release exclusion for both call sites. |
| +func productionRestartRequestPass(ctx context.Context, store release.Store, writes hostwrite.Config, oldPass func() error) error { |
| + return restartRequestPass(legacyRootRestart, oldPass, func() error { |
| + census, err := rootparticipation.Open() |
| + if err != nil { |
| + return err |
| + } |
| + defer census.State.Close() |
| + state, err := rootstate.Open("/var/lib/carlos-claims/authority/fences", false, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer state.Close() |
| + journal, err := rootstate.Open(release.RootRestartsDir, true, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer journal.Close() |
| + sys := systemdUnits{} |
| + source := productionProofBindingSource(store, sys, release.OptDir()) |
| + source.cacheIndex = false |
| + checks := productionRootRestartChecks(sys, release.OptDir()) |
| + checks.host = source.forHost |
| + p := rootRestartRequests{journal: &release.RootRestarts{Store: journal}, source: rootRestartRequestSource{state: state, store: store, census: census.Census}, writes: writes, sys: sys, checks: checks, getenv: os.Getenv, optDir: release.OptDir()} |
| + return p.run(ctx) |
| + }) |
| +} |
| + |
| +func restartRequestPass(legacy func() (bool, error), oldPass, root func() error) error { |
| + old, err := legacy() |
| + if err != nil { |
| + return err |
| + } |
| + if !old { |
| + return root() |
| + } |
| + return oldPass() |
| +} |
| + |
| +func (p rootRestartRequests) run(ctx context.Context) error { |
| + apps, err := p.source.inventory(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + retained, err := p.journal.Bindings() |
| + if err != nil { |
| + return err |
| + } |
| + census, err := p.source.census() |
| + if err != nil { |
| + return err |
| + } |
| + for _, b := range retained { |
| + if b.Box != census.Box { |
| + return os.ErrPermission |
| + } |
| + if _, ok := apps[b]; !ok { |
| + apps[b] = nil |
| + } |
| + } |
| + var failures []error |
| + for b, targets := range apps { |
| + body, id, readErr := p.source.store.Get(ctx, release.RestartGenKey(b.Account, b.App)) |
| + if errors.Is(readErr, os.ErrNotExist) { |
| + body, id, readErr = nil, release.RootRestartAbsent, nil |
| + } |
| + scopes, _, scopeErr := release.LoadRestartScopes(ctx, p.source.store, b.Account, b.App) |
| + in := release.RestartSnapshot{Complete: readErr == nil && scopeErr == nil, AppID: id, AppBody: body, Targets: targets} |
| + if scopes != nil { |
| + in.Scopes = *scopes |
| + } |
| + intent, err := p.journal.Observe(b, in) |
| + if err != nil { |
| + failures = append(failures, errors.Join(err, readErr, scopeErr)) |
| + continue |
| + } |
| + if intent == nil { |
| + continue |
| + } |
| + for _, progress := range intent.Targets { |
| + if progress.After != "" || progress.Failed { |
| + continue |
| + } |
| + if err := p.consume(ctx, b, intent.ID, progress); err != nil { |
| + failures = append(failures, err) |
| + } |
| + } |
| + if err := p.journal.Finish(b, intent.ID); err != nil { |
| + failures = append(failures, err) |
| + } |
| + } |
| + return errors.Join(failures...) |
| +} |
| + |
| +func (p rootRestartRequests) consume(ctx context.Context, b release.RestartBinding, id string, progress release.RestartProgress) error { |
| + target := progress.Target |
| + in, err := p.source.installation(ctx, target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if in.Empty || in.Box != b.Box || in.Account != b.Account || in.App != b.App { |
| + return os.ErrPermission |
| + } |
| + current, err := p.source.target(ctx, in) |
| + if err != nil { |
| + return err |
| + } |
| + if current != target { |
| + return os.ErrPermission |
| + } |
| + // A retained census never falls through the flag-off work provider. |
| + if !p.writes.Enabled { |
| + return os.ErrPermission |
| + } |
| + work, err := p.writes.Select(ctx, target.Host, "restart") |
| + if err != nil { |
| + return err |
| + } |
| + selected, err := hostbox.Selected(work) |
| + if err != nil { |
| + return err |
| + } |
| + if selected.Box != b.Box || selected.Captured != (hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}) { |
| + return os.ErrPermission |
| + } |
| + var before proofBinding |
| + check := func() error { |
| + fresh, err := p.source.target(ctx, in) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh != target { |
| + return os.ErrPermission |
| + } |
| + observed, err := p.checks.host(ctx, target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if observed.Route.Account != b.Account || observed.Route.App != b.App || observed.Route.Unit != target.Unit || observed.RecordKey+"@"+observed.RecordETag != target.Source || observed.Invocation == "" || observed.PID <= 0 || !observed.authorizedRecovery() { |
| + return os.ErrPermission |
| + } |
| + props, err := p.sys.Show(target.Unit) |
| + if err != nil { |
| + return err |
| + } |
| + if props.ActiveState != "active" || props.MainPID != observed.PID || props.InvocationID != observed.Invocation { |
| + return os.ErrPermission |
| + } |
| + if before.Source != "" && (observed.Source != before.Source || observed.Invocation != before.Invocation || observed.Version != before.Version || observed.Digest != before.Digest) { |
| + return os.ErrPermission |
| + } |
| + before = observed |
| + return nil |
| + } |
| + previous := progress.Before |
| + if previous == "" { |
| + if err := work.With(ctx, hostwrite.Identity{}, func() error { |
| + if err := check(); err != nil { |
| + return err |
| + } |
| + previous = before.Invocation |
| + return p.journal.Begin(b, id, target, previous) |
| + }); err != nil { |
| + return err |
| + } |
| + } |
| + // Re-enter admission after the durable intent. A move that starts during |
| + // the write cannot turn a persisted intent into permission to restart. |
| + return work.With(ctx, hostwrite.Identity{}, func() error { |
| + if err := check(); err != nil { |
| + return err |
| + } |
| + if progress.Before == "" { |
| + if err := p.sys.TryRestart(target.Unit); err != nil { |
| + return err |
| + } |
| + } |
| + // An ambiguous invocation cannot be dispatched again, even after reboot. |
| + opts := settleOpts{Timeout: unitSettleTimeout(p.getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second, RequireExe: true, WantDir: filepath.Dir(release.VersionPath(p.optDir, b.Account, before.Version, b.App)), Ready: p.checks.ready(before.Route), Now: p.checks.now, Sleep: p.checks.sleep} |
| + if err := settleUnit(p.sys, target.Unit, previous, opts); err != nil { |
| + if progress.Before == "" && restartEffectFailed(err) { |
| + props, showErr := p.sys.Show(target.Unit) |
| + failed, bindingErr := p.checks.host(ctx, target.Host) |
| + if showErr == nil && bindingErr == nil && props.ActiveState == "failed" && failed.Source == before.Source && failed.Version == before.Version && failed.Digest == before.Digest { |
| + return errors.Join(err, p.journal.Fail(b, id, target)) |
| + } |
| + } |
| + return err |
| + } |
| + after, err := p.checks.host(ctx, target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + fresh, err := p.source.target(ctx, in) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh != target || after.Source != before.Source || after.Version != before.Version || after.Digest != before.Digest || after.Invocation == previous || after.PID <= 0 { |
| + return os.ErrPermission |
| + } |
| + return p.journal.Complete(b, id, target, after.Invocation) |
| + }) |
| +} |
| + |
| +func logRestartRequestError(err error) { |
| + if err != nil { |
| + log.Printf("adopt: root restart requests: %v", err) |
| + } |
| +} |
diff --git a/internal/host/rootrestart_requests_source.go b/internal/host/rootrestart_requests_source.go| new file mode 100644 |
| index 000000000..ebe1edd5f |
| --- /dev/null |
| +++ b/internal/host/rootrestart_requests_source.go |
| @@ -0,0 +1,130 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type rootRestartRequestSource struct { |
| + state rootstate.Store |
| + store release.Store |
| + census func() (rootparticipation.Census, error) |
| +} |
| + |
| +func (s rootRestartRequestSource) installation(ctx context.Context, host string) (hostguard.RootInstallation, error) { |
| + g := hostguard.Guard{Root: hostguard.NewRootAuthority(s.state)} |
| + captured, err := g.Capture(host) |
| + if err != nil { |
| + return hostguard.RootInstallation{}, err |
| + } |
| + if captured.Stamp == 0 { |
| + return hostguard.RootInstallation{Empty: true}, nil |
| + } |
| + v, err := g.Root.View(ctx, host, captured) |
| + if err != nil { |
| + return hostguard.RootInstallation{}, err |
| + } |
| + c, err := s.census() |
| + in := v.Installation |
| + if err != nil { |
| + return in, err |
| + } |
| + if !c.Complete || c.NeverEnrolled || in.Empty || in.Box != c.Box || in.Enrollment != c.Enrollment || in.Migration != c.Migration || in.InventoryRevision == 0 { |
| + return in, os.ErrPermission |
| + } |
| + return in, nil |
| +} |
| + |
| +func (s rootRestartRequestSource) target(ctx context.Context, in hostguard.RootInstallation) (release.RestartTarget, error) { |
| + if in.Record != provision.RecordKey(in.Account, in.App, in.Host) { |
| + return release.RestartTarget{}, os.ErrPermission |
| + } |
| + rec, revision, err := provision.GetRecordETag(ctx, s.store, in.Account, in.App, in.Host) |
| + if err != nil { |
| + return release.RestartTarget{}, err |
| + } |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil || inc != in.Incarnation { |
| + return release.RestartTarget{}, os.ErrPermission |
| + } |
| + // The agent owns exec children. Only an explicit canonical unit enters |
| + // root's unit journal; socket shape cannot invent a unit binding. |
| + if rec.Unit == "" { |
| + return release.RestartTarget{}, nil |
| + } |
| + rt, err := rootRecordRoute(rec) |
| + if err != nil { |
| + return release.RestartTarget{}, err |
| + } |
| + if !release.Adoptable(rt) || revision == "" { |
| + return release.RestartTarget{}, os.ErrPermission |
| + } |
| + raw, err := json.Marshal(in) |
| + if err != nil { |
| + return release.RestartTarget{}, err |
| + } |
| + id := sha256.Sum256(raw) |
| + return release.RestartTarget{Host: in.Host, Source: in.Record + "@" + revision, Installation: hex.EncodeToString(id[:]), Unit: rt.Unit, Environment: rec.Environment, Hibernate: rec.HibernateOrDefault()}, nil |
| +} |
| + |
| +func (s rootRestartRequestSource) inventory(ctx context.Context) (map[release.RestartBinding][]release.RestartTarget, error) { |
| + if s.state == nil || s.store == nil || s.census == nil { |
| + return nil, os.ErrPermission |
| + } |
| + c, err := s.census() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !c.Complete || c.NeverEnrolled { |
| + return nil, os.ErrPermission |
| + } |
| + names, err := s.state.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := map[release.RestartBinding][]release.RestartTarget{} |
| + for _, name := range names { |
| + if strings.HasSuffix(name, ".lock") { |
| + continue |
| + } |
| + if !strings.HasSuffix(name, ".json") { |
| + return nil, os.ErrPermission |
| + } |
| + in, err := s.installation(ctx, strings.TrimSuffix(name, ".json")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if in.Empty { |
| + continue |
| + } |
| + // Sidecars use a different record family and have no root restart leg. |
| + if in.Record != provision.RecordKey(in.Account, in.App, in.Host) { |
| + acct, app, name, ok := provision.ParseSidecarKey(in.Record, map[string]bool{in.Account: true}) |
| + if ok && acct == in.Account && app == in.App && provision.ValidSidecarName(name) && provision.SidecarHost(name, app, acct) == in.Host { |
| + continue |
| + } |
| + return nil, os.ErrPermission |
| + } |
| + target, err := s.target(ctx, in) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if target.Host == "" { |
| + continue |
| + } |
| + b := release.RestartBinding{Box: c.Box, Account: in.Account, App: in.App} |
| + out[b] = append(out[b], target) |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/provision/plan14_e_write_census_test.go b/internal/provision/plan14_e_write_census_test.go| index 609de984d..53839474c 100644 |
| --- a/internal/provision/plan14_e_write_census_test.go |
| +++ b/internal/provision/plan14_e_write_census_test.go |
| @@ -2,6 +2,9 @@ package provision |
| // Track E overlays the reviewed Part 1 write census without changing its baseline. |
| var plan14EWriteCensusOverlay = []censusEntry{ |
| + {"internal/host/rootrestart_requests.go", "rootRestartRequests.consume", "key-site", "key:RecordKey", "0a9fbbe6f201077e276298b7e666e1ee2a345f1c420335290fec38a676b1ad84"}, |
| + {"internal/host/rootrestart_requests_source.go", "rootRestartRequestSource.inventory", "key-site", "key:ParseSidecarKey,key:RecordKey", "1ae4bd07fdbd2b0fccd29cc032b0de4a6c53bc52da7fcbc186a11f8b65b394f9"}, |
| + {"internal/host/rootrestart_requests_source.go", "rootRestartRequestSource.target", "key-site", "key:RecordKey", "301b62efbf0281f713cbed7fb15cd244b7d2c479882f4ad930c8d482b51177ff"}, |
| {"internal/historyreadback/readback.go", "publish", "nonrecord", "sel:Put", "2168932e34cc6f173fde4d4cf0e74a302217ade779c09cc2d1e850ffdab0cb48"}, |
| {"internal/release/roothistory_delivery.go", "PublishHistoryRecoveryRetirement", "nonrecord", "sel:Put", "ec499a70f267b5b551e918c7ebb7ce36745afa87f77160fe9dc826d8dbe3032b"}, |
| {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "56edaf01411de3813fe327973302b1df35e403d2870625fc341355b5bc5cfa0b"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index a93789c81..bd9342e77 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-e.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-e.tsv |
| @@ -1,5 +1,5 @@ |
| # Track E integration after merge3. Earlier baselines, overlays and legacy fingerprints are immutable. |
| -internal/host/agent.go runAdoptUnderRootRelease authority B3,E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. whole:Route@10480,whole:Route@17841,whole:Route@17877,whole:Route@7901 2cc358516c72149fd1cccf42f1e20765dba30ec8b98e6c83014fb002261b4d3a |
| +internal/host/agent.go runAdoptUnderRootRelease authority B3,E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. whole:Route@10700,whole:Route@18080,whole:Route@18116,whole:Route@8002 9fc9698cee7c2b88e3d38203a17e7a41512e800fa172ce5b19272e75c2c83ca5 |
| internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F,J1 E1 selects the checked private history, canonical named-unit provider and root mint inventory under release exclusion with claims off or on; reconstruction and restart consumption remain incomplete. file:Open@191 9cdd0a958f5924c9c8d8c5613fd665f2c96a194cd11322adb6f0050fb183b541 |
| internal/host/namedrecovery.go namedUnitRecovery.repair authority E1,J1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Open@2642,registry:Channel@1814,whole:Route@1060,whole:Route@1822 566bc61eafb118d9f6fba29228c372b18c8729549ed9a0bccf32c61b360eb9c3 |
| internal/host/namedrecovery_active.go namedUnitRecovery.recover authority B3,E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Open@5504,registry:Channel@2558,registry:Unit@216,registry:Unit@233,registry:Version@3618,registry:Version@3724,whole:Route@1478,whole:Route@202,whole:Route@2560,whole:Route@3544 dabec20a8ea93c7d72b6d6c3a30a1ae7c99ffcee4de4c0416fba699734739b92 |
| @@ -54,8 +54,8 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S |
| internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a |
| internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab |
| internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7 |
| -@coverage internal/host d2d36da953f0ca69559cbfe67b919e1cf2a66419c67b8c247177b3da8c7cdbdf |
| -@coverage internal/release 3bc50bded64402b272fe913562fce6f79828338e2356387a9bdc600961fc7153 |
| +@coverage internal/host b2bc360b179563924a103a527049fe2bfd013f3979f43d5585cf281059a0ce39 |
| +@coverage internal/release 41c455979764d29fd7801abfa4700e0d941341a954e37bbe1de00665ae081ec2 |
| @coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05 |
| internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154 |
| internal/host/legacyrestart.go restartEffectFailed authority B3 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding. b68bb65884a14fe0cf78a65f56ded4ea41584bba99af9195827d1ea191b340a9 |
| @@ -151,3 +151,14 @@ internal/release/rootrestart_history.go type RestartTarget authority E1 E14 rest |
| internal/release/rootrestart_history.go type RootRestarts authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. ae897b5a0d3f198ce8fbb4785167e9bb949a48be6b7c386d4b9710f38eee8a69 |
| internal/release/rootrestart_history.go type rootRestartRecord authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. c047024b6ac2b684e65d8728e338896c65712f531cb853596cb74c855c435b1e |
| internal/release/rootrestart_history.go validRootRestart authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 2298397712698e769cf455413f949dc7fd4fede1edf84b04ca8c5aaf0bdada86 |
| +internal/host/rootrestart_requests.go logRestartRequestError authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. de113a11aa6f342ee7c1199a96094dda68e25de58b13e4f2aa96ca026bf7b0d5 |
| +internal/host/rootrestart_requests.go productionRestartRequestPass authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. file:Open@218,file:Open@324,file:Open@472 b29fad52fafeb23a5d7fce0d34cde66c79aa1070e9844c78b9fa68135f80b872 |
| +internal/host/rootrestart_requests.go restartRequestPass authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. a8c176e0b5526f49b74a953c58c66bbbac794105d3555725d0c8d90b95ec8731 |
| +internal/host/rootrestart_requests.go rootRestartRequests.consume authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. registry:Account@1196,registry:App@1235,registry:Unit@1266,whole:Route@1205,whole:Route@1244,whole:Route@1275,whole:Route@2938 0a9fbbe6f201077e276298b7e666e1ee2a345f1c420335290fec38a676b1ad84 |
| +internal/host/rootrestart_requests.go rootRestartRequests.run authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. 55416b6e2303523ac666e2a38cc60964bd74dea0488767ba6f735be42e9516f0 |
| +internal/host/rootrestart_requests.go type rootRestartRequests authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. 2ab24e3f7fb2932ed8ac874f78bff6f2d76d9c052044a72ca8501cbd2264717e |
| +internal/host/rootrestart_requests_source.go rootRestartRequestSource.installation authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. 23eea263231be328beb687006862e773af38d7489e21fcc07ecbfc8107ecff22 |
| +internal/host/rootrestart_requests_source.go rootRestartRequestSource.inventory authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. 1ae4bd07fdbd2b0fccd29cc032b0de4a6c53bc52da7fcbc186a11f8b65b394f9 |
| +internal/host/rootrestart_requests_source.go rootRestartRequestSource.target authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. registry:Unit@1211,whole:Route@891 301b62efbf0281f713cbed7fb15cd244b7d2c479882f4ad930c8d482b51177ff |
| +internal/host/rootrestart_requests_source.go type rootRestartRequestSource authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. db7ff5c46b6db3fe88657fddf8b4e17324b896fce9b21042d7f0f63140c1d776 |
| +internal/release/rootrestart_history.go RootRestarts.Bindings authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. file:Read@324 d22b7e6b5710ca0025639395101933bec5ce0bd38768ebcf44a0805f1f159b08 |
diff --git a/internal/release/rootrestart_history.go b/internal/release/rootrestart_history.go| index 8a6c45ca7..4f67230ed 100644 |
| --- a/internal/release/rootrestart_history.go |
| +++ b/internal/release/rootrestart_history.go |
| @@ -23,6 +23,36 @@ const RootRestartsDir = "/var/lib/carlos-release/private/restarts" |
| // marks, registry rows or service RPC payloads establish these inputs. |
| type RootRestarts struct{ Store rootstate.Store } |
| +// Bindings retains apps whose captured work outlives their local installation. |
| +func (h *RootRestarts) Bindings() ([]RestartBinding, error) { |
| + if h == nil || h.Store == nil { |
| + return nil, os.ErrPermission |
| + } |
| + names, err := h.Store.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []RestartBinding |
| + for _, name := range names { |
| + if strings.HasSuffix(name, ".lock") { |
| + continue |
| + } |
| + raw, err := h.Store.Read(name) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r rootRestartRecord |
| + if strictCanonicalJSON(raw, &r) != nil || !validRootRestart(r, r.Binding) || rootRestartKey(r.Binding) != name { |
| + return nil, os.ErrPermission |
| + } |
| + if _, err := h.read(r.Binding); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, r.Binding) |
| + } |
| + return out, nil |
| +} |
| + |
| type RestartBinding struct { |
| Box string `json:"box"` |
| Account string `json:"account"` |