WIP claim Plan 14 track E checkpoint 3 (not for merge): E1 recovery linkage
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+815
−27
internal/host/agent.go+1 −0internal/host/plan14_history_recovery_process_test.go+182 −0internal/host/plan14_history_recovery_test.go+237 −0internal/host/roothistory_recovery.go+244 −0internal/host/rootrestart.go+13 −2internal/host/rootrollback.go+34 −12internal/host/rootrollback_resume.go+3 −0internal/provision/testdata/plan14-root-reads-merge.tsv+17 −5internal/release/adopt.go+9 −1internal/release/plan14_root_history_pass_test.go+32 −0internal/release/roothistory.go+19 −4internal/release/roothistory_pass.go+24 −3
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 358.421s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.030s
ok github.com/carlosframework/platform/cmd/moveprobe 93.251s
ok github.com/carlosframework/platform/internal/activator 232.660s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.395s
ok github.com/carlosframework/platform/internal/apperr 0.088s
ok github.com/carlosframework/platform/internal/applog 0.140s
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.729s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.192s
ok github.com/carlosframework/platform/internal/changelog 0.240s
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 367.585s
? 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.980s
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.158s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.296s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.124s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.018s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 85.874s
ok github.com/carlosframework/platform/internal/hostactors 0.387s
ok github.com/carlosframework/platform/internal/hostbox 2.208s
ok github.com/carlosframework/platform/internal/hostclaim 0.537s
ok github.com/carlosframework/platform/internal/hostevidence 0.008s
ok github.com/carlosframework/platform/internal/hostexec 0.495s
ok github.com/carlosframework/platform/internal/hostfence 0.033s
ok github.com/carlosframework/platform/internal/hostfiles 10.562s
ok github.com/carlosframework/platform/internal/hostfloor 0.008s
ok github.com/carlosframework/platform/internal/hostguard 0.388s
ok github.com/carlosframework/platform/internal/hostops 1.430s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.008s
ok github.com/carlosframework/platform/internal/hostquarantine 0.008s
ok github.com/carlosframework/platform/internal/hostrelocate 0.470s
ok github.com/carlosframework/platform/internal/hostresolve 0.138s
ok github.com/carlosframework/platform/internal/hostwrite 0.007s
ok github.com/carlosframework/platform/internal/infraview 0.009s
ok github.com/carlosframework/platform/internal/instancectl 1.711s
ok github.com/carlosframework/platform/internal/instanceexec 2.716s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.008s
ok github.com/carlosframework/platform/internal/instanceupdates 0.012s
ok github.com/carlosframework/platform/internal/layout 1.432s
ok github.com/carlosframework/platform/internal/lease 9.447s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
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.081s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.550s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.093s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.112s
ok github.com/carlosframework/platform/internal/moves/archive 0.778s
ok github.com/carlosframework/platform/internal/objstore 0.172s
ok github.com/carlosframework/platform/internal/ops 0.166s
ok github.com/carlosframework/platform/internal/park 0.199s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.297s
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.054s
ok github.com/carlosframework/platform/internal/relay 7.222s
ok github.com/carlosframework/platform/internal/release 20.402s
ok github.com/carlosframework/platform/internal/rollout 3.339s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.002s
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.004s
ok github.com/carlosframework/platform/internal/schedule 2.330s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.679s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.198s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.132s
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.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.556s
=== 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.024s
=== RUN TestT4StorageProbeMinio
2026/10/13 12:17:40 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE15F8E9D9BC34, 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 506ad5259..bd02c9f58 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4468,6 +4468,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| log.Fatalf("adopt: %v", err) |
| } |
| defer history.History.Store.Close() |
| + linkRootHistory(history, restartChecks, release.OptDir(), os.Getenv) |
| passOpts := []release.PassOption{release.WithRootHistory(history), 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), |
diff --git a/internal/host/plan14_history_recovery_process_test.go b/internal/host/plan14_history_recovery_process_test.go| new file mode 100644 |
| index 000000000..a682320b7 |
| --- /dev/null |
| +++ b/internal/host/plan14_history_recovery_process_test.go |
| @@ -0,0 +1,182 @@ |
| +package host |
| + |
| +import ( |
| + "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" |
| +) |
| + |
| +func TestPlan14HistoryRecoveryProcessCrash(t *testing.T) { |
| + if boundary := os.Getenv("PLAN14_HISTORY_CRASH"); boundary != "" { |
| + dir := os.Getenv("PLAN14_HISTORY_DIR") |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + storeDir := filepath.Join(dir, "deployment") |
| + rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + plan14PutRecord(t, release.NewDirStore(storeDir), *rec) |
| + h := seedHostRootHistory(t, rt) |
| + disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")} |
| + historyDisk := &rollbackDisk{dir: filepath.Join(dir, "history")} |
| + for _, d := range []*rollbackDisk{disk, historyDisk} { |
| + if err := os.Mkdir(d.dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for k, raw := range h.Store.(*rollbackMemory).files { |
| + if err := historyDisk.Write(k, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + h.Store = historyDisk |
| + deps := boundRootHistory(h, c.resolve, e.root.obs) |
| + c.attempts = func() (rootstate.Store, error) { return disk, nil } |
| + c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| + linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| + crash := func() { |
| + raw, 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"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Exit(74) |
| + } |
| + disk.afterWrite = func(k string, raw []byte) { |
| + if k != rollbackJournalKey(rt.Host) { |
| + return |
| + } |
| + var a rollbackAttempt |
| + if json.Unmarshal(raw, &a) != nil || a.History == nil { |
| + return |
| + } |
| + if boundary == "linked" && !a.Failed && !a.Recovering && !a.Settled { |
| + crash() |
| + } |
| + if boundary == "forward settled" && a.Settled { |
| + crash() |
| + } |
| + if boundary == "rollback pending" && a.Recovering && !a.Settled { |
| + crash() |
| + } |
| + } |
| + historyDisk.afterWrite = func(k string, raw []byte) { |
| + if boundary != "history settled" { |
| + return |
| + } |
| + var r struct { |
| + Binding release.HistoryBinding `json:"binding"` |
| + Attempt *release.HistoryAttempt `json:"attempt"` |
| + } |
| + if json.Unmarshal(raw, &r) == nil && r.Binding.Host == rt.Host && r.Attempt == nil && len(u.effects) > 0 { |
| + crash() |
| + } |
| + } |
| + u.crashNew = boundary != "forward running" && boundary != "forward unhealthy" && boundary != "forward settled" && boundary != "history settled" |
| + u.afterEffect = func() { |
| + if (boundary == "forward failed" || boundary == "forward running" || boundary == "forward unhealthy" || boundary == "rollback fails") && 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.WithRootHistory(deps), 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{"linked", "forward failed", "forward running", "forward unhealthy", "forward settled", "rollback pending", "rollback running", "history settled", "rollback fails"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + dir := t.TempDir() |
| + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestPlan14HistoryRecoveryProcessCrash$", "-test.count=1") |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "TMPDIR="+dir, "PLAN14_HISTORY_CRASH="+boundary, "PLAN14_HISTORY_DIR="+dir, "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 f rollbackProcessFixture |
| + if err := json.Unmarshal(raw, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := &plan14RestartUnits{props: f.Props, exe: f.Exe, opt: f.Opt, effects: f.Effects, badRollback: boundary == "rollback fails"} |
| + 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 || a.History == nil { |
| + t.Fatalf("missing linked intent: %+v %v", a, err) |
| + } |
| + now := a.Created.Add(time.Second) |
| + ready := func(registry.Route) func() error { |
| + return func() error { |
| + if boundary == "forward unhealthy" && u.exe == release.VersionPath(u.opt, "bab", "v2", "console") { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + } |
| + } |
| + h := &release.RootHistory{Store: &rollbackDisk{dir: filepath.Join(dir, "history")}} |
| + obs := proofObserver{resolve: source.resolve, sys: u, optDir: u.opt, procExe: func(int) string { return u.exe }, now: time.Now, ready: ready} |
| + deps := boundRootHistory(h, source.resolve, obs) |
| + c := rootRestartChecks{history: func() (*release.RootHistoryPass, error) { return deps, nil }, started: &sync.Map{}, attempts: func() (rootstate.Store, error) { return disk, nil }, prepareRollback: source.prepareRollback, rollback: source.rollback, host: source.forHost, ready: ready, 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{}) |
| + refs, refsErr := h.Refusals(a.HistoryBinding) |
| + if boundary == "rollback fails" { |
| + if err == nil || refsErr == nil { |
| + t.Fatalf("failed rollback settled: %v %v", err, refsErr) |
| + } |
| + 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("terminal rollback repeated: %v %v", err, u.effects) |
| + } |
| + if _, err := h.Refusals(a.HistoryBinding); err == nil { |
| + t.Fatal("terminal failure erased history intent") |
| + } |
| + return |
| + } |
| + if err != nil || refsErr != nil { |
| + t.Fatalf("resume: %v %v", err, refsErr) |
| + } |
| + forward := boundary == "forward running" || boundary == "forward settled" || boundary == "history settled" |
| + if forward && (len(refs) != 0 || u.exe != release.VersionPath(u.opt, "bab", "v2", "console")) { |
| + t.Fatalf("forward not recovered: %+v %s", refs, u.exe) |
| + } |
| + if !forward && (len(refs) != 1 || !refs[0].Recovered || refs[0].Version != "v2" || refs[0].RestoredVersion != "v1") { |
| + t.Fatalf("rollback deny missing: %+v", refs) |
| + } |
| + if boundary != "forward failed" && boundary != "forward unhealthy" && boundary != "rollback pending" && len(u.effects) != before { |
| + t.Fatal("recovery repeated an observed effect") |
| + } |
| + 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("replay: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/plan14_history_recovery_test.go b/internal/host/plan14_history_recovery_test.go| new file mode 100644 |
| index 000000000..9a6cdc687 |
| --- /dev/null |
| +++ b/internal/host/plan14_history_recovery_test.go |
| @@ -0,0 +1,237 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14HistoryRollbackLink(t *testing.T) { |
| + for _, scenario := range []string{"success", "rollback", "failed rollback", "intent crash", "link crash", "restart crash"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + h := seedHostRootHistory(t, rt) |
| + deps := boundRootHistory(h, c.resolve, e.root.obs) |
| + c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| + linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| + u.crashNew = scenario != "success" |
| + u.badRollback = scenario == "failed rollback" |
| + stopped := errors.New("crash") |
| + prepare := deps.Prepare |
| + deps.Prepare = func(ctx context.Context, b release.HistoryBinding, a release.HistoryAttempt, id hostwrite.Identity) error { |
| + if scenario == "intent crash" { |
| + panic(stopped) |
| + } |
| + if err := prepare(ctx, b, a, id); err != nil { |
| + return err |
| + } |
| + live, _ := release.LiveVersion(u.opt, rt.Host) |
| + if live != "v1" { |
| + t.Fatal("rollback captured after live effect") |
| + } |
| + s, _ := c.attempts() |
| + journal, err := readRollbackAttempt(s, rt.Host) |
| + if err != nil || journal.History == nil || journal.History.ID != a.ID { |
| + t.Fatalf("unlinked intent: %+v %v", journal, err) |
| + } |
| + if scenario == "link crash" { |
| + panic(stopped) |
| + } |
| + return nil |
| + } |
| + if scenario == "restart crash" { |
| + u.afterEffect = func() { panic(stopped) } |
| + } |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| + var passErr error |
| + func() { |
| + defer func() { |
| + if p := recover(); p != nil && p != stopped { |
| + panic(p) |
| + } |
| + }() |
| + passErr = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, |
| + release.WithRootHistory(deps), release.WithRestartFailurePropagation(), |
| + release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| + }() |
| + u.afterEffect = nil |
| + if scenario == "success" && passErr != nil { |
| + t.Fatal(passErr) |
| + } |
| + b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| + if scenario == "intent crash" || scenario == "failed rollback" { |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("unresolved attempt granted permission") |
| + } |
| + return |
| + } |
| + if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + refs, err := h.Refusals(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if scenario == "success" { |
| + if len(refs) != 0 { |
| + t.Fatalf("successful target refused: %+v", refs) |
| + } |
| + } else if len(refs) == 0 || refs[0].Version != "v2" || !refs[0].Recovered || refs[0].RestoredVersion != "v1" { |
| + t.Fatalf("rollback lost exact deny: %+v", refs) |
| + } |
| + 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("recovery replay: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type historyVisibleFailure struct { |
| + rootstate.Store |
| + armed bool |
| + visible []byte |
| +} |
| + |
| +func (s *historyVisibleFailure) Write(k string, raw []byte) error { |
| + if err := s.Store.Write(k, raw); err != nil { |
| + return err |
| + } |
| + var record struct { |
| + Binding release.HistoryBinding `json:"binding"` |
| + Attempt *release.HistoryAttempt `json:"attempt"` |
| + } |
| + if s.armed && json.Unmarshal(raw, &record) == nil && record.Binding.Host != "" && record.Attempt == nil { |
| + s.visible = append([]byte(nil), raw...) |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func TestPlan14HistorySettlementRetriesVisibleSync(t *testing.T) { |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + h := seedHostRootHistory(t, rt) |
| + fault := &historyVisibleFailure{Store: h.Store} |
| + h.Store = fault |
| + deps := boundRootHistory(h, c.resolve, e.root.obs) |
| + c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| + linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| + u.afterEffect = func() { fault.armed = true } |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| + if err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps)); err == nil { |
| + t.Fatal("failed settlement sync reported success") |
| + } |
| + if len(fault.visible) == 0 { |
| + t.Fatal("settlement did not become visible") |
| + } |
| + binding := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| + before := len(u.effects) |
| + if _, err := h.Refusals(binding); err == nil { |
| + t.Fatal("unsynced settlement granted mint") |
| + } |
| + if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err == nil { |
| + t.Fatal("recovery ignored failed sync") |
| + } |
| + if len(u.effects) != before { |
| + t.Fatal("sync recovery repeated restart") |
| + } |
| + want := append([]byte(nil), fault.visible...) |
| + fault.armed = false |
| + if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if refs, err := h.Refusals(binding); err != nil || len(refs) != 0 { |
| + t.Fatalf("durable settlement: %+v %v", refs, err) |
| + } |
| + if len(u.effects) != before { |
| + t.Fatal("durable recovery repeated restart") |
| + } |
| + found := false |
| + for _, raw := range fault.Store.(*rollbackMemory).files { |
| + if bytes.Equal(raw, want) { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatal("sync retry refreshed settlement evidence") |
| + } |
| +} |
| + |
| +func TestPlan14HistoryRecoveryRefusesChanges(t *testing.T) { |
| + for _, attack := range []string{"record", "unit", "attempt", "missing capability", "write failure", "stopped", "target"} { |
| + t.Run(attack, func(t *testing.T) { |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + h := seedHostRootHistory(t, rt) |
| + deps := boundRootHistory(h, c.resolve, e.root.obs) |
| + c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| + linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| + stopped := errors.New("crash") |
| + u.crashNew = true |
| + u.afterEffect = func() { panic(stopped) } |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| + func() { |
| + defer func() { |
| + if p := recover(); p != stopped { |
| + t.Fatalf("crash: %v", p) |
| + } |
| + }() |
| + _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps)) |
| + }() |
| + u.afterEffect = nil |
| + s, _ := c.attempts() |
| + a, err := readRollbackAttempt(s, rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "record": |
| + rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.RecordSeq++ |
| + plan14PutRecord(t, e.store, *rec) |
| + case "unit": |
| + if err := os.Rename(u.props.FragmentPath, u.props.FragmentPath+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(u.props.FragmentPath, []byte("changed"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "attempt": |
| + a.History.ID = "another-attempt" |
| + if err := writeRollbackState(s, rollbackJournalKey(rt.Host), a); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing capability": |
| + if err := s.Remove(rollbackCapabilityKey(rt.Host, a.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "write failure": |
| + h.Store.(*rollbackMemory).failWrite = true |
| + case "stopped": |
| + u.props.ActiveState, u.props.SubState = "inactive", "dead" |
| + case "target": |
| + if err := os.Remove(release.LiveLink(u.opt, rt.Host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + 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("unsafe recovery: %v %v", err, u.effects) |
| + } |
| + h.Store.(*rollbackMemory).failWrite = false |
| + if _, err := h.Refusals(a.HistoryBinding); err == nil { |
| + t.Fatal("failed recovery cleared pending history") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/roothistory_recovery.go b/internal/host/roothistory_recovery.go| new file mode 100644 |
| index 000000000..f20b6cb67 |
| --- /dev/null |
| +++ b/internal/host/roothistory_recovery.go |
| @@ -0,0 +1,244 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type historyRollbackLink struct { |
| + Binding release.HistoryBinding |
| + Attempt release.HistoryAttempt |
| +} |
| + |
| +func linkRootHistory(h *release.RootHistoryPass, checks rootRestartChecks, optDir string, getenv func(string) string) { |
| + h.Prepare = func(ctx context.Context, binding release.HistoryBinding, attempt release.HistoryAttempt, identity hostwrite.Identity) error { |
| + b, err := checks.host(ctx, binding.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if b.Source != attempt.Source || b.Route.Account != binding.Account || b.Route.App != binding.App { |
| + return os.ErrPermission |
| + } |
| + pending, err := h.History.Pending(binding) |
| + if err != nil || pending == nil || *pending != attempt { |
| + return errors.Join(err, os.ErrPermission) |
| + } |
| + s, err := checks.attempts() |
| + if err != nil { |
| + return err |
| + } |
| + defer s.Close() |
| + unlock, err := s.Lock(ctx, rollbackHostKey(binding.Host)+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + live, err := filepath.EvalSymlinks(release.LiveLink(optDir, binding.Host)) |
| + if err != nil || live != release.VersionPath(optDir, binding.Account, b.Version, binding.App) { |
| + return errors.Join(err, os.ErrPermission) |
| + } |
| + _, err = captureRollback(s, b, checks.now(), unitSettleTimeout(getenv), release.VersionPath(optDir, binding.Account, attempt.Version, binding.App), identity, historyRollbackLink{binding, attempt}) |
| + return err |
| + } |
| +} |
| + |
| +func historyRestartAttempt(s rootstate.Store, b proofBinding, c rootRestartChecks, target string, getenv func(string) string) (rollbackAttempt, error) { |
| + a, err := readRollbackAttempt(s, b.Route.Host) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return a, err |
| + } |
| + if err == nil && a.History != nil && !a.Settled { |
| + loaded, old, err := loadRollback(s, b.Route.Host, c.now()) |
| + if err != nil { |
| + return a, err |
| + } |
| + if loaded.ID != a.ID || a.Identity != c.identity || a.Target != target || old.Source != b.Source || old.Version != b.Version || old.Digest != b.Digest || old.Invocation != b.Invocation || old.PID != b.PID || old.Start != b.Start || a.Failed || a.Recovering { |
| + return a, os.ErrPermission |
| + } |
| + if err := c.checkHistoryAttempt(a); err != nil { |
| + return a, err |
| + } |
| + return a, nil |
| + } |
| + return captureRollback(s, b, c.now(), unitSettleTimeout(getenv), target, c.identity) |
| +} |
| + |
| +func (c rootRestartChecks) checkHistoryAttempt(a rollbackAttempt) error { |
| + if a.History == nil { |
| + return nil |
| + } |
| + if c.history == nil { |
| + return os.ErrPermission |
| + } |
| + h, err := c.history() |
| + if err != nil { |
| + return err |
| + } |
| + defer h.History.Store.Close() |
| + pending, err := h.History.Pending(a.HistoryBinding) |
| + if err != nil { |
| + return err |
| + } |
| + if pending == nil || *pending != *a.History { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func (c rootRestartChecks) settleHistory(ctx context.Context, a rollbackAttempt, rollback bool) error { |
| + if a.History == nil { |
| + return nil |
| + } |
| + if c.history == nil { |
| + return os.ErrPermission |
| + } |
| + h, err := c.history() |
| + if err != nil { |
| + return err |
| + } |
| + defer h.History.Store.Close() |
| + pending, err := h.History.Pending(a.HistoryBinding) |
| + if err != nil { |
| + return err |
| + } |
| + if pending == nil { |
| + return nil |
| + } |
| + if *pending != *a.History { |
| + return os.ErrPermission |
| + } |
| + b, err := c.host(ctx, a.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if b.Source != a.History.Source || b.Route.Account != a.HistoryBinding.Account || b.Route.App != a.HistoryBinding.App { |
| + return os.ErrPermission |
| + } |
| + if !rollback { |
| + if b.Version != a.History.Version || b.Digest != a.History.SHA256 { |
| + return os.ErrPermission |
| + } |
| + return h.CompleteAttempt(ctx, b.Route, *a.History) |
| + } |
| + if b.Version != a.Previous || b.Digest != a.PreviousDigest { |
| + return os.ErrPermission |
| + } |
| + if _, err := h.Installed(ctx, b.Route, b.Version); err != nil { |
| + return err |
| + } |
| + again, err := c.host(ctx, a.Host) |
| + if err != nil || again.Source != b.Source || again.Version != b.Version || again.Digest != b.Digest || again.PID != b.PID || again.Start != b.Start || again.Invocation != b.Invocation { |
| + return errors.Join(err, os.ErrPermission) |
| + } |
| + ref := release.Refusal{Host: a.Host, App: b.Route.App, Version: a.History.Version, SHA256: a.History.SHA256, Platform: a.History.Platform, PointerPromotedAt: a.History.PromotedAt, RefusedAt: c.now().UTC(), Kind: release.RefusalNotUp, Recovered: true, RestoredVersion: b.Version} |
| + return h.History.Settle(a.HistoryBinding, a.History.ID, &ref) |
| +} |
| + |
| +func resumeHistoryRestart(ctx context.Context, sys unitSystem, optDir string, getenv func(string) string, checks rootRestartChecks, writes hostwrite.Config, s rootstate.Store, a rollbackAttempt) error { |
| + checks.verifyOnly = false |
| + if checks.history == nil { |
| + return os.ErrPermission |
| + } |
| + h, err := checks.history() |
| + if err != nil { |
| + return err |
| + } |
| + pending, err := h.History.Pending(a.HistoryBinding) |
| + closeErr := h.History.Store.Close() |
| + if err != nil || closeErr != nil { |
| + return errors.Join(err, closeErr) |
| + } |
| + if pending == nil { |
| + return settleRollbackAttempt(s, a) |
| + } |
| + if *pending != *a.History { |
| + return os.ErrPermission |
| + } |
| + if a.Settled { |
| + if a.Failed || a.Recovering { |
| + return nil |
| + } |
| + return checks.settleHistory(ctx, a, false) |
| + } |
| + _, old, err := loadRollback(s, a.Host, checks.now()) |
| + 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) } |
| + var forward bool |
| + err = checks.effect(func() error { |
| + var err error |
| + old, err = checks.prepareRollback(ctx, old) |
| + if err != nil { |
| + return err |
| + } |
| + p, err := sys.Show(old.Route.Unit) |
| + if err != nil { |
| + return err |
| + } |
| + if p.ActiveState == "inactive" { |
| + return errUnitStopped |
| + } |
| + live, err := filepath.EvalSymlinks(release.LiveLink(optDir, a.Host)) |
| + previous := release.VersionPath(optDir, old.Route.Account, old.Version, old.Route.App) |
| + if err != nil || (live != previous && live != a.Target) { |
| + return os.ErrPermission |
| + } |
| + if p.ActiveState == "active" && p.MainPID > 0 { |
| + observed, err := checks.host(ctx, a.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if observed.Source != old.Source { |
| + return os.ErrPermission |
| + } |
| + if live == a.Target && observed.Version == a.History.Version && observed.Digest == a.History.SHA256 && !a.Failed && !a.Recovering { |
| + forward, checks.verifyOnly = true, true |
| + return nil |
| + } |
| + if observed.Version == old.Version && observed.Digest == old.Digest { |
| + checks.verifyOnly = true |
| + } else if !a.Failed || observed.Version != a.History.Version || observed.Digest != a.History.SHA256 { |
| + return os.ErrPermission |
| + } |
| + } 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 != previous { |
| + return release.PointLive(optDir, a.Host, old.Route.Account, old.Version, old.Route.App) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if err := restartBoundUnit(sys, optDir, getenv, old, !forward, nil, checks); err != nil { |
| + if forward { |
| + a.Failed = true |
| + if writeErr := writeRollbackState(s, rollbackJournalKey(a.Host), a); writeErr != nil { |
| + return errors.Join(err, writeErr) |
| + } |
| + return resumeHistoryRestart(ctx, sys, optDir, getenv, checks, writes, s, a) |
| + } |
| + return errors.Join(err, settleRollbackAttempt(s, a)) |
| + } |
| + if err := checks.settleHistory(ctx, a, !forward); err != nil { |
| + return err |
| + } |
| + return settleRollbackAttempt(s, a) |
| +} |
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go| index f74ff68bd..e22443858 100644 |
| --- a/internal/host/rootrestart.go |
| +++ b/internal/host/rootrestart.go |
| @@ -15,6 +15,7 @@ import ( |
| ) |
| type rootRestartChecks struct { |
| + history func() (*release.RootHistoryPass, error) |
| dispatch func(context.Context, registry.Route) (bool, error) |
| resolve proofBindingResolver |
| started *sync.Map |
| @@ -36,6 +37,13 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck |
| return productionProofBindingSource(s, sys, optDir), err |
| } |
| return rootRestartChecks{started: &sync.Map{}, |
| + history: func() (*release.RootHistoryPass, error) { |
| + s, err := openDeployStore(context.Background()) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return openRootHistory(s, optDir) |
| + }, |
| dispatch: func(ctx context.Context, rt registry.Route) (bool, error) { |
| s, err := source(ctx) |
| if err != nil { |
| @@ -114,7 +122,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| if err != nil { |
| return err |
| } |
| - attempt, err := captureRollback(s, binding, c.now(), unitSettleTimeout(getenv), target, c.identity) |
| + attempt, err := historyRestartAttempt(s, binding, c, target, getenv) |
| if err != nil { |
| return err |
| } |
| @@ -287,7 +295,10 @@ func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, ge |
| if err := writeRollbackState(s, rollbackJournalKey(host), attempt); err != nil { |
| return err |
| } |
| - return restartBoundUnit(sys, optDir, getenv, binding, true, nil, c) |
| + if err := restartBoundUnit(sys, optDir, getenv, binding, true, nil, c); err != nil { |
| + return err |
| + } |
| + return c.settleHistory(context.Background(), attempt, true) |
| } |
| } |
diff --git a/internal/host/rootrollback.go b/internal/host/rootrollback.go| index 091bf2c2a..4508f4db5 100644 |
| --- a/internal/host/rootrollback.go |
| +++ b/internal/host/rootrollback.go |
| @@ -12,6 +12,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| @@ -19,15 +20,19 @@ const rollbackMargin = 30 * time.Second |
| const rootRestartState = "/var/lib/carlos-release/private/restarts" |
| type rollbackAttempt struct { |
| - ID string |
| - Host string |
| - Created time.Time |
| - Deadline time.Time |
| - Failed bool |
| - Recovering bool |
| - Settled bool |
| - Target string |
| - Identity hostwrite.Identity |
| + ID string |
| + Host string |
| + Created time.Time |
| + Deadline time.Time |
| + Failed bool |
| + Recovering bool |
| + Settled bool |
| + Target string |
| + Previous string `json:",omitempty"` |
| + PreviousDigest string `json:",omitempty"` |
| + Identity hostwrite.Identity |
| + History *release.HistoryAttempt `json:",omitempty"` |
| + HistoryBinding release.HistoryBinding `json:",omitempty"` |
| } |
| type rollbackCapability struct { |
| @@ -73,7 +78,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, target string, identity hostwrite.Identity) (rollbackAttempt, error) { |
| +func captureRollback(s rootstate.Store, b proofBinding, now time.Time, timeout time.Duration, target string, identity hostwrite.Identity, link ...historyRollbackLink) (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 |
| } |
| @@ -91,7 +96,17 @@ 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), Target: target, Identity: identity} |
| + a := rollbackAttempt{ID: hex.EncodeToString(id), Host: b.Route.Host, Created: now, Deadline: now.Add(2 * timeout), Target: target, Identity: identity, Previous: b.Version, PreviousDigest: b.Digest} |
| + if len(link) > 1 { |
| + return rollbackAttempt{}, os.ErrPermission |
| + } |
| + if len(link) == 1 { |
| + l := link[0] |
| + if l.Binding.Host != b.Route.Host || l.Binding.Account != b.Route.Account || l.Binding.App != b.Route.App || l.Attempt.Source != b.Source { |
| + return rollbackAttempt{}, os.ErrPermission |
| + } |
| + a.History, a.HistoryBinding = &l.Attempt, l.Binding |
| + } |
| if err := writeRollbackState(s, rollbackCapabilityKey(a.Host, a.ID), rollbackCapability{Attempt: a, Binding: b}); err != nil { |
| return rollbackAttempt{}, err |
| } |
| @@ -114,7 +129,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.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 { |
| + if json.Unmarshal(b, &cap) != nil || cap.Attempt.ID != a.ID || cap.Attempt.HistoryBinding != a.HistoryBinding || !sameHistoryAttempt(cap.Attempt.History, a.History) || cap.Attempt.Previous != a.Previous || cap.Attempt.PreviousDigest != a.PreviousDigest || 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 |
| @@ -167,3 +182,10 @@ func removeRollbackCapabilities(s rootstate.Store, host string) error { |
| } |
| return nil |
| } |
| + |
| +func sameHistoryAttempt(a, b *release.HistoryAttempt) bool { |
| + if a == nil || b == nil { |
| + return a == nil && b == nil |
| + } |
| + return *a == *b |
| +} |
diff --git a/internal/host/rootrollback_resume.go b/internal/host/rootrollback_resume.go| index 65560359d..66145309b 100644 |
| --- a/internal/host/rootrollback_resume.go |
| +++ b/internal/host/rootrollback_resume.go |
| @@ -53,6 +53,9 @@ func resumeRootRestart(ctx context.Context, sys unitSystem, optDir string, geten |
| if err != nil || current.ID != expected.ID { |
| return errors.Join(err, os.ErrPermission) |
| } |
| + if current.History != nil { |
| + return resumeHistoryRestart(ctx, sys, optDir, getenv, checks, writes, s, current) |
| + } |
| if current.Settled { |
| return settleRollbackAttempt(s, current) |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv| index 84134e833..a79295b3c 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-merge.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-merge.tsv |
| @@ -251,7 +251,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/console 41714f3779b343460800cb25b31782dc7d5c1e83b2aacf7d8d0a28f39c47bf76 |
| @coverage internal/console/templates 6492b7455fa3315105339cca03edb4372ac0d9bf746c018e13a308cf0b74cc05 |
| @coverage internal/edgesfile 553af147ca4882b8425afddf1ed250ee40a6cac92361d667cb4a690393be5272 |
| -@coverage internal/host 49f8b9b24fa1172af522c534f542eb17cbfae2d614b2c6fc01d1e3d773e18c22 |
| +@coverage internal/host 50e42fa6fee6618e66b88faab0bfa1a595a04215a33459c605d81b85cae349b3 |
| @coverage internal/hostbox afb08c287eaccb43adde1c488bd122482711ce4e29ecb675c9428f27130c3d93 |
| @coverage internal/hostclaim feaf701db4b49ce92733b9d6c1fa1c0ae8efc6e13439ce201fca1a9e24dcf927 |
| @coverage internal/hostfiles 554285c123758375c13388b2fa42f654819392dac0b027816a713548ec17dcc6 |
| @@ -264,7 +264,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/moves 5218cf9bd31f6347ae49a595a32455f441b1718ecfa0a9559f99036d32f56498 |
| @coverage internal/provision a5c7116ee1a063ecdbe8bd5ea1832370ce66960ab13cc7afd34c0973dd21b457 |
| @coverage internal/registry bb6028d50884545209c11e65131dc3389a0991c9a94a0954965e0df4b82b3232 |
| -@coverage internal/release 1810c133821ef7df5cfd0ad3a4248b112f2350aaabafe44299a3c1dc5b1d56ea |
| +@coverage internal/release a3d5d29063ae63c9cb6aa4dbec657ad7ef9953b1bf410d9176afd84982e02273 |
| @coverage internal/sim/hostlife eaac101ea7970c74fee621e5cd7515ec7e4907bc5874c65eab86666af0f7dc61 |
| @coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976 |
| @coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6 |
| @@ -285,7 +285,7 @@ internal/rootparticipation/ledger.go Ledger.idle authority D D3 enumerates root |
| internal/hostexec/rootclient.go requestIdentity authority D D1 supplies collision-resistant request correlation only; root authenticates peer and authority independently. Production client integration pending. file:Read@73 2acec351fdc4ab671e65b4a9bb5017aeb094fbf78fad8e34d713e48a2846c158 |
| internal/hostexec/rootserving.go RootBroker.servingView authority D D1 reads only completed root census and settled root participation under transaction exclusion, rechecks claim authority and peer identity, and returns no lease or token. Production client selection is pending. file:Read@441 9a57720834b3d7c661854196cf0ed1261c643673b5ba900c38987a162f5de0bb |
| internal/release/roothistory.go RootHistory.read authority E1 Root per-binding refusal/program completeness and pending attempt state; strict decode and exact durable recommit before authority reads. E1 production integration is selected but incomplete. file:Read@183 7ad6f4711ce92da81c19dfe5d7740bb0058b6689afcde5a89c34b9a2491a82a3 |
| -internal/host/agent.go runAdoptUnderRootRelease authority C,J1,E1 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. whole:Route@10418,whole:Route@17293,whole:Route@17329,whole:Route@7846 019dbd2e8726d51c51f0b00a621876de212156c838abaa216cd9652a38f5b0ff |
| +internal/host/agent.go runAdoptUnderRootRelease authority C,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. whole:Route@10418,whole:Route@17363,whole:Route@17399,whole:Route@7846 2f5e604ccd70ecaedbc48c8c2549164cd3c445a777812964331dc0c0de723c02 |
| 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 3dbb187bf1456f5343bbaa540d0f5886aa073d3319d0538e0827488e2b202120 |
| internal/host/proofroot.go proofRoot.mint authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. registry:Account@136,registry:App@1338,registry:App@1960,registry:App@2075,registry:App@2279,registry:App@316,registry:App@934,registry:Host@1135,registry:Host@1259,registry:Host@1599,registry:Host@2066,registry:Host@2189,registry:Host@287,registry:Host@469,registry:Host@622,whole:Route@129,whole:Route@144 8c4e2457e74ac917a5be303d74493247190e344585da622e989048fd0e00e61a |
| internal/host/proofroot.go proofRoot.observeAndRecord authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. file:Readlink@108,registry:Account@443,registry:App@468,registry:Host@151,registry:Host@657,whole:Route@186,whole:Route@453,whole:Route@478 88065f1cfe7ad31178ba65c2672d8dd1e25fab26820290368fc063907f7b3b98 |
| @@ -293,11 +293,11 @@ internal/host/roothistory.go boundRootHistory authority E1 E1 checked private st |
| internal/host/roothistory.go openRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@104 b3be056c0d2ff1eb7f2ff031ff3e201ba8569abb5dff827cb195b4a5a0a4aafd |
| internal/host/roothistory.go recordRootProgram authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. whole:Route@151,whole:Route@297,whole:Route@398 fbbc9451c39309090580486d75da6120b02e1ba054157159846bea057ae0c80f |
| internal/release/adopt.go PassFilter authority B3,E3,J1,E1 E1 root history and bake selection, retained denies, signed program consent, durable intent before effects and independently observed settlement; B3 crash linkage remains incomplete. registry:App@24918,registry:App@27375,registry:App@27867,registry:App@28104,registry:App@29511,registry:App@4713,registry:App@7738,registry:App@7879,registry:Hibernate@27750,registry:Host@1484,registry:Host@1551,registry:Host@1647,registry:Host@1682,registry:Host@27920,registry:Host@28836,registry:Host@29564,registry:UpdateSettings@7616,whole:Route@1098,whole:Route@1122,whole:Route@1153,whole:Route@1210,whole:Route@1296,whole:Route@1318,whole:Route@1418,whole:Route@2403,whole:Route@24913,whole:Route@27370,whole:Route@27628,whole:Route@27650,whole:Route@27697,whole:Route@27715,whole:Route@27725,whole:Route@27768,whole:Route@27789,whole:Route@27802,whole:Route@27862,whole:Route@27888,whole:Route@27962,whole:Route@28001,whole:Route@28017,whole:Route@28045,whole:Route@28099,whole:Route@28125,whole:Route@28878,whole:Route@28932,whole:Route@28948,whole:Route@29455,whole:Route@29506,whole:Route@29532,whole:Route@29606,whole:Route@29641,whole:Route@29657,whole:Route@3050,whole:Route@4635,whole:Route@4657,whole:Route@4708,whole:Route@4734,whole:Route@7567,whole:Route@7681,whole:Route@7726,whole:Route@7874 3adae93bfe6c7c12d3c8a78c3a6af528d7f5e3b538ab376b3653ec60f5871e02 |
| -internal/release/adopt.go adoptHost authority B3,E1,E2,E3,E4,J1 E1 root history and bake selection, retained denies, signed program consent, durable intent before effects and independently observed settlement; B3 crash linkage remains incomplete. registry:App@10601,registry:App@10761,registry:App@11790,registry:App@12514,registry:App@14076,registry:App@18796,registry:App@24060,registry:App@3114,registry:App@31667,registry:App@6423,registry:App@7724,registry:App@7918,registry:Hibernate@32528,registry:Host@10481,registry:Host@10514,registry:Host@10614,registry:Host@10774,registry:Host@10876,registry:Host@10908,registry:Host@10973,registry:Host@11423,registry:Host@11556,registry:Host@11986,registry:Host@12112,registry:Host@12604,registry:Host@13380,registry:Host@13848,registry:Host@14062,registry:Host@14404,registry:Host@14782,registry:Host@18444,registry:Host@18552,registry:Host@18764,registry:Host@19488,registry:Host@19646,registry:Host@20108,registry:Host@20160,registry:Host@20307,registry:Host@20394,registry:Host@2075,registry:Host@21500,registry:Host@21744,registry:Host@22404,registry:Host@2264,registry:Host@22789,registry:Host@22930,registry:Host@2345,registry:Host@24028,registry:Host@24155,registry:Host@25497,registry:Host@26147,registry:Host@29380,registry:Host@29998,registry:Host@30323,registry:Host@30397,registry:Host@30650,registry:Host@3127,registry:Host@31839,registry:Host@32006,registry:Host@32113,registry:Host@32341,registry:Host@32484,registry:Host@33229,registry:Host@33355,registry:Host@33443,registry:Host@4248,registry:Host@5211,registry:Host@5316,registry:Host@6535,registry:Host@6583,registry:Host@6700,registry:Host@6732,registry:Host@7610,registry:Host@7778,registry:Host@7904,registry:Host@8023,registry:Host@8501,registry:Host@8888,registry:Host@9114,registry:Host@9210,registry:Host@9301,registry:Kind@552,registry:Unit@577,whole:Route@10596,whole:Route@10756,whole:Route@11780,whole:Route@12504,whole:Route@14044,whole:Route@14609,whole:Route@14758,whole:Route@15912,whole:Route@16215,whole:Route@16371,whole:Route@18701,whole:Route@18786,whole:Route@21229,whole:Route@21628,whole:Route@2176,whole:Route@24050,whole:Route@28951,whole:Route@3109,whole:Route@31815,whole:Route@3554,whole:Route@6418,whole:Route@9738 538ddbcbaacceb145ebfb0ec797d71938e7a6c3c223459af1fabb8bf06a12808 |
| +internal/release/adopt.go adoptHost authority B3,E1,E2,E3,E4,J1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:App@10601,registry:App@10761,registry:App@11790,registry:App@12514,registry:App@14076,registry:App@19077,registry:App@24341,registry:App@3114,registry:App@31948,registry:App@6423,registry:App@7724,registry:App@7918,registry:Hibernate@32809,registry:Host@10481,registry:Host@10514,registry:Host@10614,registry:Host@10774,registry:Host@10876,registry:Host@10908,registry:Host@10973,registry:Host@11423,registry:Host@11556,registry:Host@11986,registry:Host@12112,registry:Host@12604,registry:Host@13380,registry:Host@13848,registry:Host@14062,registry:Host@14404,registry:Host@14994,registry:Host@18725,registry:Host@18833,registry:Host@19045,registry:Host@19769,registry:Host@19927,registry:Host@20389,registry:Host@20441,registry:Host@20588,registry:Host@20675,registry:Host@2075,registry:Host@21781,registry:Host@22025,registry:Host@2264,registry:Host@22685,registry:Host@23070,registry:Host@23211,registry:Host@2345,registry:Host@24309,registry:Host@24436,registry:Host@25778,registry:Host@26428,registry:Host@29661,registry:Host@30279,registry:Host@30604,registry:Host@30678,registry:Host@30931,registry:Host@3127,registry:Host@32120,registry:Host@32287,registry:Host@32394,registry:Host@32622,registry:Host@32765,registry:Host@33510,registry:Host@33636,registry:Host@33724,registry:Host@4248,registry:Host@5211,registry:Host@5316,registry:Host@6535,registry:Host@6583,registry:Host@6700,registry:Host@6732,registry:Host@7610,registry:Host@7778,registry:Host@7904,registry:Host@8023,registry:Host@8501,registry:Host@8888,registry:Host@9114,registry:Host@9210,registry:Host@9301,registry:Kind@552,registry:Unit@577,whole:Route@10596,whole:Route@10756,whole:Route@11780,whole:Route@12504,whole:Route@14044,whole:Route@14609,whole:Route@14970,whole:Route@16193,whole:Route@16496,whole:Route@16652,whole:Route@18982,whole:Route@19067,whole:Route@21510,whole:Route@2176,whole:Route@21909,whole:Route@24331,whole:Route@29232,whole:Route@3109,whole:Route@32096,whole:Route@3554,whole:Route@6418,whole:Route@9738 6767d178b99cc164cb17b78e707127ef47942717d219da593b03ce186da4dfe1 |
| internal/release/rollback.go writeGroupRefusals authority E1 E1 group refusal writer persists root denial before its diagnostic projection; pending attempt remains restrictive. registry:App@801,whole:Route@783,whole:Route@882,whole:Route@888,whole:Route@952 301386c9d57f9f13db3c31a6cf3a26a44c7ca53ff8d98d910c10a7e8530b8795 |
| internal/release/rootbake.go RootHistory.LoadBake authority E1 E1 strict root bake and app completeness revisions; backward clocks refuse and visible records are durably recommitted. file:Read@191 8843a3ecde0ff16e87f21a668d4b5d678da850098589795343a8d189dd029f04 |
| internal/release/roothistory_app.go RootHistory.AppRefusals authority E1 E1 explicit app inventory plus retained host records and per-host completeness; missing, extra, malformed or pending histories refuse minting. file:Read@227,file:Read@749 1c198ebb982ec81d54cbec4c1d7e85019ce1209bd661945d4b9e2a3152b3cced |
| -internal/release/roothistory_pass.go RootHistoryPass.begin authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. file:Read@243,whole:Route@169 c7acc63ed846f2009207c3f1a09507df34aac0563662fb13162040a28440b32d |
| +internal/release/roothistory_pass.go RootHistoryPass.begin authority E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@607,file:Prepare@638,file:Read@292,whole:Route@218 dde605d1d382309a8546c7fb4a78e276909bc7741a83b93c93d0f2ec9c783004 |
| internal/release/roothistory_pass.go RootHistoryPass.complete authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@205,whole:Route@278,whole:Route@350 10a2d72856ad28919399878857ee8d40e60877b86f76ba12d6f98d456001ccd2 |
| internal/release/roothistory_pass.go RootHistoryPass.recheck authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@133 b335ffd791175d39f69451d7f55b9fb59674cacd48ff6221ad3edf9719208038 |
| internal/release/roothistory_pass.go RootHistoryPass.resolve authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:App@394,registry:Host@341,whole:Route@240,whole:Route@378 7a0d3ca9f00fedcb219693d9ee98a68d03ae67e83b0efd42a943eccebadc7105 |
| @@ -305,3 +305,15 @@ internal/release/roothistory_pass.go RootHistoryPass.selectRoute authority E1 E1 |
| internal/release/roothistory_pass.go passConfig.bindProof authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@214,whole:Route@277,whole:Route@353 395b72a300204af57355f0d6d037438b1210798f17c26efa9cd3dbef1f098806 |
| internal/release/roothistory_pass.go passConfig.recheckHistory authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@184,whole:Route@162 2a7c830d3889757864bf69ddc17a9ee1cff2151dd27c90e69c9b6e4f85854332 |
| internal/release/roothistory_pass.go passConfig.recordRefusal authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@151,whole:Route@241 298ca88f7dc02be7779aaa527990b4e4548c1fe5498fba0272a6e7041b236354 |
| +internal/host/roothistory_recovery.go historyRestartAttempt authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:Host@187,registry:Host@361,whole:Route@189,whole:Route@363 13acb4429428da1c1d4f96514bf86d832af6e32acb9178f1d2830601a1303fa5 |
| +internal/host/roothistory_recovery.go linkRootHistory authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@845,file:Prepare@121,registry:Account@374,registry:App@412,whole:Route@376,whole:Route@414 b4c36059dcd6aca69f585265ff22cd7439480319fa4b37d8c7979a35cd444b24 |
| +internal/host/roothistory_recovery.go resumeHistoryRestart authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@1333,registry:Account@1431,registry:Account@2502,registry:App@1463,registry:App@2534,registry:Unit@1202,whole:Route@1206,whole:Route@1435,whole:Route@1467,whole:Route@2506,whole:Route@2538 721b697454513f08a6faeef4360913a27e4fbe1d6d0286c925f6cf123f014492 |
| +internal/host/roothistory_recovery.go rootRestartChecks.settleHistory authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:Account@563,registry:App@1334,registry:App@610,whole:Route@1336,whole:Route@565,whole:Route@612,whole:Route@828,whole:Route@974 a43e6c47441fa671759bde8a235917c1335efe9b1563b935587ccaed48a9eb76 |
| +internal/host/rootrestart.go checkedUnitRestart authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@1023,registry:Host@1070,registry:Host@1273,registry:Host@1809,registry:Host@510,registry:Unit@1691,registry:Unit@711,whole:Route@1699,whole:Route@638,whole:Route@719 46858f40c42310417a10e1fe333a299173ce576dd8a48a12dcbdc87a424d4f4b |
| +internal/host/rootrestart.go productionRootRestartChecks authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Open@722,whole:Route@1354,whole:Route@1621,whole:Route@660 42884d03f345975518948b4947473dd268a7ef344b0c3d054ccf578b55a92e18 |
| +internal/host/rootrestart.go rollbackUnitRestart authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:Unit@904,whole:Route@912 ffb4fbd89e902e650bbc24528bd434e4acfcc207a445cc6996f4f93b217db5ea |
| +internal/host/rootrollback.go captureRollback authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Read@807,registry:Account@1233,registry:App@1269,registry:Host@1196,registry:Host@444,registry:Host@926,whole:Route@1198,whole:Route@1235,whole:Route@1271,whole:Route@446,whole:Route@928 bda2805292bba2046ebd04dad3cf9ceafd9d2793b8fa459cb073e4597332639d |
| +internal/host/rootrollback.go loadRollback authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Read@451,registry:Host@1014,whole:Route@1026 6929d919e83b02b708bb4d45b53617a94502fb85e44f8914b869a0a2b90b7eda |
| +internal/host/rootrollback_resume.go resumeRootRestart authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@1384,registry:Account@1477,registry:Account@1997,registry:App@1505,registry:App@2025,registry:Unit@1255,whole:Route@1257,whole:Route@1479,whole:Route@1507,whole:Route@1999,whole:Route@2027 d51ee3af37b32715f6ddc4d7e70d9dff0eb5d9a50af15cb3bb331d8a796d24a6 |
| +internal/release/roothistory_pass.go RootHistoryPass.CompleteAttempt authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. whole:Route@136,whole:Route@255 97d46675209fa1d5ba064afded9b8b3265b741be9a30dfec6efab01a745d1d82 |
| +internal/release/roothistory_pass.go type RootHistoryPass authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@209 fcf30eb478e7f66189021aee547518c518fa68cd53aee153e026af82eca392a8 |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 6ebbbb6d2..31afbea08 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -1496,7 +1496,15 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| } |
| var historyAttempt string |
| if cfg.requireRootHistory { |
| - historyAttempt, err = cfg.rootHistory.begin(ctx, rt, cfg.historySelected[rt.Host], ver, now) |
| + if m == nil || m.Artifacts[adoptPlatform].SHA256 == "" { |
| + cfg.historyError(os.ErrPermission) |
| + return |
| + } |
| + historyIdentity := id |
| + if !cfg.hostWrites.Enabled { |
| + historyIdentity = hostwrite.Identity{} |
| + } |
| + historyAttempt, err = cfg.rootHistory.begin(ctx, rt, cfg.historySelected[rt.Host], sel, strings.ToLower(m.Artifacts[adoptPlatform].SHA256), now, historyIdentity) |
| if err != nil { |
| cfg.historyError(err) |
| return |
diff --git a/internal/release/plan14_root_history_pass_test.go b/internal/release/plan14_root_history_pass_test.go| index 9fe128469..a124499ad 100644 |
| --- a/internal/release/plan14_root_history_pass_test.go |
| +++ b/internal/release/plan14_root_history_pass_test.go |
| @@ -11,6 +11,38 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| +func TestPlan14RootHistoryMissingManifest(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + seedRootBake(t, h, b, time.Now().UTC().Add(-time.Hour)) |
| + st := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + ctx := t.Context() |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + addRoute(t, reg, b.Host, b.App, "canary/x", false) |
| + shipRelease(t, st, b.Account, b.App, "new", adoptPlatform, []byte("new")) |
| + if err := Promote(ctx, st, priv, b.Account, b.App, "canary/x", "new"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := StageArtifact(opt, b.Account, "new", b.App, []byte("new")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.Delete(ctx, ManifestKey(b.Account, b.App, "new")); err != nil { |
| + t.Fatal(err) |
| + } |
| + deps := &RootHistoryPass{History: h, Resolve: func(context.Context, registry.Route) (HistoryBinding, string, error) { return b, "record", nil }, Installed: func(context.Context, registry.Route, string) (string, error) { return "", nil }} |
| + effects := 0 |
| + if err := Pass(ctx, reg, st, pub, opt, func(string) error { effects++; return nil }, WithRootHistory(deps)); err == nil || effects != 0 { |
| + t.Fatalf("missing manifest: %v, effects=%d", err, effects) |
| + } |
| + if live, _ := LiveVersion(opt, b.Host); live != "" { |
| + t.Fatal("missing manifest changed live link") |
| + } |
| + if a, err := h.Pending(b); err != nil || a != nil { |
| + t.Fatalf("unprepared attempt: %+v %v", a, err) |
| + } |
| +} |
| + |
| func TestPlan14RootHistoryPassRefusesUnknown(t *testing.T) { |
| for _, state := range []string{"missing", "programs", "refusals", "pending", "sync", "binding"} { |
| t.Run(state, func(t *testing.T) { |
diff --git a/internal/release/roothistory.go b/internal/release/roothistory.go| index 973495e43..220cc6639 100644 |
| --- a/internal/release/roothistory.go |
| +++ b/internal/release/roothistory.go |
| @@ -23,10 +23,25 @@ type HistoryBinding struct { |
| } |
| type HistoryAttempt struct { |
| - ID string `json:"id"` |
| - Version string `json:"version"` |
| - Source string `json:"source"` |
| - Started time.Time `json:"started"` |
| + ID string `json:"id"` |
| + Version string `json:"version"` |
| + Source string `json:"source"` |
| + Started time.Time `json:"started"` |
| + SHA256 string `json:"sha256,omitempty"` |
| + Platform string `json:"platform,omitempty"` |
| + PromotedAt time.Time `json:"promoted_at,omitempty"` |
| +} |
| + |
| +func (h *RootHistory) Pending(b HistoryBinding) (*HistoryAttempt, error) { |
| + r, err := h.read(b) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if r.Attempt == nil { |
| + return nil, nil |
| + } |
| + a := *r.Attempt |
| + return &a, nil |
| } |
| type historyPrograms struct { |
diff --git a/internal/release/roothistory_pass.go b/internal/release/roothistory_pass.go| index d26bf096a..558385a4b 100644 |
| --- a/internal/release/roothistory_pass.go |
| +++ b/internal/release/roothistory_pass.go |
| @@ -8,6 +8,7 @@ import ( |
| "slices" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -15,6 +16,7 @@ type RootHistoryPass struct { |
| History *RootHistory |
| Resolve func(context.Context, registry.Route) (HistoryBinding, string, error) |
| Installed func(context.Context, registry.Route, string) (string, error) |
| + Prepare func(context.Context, HistoryBinding, HistoryAttempt, hostwrite.Identity) error |
| } |
| type historySelection struct { |
| @@ -87,7 +89,7 @@ func (h *RootHistoryPass) recheck(ctx context.Context, rt registry.Route, want h |
| return nil |
| } |
| -func (h *RootHistoryPass) begin(ctx context.Context, rt registry.Route, sel historySelection, version string, now time.Time) (string, error) { |
| +func (h *RootHistoryPass) begin(ctx context.Context, rt registry.Route, sel historySelection, ptr *ChannelPointer, digest string, now time.Time, identity hostwrite.Identity) (string, error) { |
| if err := h.recheck(ctx, rt, sel); err != nil { |
| return "", err |
| } |
| @@ -95,8 +97,27 @@ func (h *RootHistoryPass) begin(ctx context.Context, rt registry.Route, sel hist |
| if _, err := rand.Read(id[:]); err != nil { |
| return "", err |
| } |
| - a := HistoryAttempt{ID: hex.EncodeToString(id[:]), Version: version, Source: sel.source, Started: now.UTC()} |
| - return a.ID, h.History.Begin(sel.binding, a) |
| + a := HistoryAttempt{ID: hex.EncodeToString(id[:]), Version: ptr.Version, Source: sel.source, Started: now.UTC(), SHA256: digest, Platform: adoptPlatform, PromotedAt: ptr.PromotedAt} |
| + if err := h.History.Begin(sel.binding, a); err != nil { |
| + return "", err |
| + } |
| + if h.Prepare != nil { |
| + if err := h.Prepare(ctx, sel.binding, a, identity); err != nil { |
| + return "", err |
| + } |
| + } |
| + return a.ID, nil |
| +} |
| + |
| +func (h *RootHistoryPass) CompleteAttempt(ctx context.Context, rt registry.Route, a HistoryAttempt) error { |
| + sel, err := h.resolve(ctx, rt) |
| + if err != nil { |
| + return err |
| + } |
| + if sel.source != a.Source { |
| + return os.ErrPermission |
| + } |
| + return h.complete(ctx, rt, sel, a.ID, a.Version) |
| } |
| func (h *RootHistoryPass) complete(ctx context.Context, rt registry.Route, sel historySelection, id, version string) error { |