WIP claim Plan 14 track E checkpoint 2 (not for merge): E1 integration
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+1440
−47
internal/host/agent.go+23 −12internal/host/claimadopt_order_test.go+4 −1internal/host/plan14_release_lock_test.go+5 −1internal/host/plan14_root_history_test.go+174 −0internal/host/proofroot.go+24 −10internal/host/roothistory.go+94 −0internal/host/rootreleaselock.go+1 −4internal/provision/testdata/plan14-root-reads-merge.tsv+23 −3internal/release/adopt.go+88 −14internal/release/bake.go+4 −0internal/release/plan14_root_bake_test.go+85 −0internal/release/plan14_root_history_app_test.go+66 −0internal/release/plan14_root_history_pass_process_test.go+128 −0internal/release/plan14_root_history_pass_test.go+303 −0internal/release/plan14_root_history_test.go+17 −0internal/release/rollback.go+14 −2internal/release/rootbake.go+105 −0internal/release/roothistory.go+19 −0internal/release/roothistory_app.go+106 −0internal/release/roothistory_pass.go+157 −0
CI failed — 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 344.546s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.060s
ok github.com/carlosframework/platform/cmd/moveprobe 93.191s
ok github.com/carlosframework/platform/internal/activator 231.676s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.334s
ok github.com/carlosframework/platform/internal/apperr 0.070s
ok github.com/carlosframework/platform/internal/applog 0.123s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.744s
? 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.005s
ok github.com/carlosframework/platform/internal/cgroup 0.190s
ok github.com/carlosframework/platform/internal/changelog 0.236s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 362.561s
? 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.815s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.004s
ok github.com/carlosframework/platform/internal/domains 2.146s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.003s
ok github.com/carlosframework/platform/internal/edge 26.146s
? 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.131s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.018s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/host 85.145s
ok github.com/carlosframework/platform/internal/hostactors 0.327s
ok github.com/carlosframework/platform/internal/hostbox 2.147s
ok github.com/carlosframework/platform/internal/hostclaim 0.507s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 0.480s
ok github.com/carlosframework/platform/internal/hostfence 0.030s
ok github.com/carlosframework/platform/internal/hostfiles 10.465s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 0.390s
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.001s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.425s
ok github.com/carlosframework/platform/internal/hostresolve 0.155s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.707s
ok github.com/carlosframework/platform/internal/instanceexec 2.705s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.020s
ok github.com/carlosframework/platform/internal/layout 1.431s
ok github.com/carlosframework/platform/internal/lease 9.433s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.178s
ok github.com/carlosframework/platform/internal/maintenance 0.088s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.536s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.069s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.010s
ok github.com/carlosframework/platform/internal/moves/archive 0.766s
ok github.com/carlosframework/platform/internal/objstore 0.187s
ok github.com/carlosframework/platform/internal/ops 0.157s
ok github.com/carlosframework/platform/internal/park 0.200s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 12.913s
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.072s
ok github.com/carlosframework/platform/internal/relay 7.222s
ok github.com/carlosframework/platform/internal/release 19.900s
--- FAIL: TestAPointerClearRequeuesBeforeTheNextChannelIsRead (0.00s)
manager_test.go:1173: 3 pointer reads, want 2 (stable and beta)
FAIL
FAIL github.com/carlosframework/platform/internal/rollout 3.347s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.005s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.308s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.385s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.646s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.144s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.007s
ok github.com/carlosframework/platform/internal/termfence 0.003s
ok github.com/carlosframework/platform/internal/tunnelclient 0.003s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.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.559s
FAIL
make: *** [Makefile:18: test] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 092b9cf6f..506ad5259 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4184,8 +4184,12 @@ func runAdopt(args []string, buildClaims ClaimFactory) { |
| // runAdoptWithEnsureSystemUnits keeps the root-only unit effect injectable; |
| // the rest of adopt startup, including its fatal registry diagnostics, is real. |
| -func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func()) { |
| - err := withClaimRootRelease(context.Background(), hostwrite.Enabled(os.Getenv), func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error { |
| +func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func(), stores ...func() (rootstate.Store, error)) { |
| + open := func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) } |
| + if len(stores) == 1 { |
| + open = stores[0] |
| + } |
| + err := withClaimRootRelease(context.Background(), hostwrite.Enabled(os.Getenv), open, func() error { |
| runAdoptUnderRootRelease(args, buildClaims, ensureUnits) |
| return nil |
| }) |
| @@ -4459,7 +4463,12 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // no-ops on the stopped unit, exits 0, and stamps a build that has never |
| // run). -stage-only keeps it for the same reason it keeps the converge: |
| // a relayed stage is exactly the request that performs such a restart. |
| - passOpts := []release.PassOption{release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(restartChecks.finish), |
| + history, err := openRootHistory(store, release.OptDir()) |
| + if err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| + defer history.History.Store.Close() |
| + 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), |
| rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, nil, restartChecks, true)), |
| @@ -4483,15 +4492,17 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // proving box and, on the hourly pass, say when a requirement is off; |
| // a route the pass changed is looked at again first. |
| pr := proofRoot{ |
| - obs: productionProofObserver(release.OptDir()), |
| - reg: reg, |
| - store: store, |
| - bakeDir: dataDir(), |
| - healthyDir: release.RootHealthyDir, |
| - policyPath: release.ProofPolicyPath, |
| - readKey: readProvingKey, |
| - hourly: !*stageOnly, |
| - logf: log.Printf, |
| + recordProgram: func(b liveBuild) error { return recordRootProgram(history, b) }, |
| + refusals: history.History.MintRefusals, |
| + obs: productionProofObserver(release.OptDir()), |
| + reg: reg, |
| + store: store, |
| + bakeDir: dataDir(), |
| + healthyDir: release.RootHealthyDir, |
| + policyPath: release.ProofPolicyPath, |
| + readKey: readProvingKey, |
| + hourly: !*stageOnly, |
| + logf: log.Printf, |
| } |
| if err := pr.around(ctx, func() error { |
| return release.Pass(ctx, reg, store, pub, release.OptDir(), restart, passOpts...) |
diff --git a/internal/host/claimadopt_order_test.go b/internal/host/claimadopt_order_test.go| index 76fd68a51..c67cc27a7 100644 |
| --- a/internal/host/claimadopt_order_test.go |
| +++ b/internal/host/claimadopt_order_test.go |
| @@ -11,6 +11,8 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| func TestRunAdoptDisabledClaimsArmsSystemUnitsBeforeRegistryOpenFailure(t *testing.T) { |
| @@ -43,7 +45,8 @@ func TestRunAdoptDisabledClaimsArmsSystemUnitsBeforeRegistryOpenFailure(t *testi |
| log.Printf, |
| ) |
| } |
| - runAdoptWithEnsureSystemUnits([]string{"-registry", os.Getenv("CARLOS_TEST_ADOPT_REGISTRY")}, nil, ensure) |
| + runAdoptWithEnsureSystemUnits([]string{"-registry", os.Getenv("CARLOS_TEST_ADOPT_REGISTRY")}, nil, ensure, |
| + func() (rootstate.Store, error) { return &plan14ReleaseStore{}, nil }) |
| return |
| } |
diff --git a/internal/host/plan14_release_lock_test.go b/internal/host/plan14_release_lock_test.go| index 7fee8dfe5..93bf1e9b6 100644 |
| --- a/internal/host/plan14_release_lock_test.go |
| +++ b/internal/host/plan14_release_lock_test.go |
| @@ -55,7 +55,11 @@ func TestPlan14AdoptReleaseLock(t *testing.T) { |
| if err := withClaimRootRelease(context.Background(), true, open, run); !errors.Is(err, os.ErrPermission) || effects != 1 { |
| t.Fatal("lock failure admitted effects", err, effects) |
| } |
| - if err := withClaimRootRelease(context.Background(), false, func() (rootstate.Store, error) { t.Fatal("flag-off opened root state"); return nil, nil }, func() error { effects++; return nil }); err != nil || effects != 2 { |
| + if err := withClaimRootRelease(context.Background(), false, open, run); !errors.Is(err, os.ErrPermission) || effects != 1 { |
| + t.Fatal("flag-off lock failure admitted effects", err, effects) |
| + } |
| + s.fail = nil |
| + if err := withClaimRootRelease(context.Background(), false, open, run); err != nil || effects != 2 || called != 4 { |
| t.Fatal(err, effects) |
| } |
| } |
diff --git a/internal/host/plan14_root_history_test.go b/internal/host/plan14_root_history_test.go| new file mode 100644 |
| index 000000000..d781de27a |
| --- /dev/null |
| +++ b/internal/host/plan14_root_history_test.go |
| @@ -0,0 +1,174 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func seedHostRootHistory(t *testing.T, rt registry.Route) *release.RootHistory { |
| + t.Helper() |
| + b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| + store := &rollbackMemory{files: map[string][]byte{}} |
| + h := &release.RootHistory{Store: store} |
| + id := sha256.Sum256([]byte(b.Account + "\x00" + b.App + "\x00" + b.Host)) |
| + rawBinding, _ := json.Marshal(b) |
| + raw := fmt.Sprintf(`{"schema":1,"binding":%s,"revision":1,"programs":{"revision":1,"complete":true,"values":null},"refusals":{"revision":1,"complete":true,"values":null}}`, rawBinding) |
| + if err := store.Write(hex.EncodeToString(id[:])+".json", []byte(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano) |
| + raw = fmt.Sprintf(`{"schema":1,"revision":1,"complete":{%q:1},"observed":%q,"clock":{"jitter":0.1,"channels":{}}}`, b.Account+"|"+b.App, at) |
| + if err := store.Write("bake.json", []byte(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + id = sha256.Sum256([]byte(b.Account + "\x00" + b.App)) |
| + raw = fmt.Sprintf(`{"schema":1,"account":%q,"app":%q,"revision":1,"complete":true,"hosts":[%q]}`, b.Account, b.App, b.Host) |
| + if err := store.Write(hex.EncodeToString(id[:])+".app", []byte(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + return h |
| +} |
| + |
| +func TestPlan14RootHistoryProductionPass(t *testing.T) { |
| + for _, attack := range []string{"positive", "legacy-deleted", "legacy-recovered", "failed-ready", "record-change"} { |
| + t.Run(attack, func(t *testing.T) { |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + h := seedHostRootHistory(t, rt) |
| + b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| + if attack == "legacy-deleted" || attack == "legacy-recovered" { |
| + ptr, err := release.Channel(context.Background(), e.store, pub, rt.Account, rt.App, rt.Channel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ref := release.Refusal{Host: rt.Host, App: rt.App, Version: ptr.Version, PointerPromotedAt: ptr.PromotedAt, RefusedAt: time.Now(), Reason: "failed"} |
| + if err := h.AddRefusal(b, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "legacy-recovered" { |
| + ref.Recovered = true |
| + ref.Version = "v1" |
| + if err := release.WriteRefusal(e.bake, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + if attack == "failed-ready" { |
| + c.ready = func(registry.Route) func() error { return func() error { return os.ErrPermission } } |
| + } |
| + if attack == "record-change" { |
| + rt.Account = "bap" |
| + if err := e.root.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + deps := boundRootHistory(h, c.resolve, e.root.obs) |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| + err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, forward, |
| + release.BakeDir(e.bake), release.WithRootHistory(deps), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| + if attack == "positive" { |
| + if err != nil || len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, "bab", "v2", "console") { |
| + t.Fatalf("root positive: %v, %+v", err, u.effects) |
| + } |
| + } else if attack == "failed-ready" { |
| + if err == nil { |
| + t.Fatal("failed readiness accepted") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("failed update lost its pending obligation") |
| + } |
| + } else if len(u.effects) != 0 { |
| + t.Fatalf("%s restarted: %v", attack, u.effects) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryMintUsesRootInventory(t *testing.T) { |
| + for _, attack := range []string{"positive", "refused", "unknown"} { |
| + t.Run(attack, func(t *testing.T) { |
| + e, rt, _, _ := plan14ProofFixture(t) |
| + h := seedHostRootHistory(t, rt) |
| + e.root.refusals = h.MintRefusals |
| + if attack == "refused" { |
| + if err := h.AddRefusal(release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App}, release.Refusal{Host: rt.Host, App: rt.App, Version: "v1", SHA256: e.sha, RefusedAt: time.Now(), Recovered: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if attack == "unknown" { |
| + h.Store = &rollbackMemory{files: map[string][]byte{}} |
| + } |
| + e.root.run(context.Background()) |
| + if attack == "positive" && e.proof(t) == nil { |
| + t.Fatalf("root inventory blocked mint: %v", e.logs) |
| + } |
| + if attack != "positive" && e.proof(t) != nil { |
| + t.Fatal("root inventory bypassed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryCanonicalBinding(t *testing.T) { |
| + for _, field := range []string{"unchanged", "account", "app", "unit", "upstream", "readiness"} { |
| + t.Run(field, func(t *testing.T) { |
| + e, rt, _, source := plan14ProofFixture(t) |
| + deps := boundRootHistory(&release.RootHistory{}, source.resolve, e.root.obs) |
| + candidate := rt |
| + switch field { |
| + case "account": |
| + candidate.Account = "bap" |
| + case "app": |
| + candidate.App = "other" |
| + case "unit": |
| + candidate.Unit = "other.service" |
| + case "upstream": |
| + candidate.Addr = "" |
| + case "readiness": |
| + candidate.Health = "" |
| + } |
| + b, revision, err := deps.Resolve(context.Background(), candidate) |
| + if field == "unchanged" { |
| + if err != nil || revision == "" || b.Account != "bab" || b.App != "console" || b.Host != rt.Host { |
| + t.Fatalf("canonical binding: %+v %q %v", b, revision, err) |
| + } |
| + } else if err == nil { |
| + t.Fatalf("forged %s accepted", field) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryInstalledRequiresObservation(t *testing.T) { |
| + for _, attack := range []string{"unchanged", "not-ready", "wrong-version", "changed-record"} { |
| + t.Run(attack, func(t *testing.T) { |
| + e, rt, _, source := plan14ProofFixture(t) |
| + version := "v1" |
| + if attack == "wrong-version" { |
| + version = "v2" |
| + } |
| + if attack == "not-ready" { |
| + e.root.obs.ready = func(registry.Route) func() error { return nil } |
| + } |
| + deps := boundRootHistory(&release.RootHistory{}, source.resolve, e.root.obs) |
| + if attack == "changed-record" { |
| + rt.Account = "bap" |
| + } |
| + _, err := deps.Installed(context.Background(), rt, version) |
| + if attack == "unchanged" && err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack != "unchanged" && err == nil { |
| + t.Fatalf("%s granted installed history", attack) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/proofroot.go b/internal/host/proofroot.go| index c29dc25d6..0576ac60f 100644 |
| --- a/internal/host/proofroot.go |
| +++ b/internal/host/proofroot.go |
| @@ -42,15 +42,17 @@ import ( |
| ) |
| type proofRoot struct { |
| - obs proofObserver |
| - reg *registry.Registry |
| - store release.Store |
| - bakeDir string |
| - healthyDir string |
| - policyPath string |
| - readKey func(path string) (ed25519.PrivateKey, error) |
| - hourly bool // carlos-adopt.service, not -stage-only |
| - logf func(format string, a ...any) |
| + recordProgram func(liveBuild) error |
| + refusals func(release.HistoryBinding) ([]release.Refusal, error) |
| + obs proofObserver |
| + reg *registry.Registry |
| + store release.Store |
| + bakeDir string |
| + healthyDir string |
| + policyPath string |
| + readKey func(path string) (ed25519.PrivateKey, error) |
| + hourly bool // carlos-adopt.service, not -stage-only |
| + logf func(format string, a ...any) |
| } |
| func (r proofRoot) historyDir() string { |
| @@ -123,6 +125,12 @@ func (r proofRoot) observeAndRecord(rt registry.Route, after time.Duration) live |
| if err != nil { |
| return o |
| } |
| + if r.recordProgram != nil { |
| + if err := r.recordProgram(b); err != nil { |
| + o.err = err |
| + return o |
| + } |
| + } |
| if err := release.RecordHealthy(r.historyDir(), b.binding.Route.Account, b.binding.Route.App, release.HealthyBuild{ |
| SHA256: b.SHA256, Version: b.Version, How: "live", At: r.obs.now().UTC()}); err != nil { |
| r.logf("proof: %s: recording the healthy build: %v", rt.Host, err) |
| @@ -249,7 +257,13 @@ func (r proofRoot) markRequired(app string) { |
| func (r proofRoot) mint(ctx context.Context, pol *release.ProofPolicy, binding proofBinding, b liveBuild) { |
| rt, acct := binding.Route, binding.Route.Account |
| - refusals, err := release.ListRefusals(r.bakeDir) |
| + var refusals []release.Refusal |
| + var err error |
| + if r.refusals != nil { |
| + refusals, err = r.refusals(release.HistoryBinding{Host: rt.Host, Account: acct, App: rt.App}) |
| + } else { |
| + refusals, err = release.ListRefusals(r.bakeDir) |
| + } |
| if err != nil { |
| r.logf("proof: %s: reading refusal records: %v; not minting", rt.Host, err) |
| return |
diff --git a/internal/host/roothistory.go b/internal/host/roothistory.go| new file mode 100644 |
| index 000000000..dfae59e25 |
| --- /dev/null |
| +++ b/internal/host/roothistory.go |
| @@ -0,0 +1,94 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "debug/buildinfo" |
| + "encoding/hex" |
| + "io" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func openRootHistory(store release.Store, optDir string) (*release.RootHistoryPass, error) { |
| + s, err := rootstate.Open(release.RootHistoryDir, true, false) |
| + if err != nil { |
| + return nil, err |
| + } |
| + source := productionProofBindingSource(store, systemdUnits{}, optDir) |
| + return boundRootHistory(&release.RootHistory{Store: s}, source.resolve, productionProofObserver(optDir)), nil |
| +} |
| + |
| +func boundRootHistory(h *release.RootHistory, resolve proofBindingResolver, obs proofObserver) *release.RootHistoryPass { |
| + return &release.RootHistoryPass{History: h, |
| + Resolve: func(ctx context.Context, rt registry.Route) (release.HistoryBinding, string, error) { |
| + if resolve == nil { |
| + return release.HistoryBinding{}, "", os.ErrPermission |
| + } |
| + b, err := resolve(ctx, rt) |
| + if err != nil { |
| + return release.HistoryBinding{}, "", err |
| + } |
| + if rt.Channel != b.Route.Channel { |
| + return release.HistoryBinding{}, "", os.ErrPermission |
| + } |
| + return release.HistoryBinding{Host: b.Route.Host, Account: b.Route.Account, App: b.Route.App}, b.Source, nil |
| + }, |
| + Installed: func(ctx context.Context, rt registry.Route, version string) (string, error) { |
| + if err := ctx.Err(); err != nil { |
| + return "", err |
| + } |
| + b, err := obs.observe(rt, 0) |
| + if err != nil { |
| + return "", err |
| + } |
| + if b.Version != version || b.binding.Version != version || b.binding.Digest != b.SHA256 || obs.procExe == nil { |
| + return "", os.ErrPermission |
| + } |
| + f, err := os.Open(obs.procExe(b.pid)) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer f.Close() |
| + digest := sha256.New() |
| + if _, err := io.Copy(digest, f); err != nil { |
| + return "", err |
| + } |
| + if hex.EncodeToString(digest.Sum(nil)) != b.SHA256 { |
| + return "", os.ErrPermission |
| + } |
| + program := "" |
| + if info, err := buildinfo.Read(f); err == nil { |
| + program = info.Path |
| + } |
| + again, err := obs.observe(rt, 0) |
| + if err != nil || again.SHA256 != b.SHA256 || again.Version != b.Version || again.binding.Source != b.binding.Source || again.pid != b.pid || again.start != b.start || again.invocation != b.invocation { |
| + return "", os.ErrPermission |
| + } |
| + return program, nil |
| + }, |
| + } |
| +} |
| + |
| +func recordRootProgram(h *release.RootHistoryPass, b liveBuild) error { |
| + ctx := context.Background() |
| + binding, source, err := h.Resolve(ctx, b.binding.Route) |
| + if err != nil { |
| + return err |
| + } |
| + if source != b.binding.Source { |
| + return os.ErrPermission |
| + } |
| + program, err := h.Installed(ctx, b.binding.Route, b.Version) |
| + if err != nil { |
| + return err |
| + } |
| + again, revision, err := h.Resolve(ctx, b.binding.Route) |
| + if err != nil || again != binding || revision != source { |
| + return os.ErrPermission |
| + } |
| + return h.History.RecordInstalledProgram(binding, program) |
| +} |
diff --git a/internal/host/rootreleaselock.go b/internal/host/rootreleaselock.go| index ca4f94b84..6a4272dae 100644 |
| --- a/internal/host/rootreleaselock.go |
| +++ b/internal/host/rootreleaselock.go |
| @@ -9,10 +9,7 @@ import ( |
| const rootReleasePrivate = "/var/lib/carlos-release/private" |
| -func withClaimRootRelease(ctx context.Context, enabled bool, open func() (rootstate.Store, error), effect func() error) error { |
| - if !enabled { |
| - return effect() |
| - } |
| +func withClaimRootRelease(ctx context.Context, _ bool, open func() (rootstate.Store, error), effect func() error) error { |
| if open == nil || effect == nil { |
| return os.ErrPermission |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv| index a64d0fad8..84134e833 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 d9e1b4aecc47f93e592ba94f5e51214592636cef8a677792b3985a8311c4e0b5 |
| +@coverage internal/host 49f8b9b24fa1172af522c534f542eb17cbfae2d614b2c6fc01d1e3d773e18c22 |
| @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 e10b520234ebb15d522104a3835701bd015d23b73082de5d0947ebe6006b4397 |
| +@coverage internal/release 1810c133821ef7df5cfd0ad3a4248b112f2350aaabafe44299a3c1dc5b1d56ea |
| @coverage internal/sim/hostlife eaac101ea7970c74fee621e5cd7515ec7e4907bc5874c65eab86666af0f7dc61 |
| @coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976 |
| @coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6 |
| @@ -284,4 +284,24 @@ internal/rootparticipation/ledger.go Ledger.EndUpdate authority D D3 removes onl |
| internal/rootparticipation/ledger.go Ledger.idle authority D D3 enumerates root records and requires settled obligations plus an independently supplied launch/kernel census. file:Read@429 b7b27590041bac6bec3de245b7fe20da7965cb2a9e52725c888f8a83313ff9f3 |
| 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-owned per-binding refusal/program completeness and attempt state; strict decode and exact durable recommit before authority reads. Production consumer integration remains pending. file:Read@183 7ad6f4711ce92da81c19dfe5d7740bb0058b6689afcde5a89c34b9a2491a82a3 |
| +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 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 |
| +internal/host/roothistory.go boundRootHistory 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@1061,file:Read@1379,registry:Account@613,registry:App@635,registry:Channel@456,registry:Channel@470,registry:Host@590,whole:Route@1469,whole:Route@377,whole:Route@472,whole:Route@592,whole:Route@615,whole:Route@637,whole:Route@846 fe46269101116374044658bbb87c1bd44317525dcadf227d4515b47845238be2 |
| +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/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.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 |
| +internal/release/roothistory_pass.go RootHistoryPass.selectRoute 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@134 41e83679f252a6347c471b271034e6a3bab9772e18ad57ff9058a3f472ac01cc |
| +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 |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 52c0a762f..6ebbbb6d2 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -37,6 +37,9 @@ var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH |
| type PassOption func(*passConfig) |
| type passConfig struct { |
| + rootHistory *RootHistoryPass |
| + requireRootHistory bool |
| + historySelected map[string]historySelection |
| propagateRestarts bool |
| restartAttemptSettled func(string) error |
| restartErrors *[]error |
| @@ -437,7 +440,7 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| for _, opt := range opts { |
| opt(&cfg) |
| } |
| - if cfg.propagateRestarts { |
| + if cfg.propagateRestarts || cfg.requireRootHistory { |
| cfg.restartErrors = new([]error) |
| } |
| cfg.pub = pub |
| @@ -461,10 +464,18 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| return (Adoptable(rt) || SidecarAdoptable(rt)) && (extra == nil || extra(rt)) |
| } |
| selected := make(map[string]hostwrite.Work, len(routes)) |
| + cfg.historySelected = make(map[string]historySelection) |
| for _, rt := range routes { |
| if !adopts(rt) { |
| continue |
| } |
| + if cfg.requireRootHistory { |
| + sel, err := cfg.rootHistory.selectRoute(ctx, rt) |
| + if err != nil { |
| + return err |
| + } |
| + cfg.historySelected[rt.Host] = sel |
| + } |
| work, selectErr := cfg.hostWrites.Select(ctx, rt.Host, "release") |
| if selectErr != nil { |
| return fmt.Errorf("%s: select release work: %w", rt.Host, selectErr) |
| @@ -698,9 +709,20 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // it. now is stamped once so every observation and verdict this pass |
| // answers the same instant. |
| now := time.Now() |
| - bake := LoadFirstSeen(FirstSeenPath(optDir)) |
| - if cfg.bakeDir != "" { |
| + var bake *FirstSeen |
| + if cfg.requireRootHistory { |
| + bindings := make([]HistoryBinding, 0, len(cfg.historySelected)) |
| + for _, sel := range cfg.historySelected { |
| + bindings = append(bindings, sel.binding) |
| + } |
| + bake, err = cfg.rootHistory.History.LoadBake(bindings, now) |
| + if err != nil { |
| + return err |
| + } |
| + } else if cfg.bakeDir != "" { |
| bake = LoadFirstSeenMigrating(FirstSeenPath(cfg.bakeDir), FirstSeenPath(optDir)) |
| + } else { |
| + bake = LoadFirstSeen(FirstSeenPath(optDir)) |
| } |
| // bake.migrated forces this pass's Save even if nothing new is observed |
| // below: otherwise a changed-gated Save could leave the new path absent |
| @@ -1315,7 +1337,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| // refusal too (final review H3). The agent cannot remove root's file; |
| // that failure is ignored and the next root pass clears it. D1 and D2 |
| // records never depend on the policy. |
| - if cfg.bakeDir != "" { |
| + if cfg.bakeDir != "" && !cfg.requireRootHistory { |
| r, err := ReadRefusal(cfg.bakeDir, rt.Host) |
| if err == nil && r.Blocks(sel) && r.Kind == RefusalNotProven { |
| if required, _ := cfg.proof.Requires(rt.App); !required { |
| @@ -1349,6 +1371,19 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| return |
| } |
| } |
| + if cfg.requireRootHistory { |
| + selHistory := cfg.historySelected[rt.Host] |
| + refs, err := cfg.rootHistory.History.Refusals(selHistory.binding) |
| + if err != nil { |
| + cfg.historyError(err) |
| + return |
| + } |
| + for _, ref := range refs { |
| + if ref.Kind == RefusalNotUp && ref.Blocks(sel) { |
| + return |
| + } |
| + } |
| + } |
| // D3's cheap gate (proofgate.go, spec §4.5 check 1): before ensureStaged, |
| // so an unproven build is neither downloaded nor relayed to root. |
| verdict, admitted := cfg.proof.admit(ctx, s, optDir, rt, sel, cur) |
| @@ -1401,19 +1436,28 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| // D2 (programhistory.go): the staged bytes, not the manifest, decide. |
| // Skipped for non-Go binaries and when this pass has no bake dir. |
| newProg := FileProgram(VersionPath(optDir, RouteAccount(rt), ver, rt.App)) |
| - if cfg.bakeDir != "" && newProg != "" { |
| - hist := ReadPrograms(cfg.bakeDir, rt.Host) |
| + if (cfg.bakeDir != "" || cfg.requireRootHistory) && newProg != "" { |
| + var hist []string |
| + if cfg.requireRootHistory { |
| + hist, err = cfg.rootHistory.History.Programs(cfg.historySelected[rt.Host].binding) |
| + if err != nil { |
| + cfg.historyError(err) |
| + return |
| + } |
| + } else { |
| + hist = ReadPrograms(cfg.bakeDir, rt.Host) |
| + } |
| // The live binary is what the route is running, so its program belongs |
| // in the history whatever the file already says: a history of [A] on a |
| // route that is live on B (a change made before this check existed, or |
| // a record lost) must not refuse the next B build. |
| - if cur != "" { |
| + if cur != "" && !cfg.requireRootHistory { |
| if live := FileProgram(VersionPath(optDir, RouteAccount(rt), cur, rt.App)); live != "" && !slices.Contains(hist, live) { |
| _ = RecordProgram(cfg.bakeDir, rt.Host, live) |
| hist = append(slices.Clone(hist), live) |
| } |
| } |
| - if len(hist) > 0 && !slices.Contains(hist, newProg) && m == nil { |
| + if (len(hist) > 0 || cfg.requireRootHistory) && !slices.Contains(hist, newProg) && m == nil { |
| // ensureStaged has the binary but could not obtain its manifest |
| // (no sibling, and the store did not answer), so whether the |
| // release carries --program-change is unknown. Unknown is not a |
| @@ -1425,15 +1469,20 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| rt.Host, ver, newProg) |
| return |
| } |
| - if len(hist) > 0 && !slices.Contains(hist, newProg) && !m.ProgramChange { |
| + programChange := m != nil && m.ProgramChange |
| + if cfg.requireRootHistory { |
| + programChange = programChange && ptr.DigestOf(ver) != "" |
| + } |
| + if (len(hist) > 0 || cfg.requireRootHistory) && !slices.Contains(hist, newProg) && !programChange { |
| fmt.Printf(" %s: %s is %s, but this route has only run %s — not pointed live (ship a new version with carlos ship --program-change and promote it, if the change is intended)\n", |
| rt.Host, ver, newProg, strings.Join(hist, ", ")) |
| sha := m.Artifacts[adoptPlatform].SHA256 |
| // Nothing was restarted, so the box is still on its old build. |
| - if err := WriteRefusal(cfg.bakeDir, Refusal{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha, |
| + if err := cfg.recordRefusal(ctx, rt, Refusal{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha, |
| Platform: adoptPlatform, PointerPromotedAt: sel.PromotedAt, RefusedAt: now.UTC(), |
| Reason: "wrong program: " + newProg, |
| Kind: RefusalWrongProgram, Recovered: true, RestoredVersion: cur}); err != nil { |
| + cfg.historyError(err) |
| fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err) |
| } |
| return |
| @@ -1442,9 +1491,17 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| // D3's binding check (spec §4.5 check 2): the staged bytes themselves |
| // must be the proven ones, or a build this box has run well. |
| - if !cfg.proof.bind(optDir, rt, sel, verdict, cur, now) { |
| + if !cfg.bindProof(ctx, optDir, rt, sel, verdict, cur, now) { |
| return |
| } |
| + var historyAttempt string |
| + if cfg.requireRootHistory { |
| + historyAttempt, err = cfg.rootHistory.begin(ctx, rt, cfg.historySelected[rt.Host], ver, now) |
| + if err != nil { |
| + cfg.historyError(err) |
| + return |
| + } |
| + } |
| // The capability converge for the NEW version, BEFORE PointLive (spec |
| // §4d). m is the manifest ensureStaged has in hand — never a re-read of |
| @@ -1474,6 +1531,10 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| var res caps.Result |
| if err == nil { |
| err = withReleaseWork(ctx, work, id, func() error { |
| + if err := cfg.recheckHistory(ctx, rt); err != nil { |
| + cfg.historyError(err) |
| + return err |
| + } |
| var convergeErr error |
| res, convergeErr = caps.Converge(*cfg.capsOps, plan) |
| return convergeErr |
| @@ -1519,7 +1580,13 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| converged = true |
| } |
| - if err := withReleaseWork(ctx, work, id, func() error { return PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App) }); err != nil { |
| + if err := withReleaseWork(ctx, work, id, func() error { |
| + if err := cfg.recheckHistory(ctx, rt); err != nil { |
| + return err |
| + } |
| + return PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App) |
| + }); err != nil { |
| + cfg.historyError(err) |
| // The symlink did not move, so the box is still serving cur — and the |
| // unit shape on disk is the new version's. Put the old one back, or a |
| // port the running binary never asked for stays publicly bound. |
| @@ -1547,7 +1614,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| } |
| return |
| } |
| - if cfg.bakeDir != "" && newProg != "" { |
| + if cfg.bakeDir != "" && newProg != "" && !cfg.requireRootHistory { |
| if err := RecordProgram(cfg.bakeDir, rt.Host, newProg); err != nil { |
| fmt.Printf(" %s: recording program history: %v\n", rt.Host, err) |
| } |
| @@ -1570,7 +1637,8 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| } |
| if restart != nil { |
| - if err := withReleaseWork(ctx, work, id, func() error { return nil }); err != nil { |
| + if err := withReleaseWork(ctx, work, id, func() error { return cfg.recheckHistory(ctx, rt) }); err != nil { |
| + cfg.historyError(err) |
| fmt.Printf(" %s: release authority changed before restart: %v\n", rt.Host, err) |
| return |
| } |
| @@ -1797,6 +1865,12 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| return |
| } |
| } |
| + if cfg.requireRootHistory { |
| + if err := cfg.rootHistory.complete(ctx, rt, cfg.historySelected[rt.Host], historyAttempt, ver); err != nil { |
| + cfg.historyError(err) |
| + return |
| + } |
| + } |
| if err := withReleaseWork(ctx, work, id, func() error { return reg.SetVersions(rt.Host, ver, cur) }); err != nil { |
| fmt.Printf(" %s: repointed but recording the version failed: %v\n", rt.Host, err) |
| return |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index 2ff93a2b0..8b88b4663 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -138,6 +138,7 @@ type FirstSeen struct { |
| Holds map[string]*holdMemory `json:"holds,omitempty"` |
| path string // where Save persists; set by LoadFirstSeen |
| + save func() error |
| // migrated marks a clock LoadFirstSeenMigrating built from its |
| // legacy-fallback branch (the new path didn't exist yet). PassFilter |
| @@ -327,6 +328,9 @@ func (st *FirstSeen) Observe(acct, app, channel, version string, now time.Time) |
| // fresh 72h. Any failure path removes the temp file rather than leaving it |
| // behind for the next Save to trip over. |
| func (st *FirstSeen) Save() error { |
| + if st.save != nil { |
| + return st.save() |
| + } |
| raw, err := json.MarshalIndent(st, "", " ") |
| if err != nil { |
| return err |
diff --git a/internal/release/plan14_root_bake_test.go b/internal/release/plan14_root_bake_test.go| new file mode 100644 |
| index 000000000..490f7358a |
| --- /dev/null |
| +++ b/internal/release/plan14_root_bake_test.go |
| @@ -0,0 +1,85 @@ |
| +package release |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func seedRootBake(t *testing.T, h *RootHistory, b HistoryBinding, at time.Time) { |
| + t.Helper() |
| + r := rootBakeRecord{Schema: 1, Revision: 1, Observed: at, |
| + Complete: map[string]uint64{prodKey(b.Account, b.App): 1}, |
| + Clock: FirstSeen{Channels: map[string]map[string]time.Time{}}} |
| + if err := h.writeBake(r); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestPlan14RootBakeRetainsHistory(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + start := time.Now().UTC().Add(-time.Hour) |
| + seedRootBake(t, h, b, start) |
| + clock, err := h.LoadBake([]HistoryBinding{b}, start) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + clock.Observe(b.Account, b.App, "stable", "new", start) |
| + clock.Production = map[string]*prodMemory{prodKey(b.Account, b.App): {WasProduction: true}} |
| + clock.Holds = map[string]*holdMemory{channelClockKey(b.Account, b.App, "stable"): {Window: 96 * time.Hour, SetAt: start}} |
| + if err := clock.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy := t.TempDir() |
| + if err := os.WriteFile(FirstSeenPath(legacy), []byte(`{"jitter":0,"channels":{},"production":{},"holds":{}}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(legacy, legacy+"-moved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = os.RemoveAll(legacy + "-moved") }) |
| + clock, err = h.LoadBake([]HistoryBinding{b}, start.Add(time.Hour)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !clock.Production[prodKey(b.Account, b.App)].WasProduction || clock.Holds[channelClockKey(b.Account, b.App, "stable")].Window != 96*time.Hour { |
| + t.Fatal("legacy replacement removed root ratchets") |
| + } |
| + holds := map[string]time.Duration{"stable": 96 * time.Hour} |
| + if Hold(clock, b.Account, b.App, "stable", "new", holds, false, start.Add(time.Hour)) == "" { |
| + t.Fatal("hold shortened") |
| + } |
| + if Hold(clock, b.Account, b.App, "stable", "new", holds, false, start.Add(121*time.Hour)) != "" { |
| + t.Fatal("root interval never elapsed") |
| + } |
| +} |
| + |
| +func TestPlan14RootBakeUnknownAndDurability(t *testing.T) { |
| + for _, attack := range []string{"missing", "malformed", "wrong-app", "sync", "backward"} { |
| + t.Run(attack, func(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + now := time.Now().UTC() |
| + seedRootBake(t, h, b, now) |
| + switch attack { |
| + case "missing": |
| + if err := os.Remove(filepath.Join(s.dir, rootBakeKey)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "malformed": |
| + if err := s.Write(rootBakeKey, []byte(`{}`)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "wrong-app": |
| + b.App = "other" |
| + case "sync": |
| + s.fail = 1 |
| + case "backward": |
| + now = now.Add(-time.Hour) |
| + } |
| + if _, err := h.LoadBake([]HistoryBinding{b}, now); err == nil { |
| + t.Fatal("unavailable bake history granted adoption") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/plan14_root_history_app_test.go b/internal/release/plan14_root_history_app_test.go| new file mode 100644 |
| index 000000000..f315e51cf |
| --- /dev/null |
| +++ b/internal/release/plan14_root_history_app_test.go |
| @@ -0,0 +1,66 @@ |
| +package release |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func (s *healthyRetryStore) Names() ([]string, error) { |
| + entries, err := os.ReadDir(s.dir) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var names []string |
| + for _, entry := range entries { |
| + names = append(names, entry.Name()) |
| + } |
| + return names, nil |
| +} |
| + |
| +func TestPlan14RootHistoryMintInventory(t *testing.T) { |
| + for _, state := range []string{"complete", "missing", "unknown-host", "missing-host", "pending", "sync"} { |
| + t.Run(state, func(t *testing.T) { |
| + h, disk, b := rootHistoryFixture(t) |
| + if err := h.writeApp(historyApp{Schema: 1, Account: b.Account, App: b.App, Revision: 1, Complete: true, Hosts: []string{b.Host}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + ref := Refusal{Host: b.Host, App: b.App, Version: "bad", RefusedAt: time.Now(), SHA256: "bad", Recovered: true} |
| + if err := h.AddRefusal(b, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch state { |
| + case "missing": |
| + if err := os.Remove(filepath.Join(disk.dir, historyAppKey(b.Account, b.App))); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing-host": |
| + if err := os.Remove(filepath.Join(disk.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "unknown-host": |
| + other := b |
| + other.Host = "another.example.com" |
| + ref.Host = other.Host |
| + if err := h.AddRefusal(other, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "pending": |
| + if err := h.Begin(b, HistoryAttempt{ID: "a", Version: "bad", Source: "record", Started: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "sync": |
| + disk.fail = 1 |
| + } |
| + refs, err := h.AppRefusals(b.Account, b.App) |
| + if state == "complete" { |
| + if err != nil || len(refs) != 1 || refs[0].SHA256 != "bad" { |
| + t.Fatalf("inventory = %v, %v", refs, err) |
| + } |
| + } else if err == nil { |
| + t.Fatalf("%s inventory allowed minting: %v", state, refs) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/plan14_root_history_pass_process_test.go b/internal/release/plan14_root_history_pass_process_test.go| new file mode 100644 |
| index 000000000..45b3eb8db |
| --- /dev/null |
| +++ b/internal/release/plan14_root_history_pass_process_test.go |
| @@ -0,0 +1,128 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/base64" |
| + "encoding/json" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +type historyPassCrashStore struct { |
| + *healthyRetryStore |
| + boundary string |
| + armed bool |
| +} |
| + |
| +func (s *historyPassCrashStore) Write(key string, raw []byte) error { |
| + if err := s.healthyRetryStore.Write(key, raw); err != nil { |
| + return err |
| + } |
| + var r historyRecord |
| + if json.Unmarshal(raw, &r) == nil && r.Binding.Host != "" { |
| + if r.Attempt != nil { |
| + s.armed = true |
| + if s.boundary == "intent" { |
| + os.Exit(91) |
| + } |
| + } else if s.armed && s.boundary == "settled" { |
| + os.Exit(91) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func TestPlan14RootHistoryPassProcessCrash(t *testing.T) { |
| + if dir := os.Getenv("CARLOS_TEST_HISTORY_PASS_DIR"); dir != "" { |
| + boundary := os.Getenv("CARLOS_TEST_HISTORY_PASS_BOUNDARY") |
| + b := HistoryBinding{Host: proofHost, Account: "bab", App: "console"} |
| + h := &RootHistory{Store: &historyPassCrashStore{healthyRetryStore: &healthyRetryStore{dir: filepath.Join(dir, "state")}, boundary: boundary}} |
| + pub, err := ParsePublicKey(os.Getenv("CARLOS_TEST_HISTORY_PASS_PUB"), "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + 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 }} |
| + err = Pass(context.Background(), reg, NewDirStore(filepath.Join(dir, "store")), pub, filepath.Join(dir, "opt"), func(string) error { |
| + if boundary == "restart" { |
| + os.Exit(91) |
| + } |
| + return nil |
| + }, BakeDir(filepath.Join(dir, "legacy")), WithRootHistory(deps)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Fatal("crash boundary was not reached") |
| + } |
| + for _, boundary := range []string{"intent", "restart", "settled"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + dir := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(dir, "state"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + h, _, b := rootHistoryFixture(t) |
| + r, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h.Store = &healthyRetryStore{dir: filepath.Join(dir, "state")} |
| + if err := h.write(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + seedRootBake(t, h, b, time.Now().UTC().Add(-time.Hour)) |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, b.Host, b.App, "canary/x", false) |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st := NewDirStore(filepath.Join(dir, "store")) |
| + priv, pub := testKeys(t) |
| + shipRelease(t, st, b.Account, b.App, "new", adoptPlatform, []byte("new")) |
| + if err := Promote(context.Background(), st, priv, b.Account, b.App, "canary/x", "new"); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14RootHistoryPassProcessCrash$") |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "CARLOS_TEST_HISTORY_PASS_DIR="+dir, "CARLOS_TEST_HISTORY_PASS_BOUNDARY="+boundary, "CARLOS_TEST_HISTORY_PASS_PUB="+base64.StdEncoding.EncodeToString(pub)) |
| + out, err := cmd.CombinedOutput() |
| + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 91 { |
| + t.Fatalf("child: %v\n%s", err, out) |
| + } |
| + r, err = h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + live, _ := LiveVersion(filepath.Join(dir, "opt"), b.Host) |
| + if boundary == "settled" { |
| + if r.Attempt != nil || live != "new" { |
| + t.Fatal("settled result lost") |
| + } |
| + } else { |
| + if r.Attempt == nil { |
| + t.Fatal("interrupted adoption lost its attempt") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("interrupted adoption allowed mint") |
| + } |
| + if boundary == "intent" && live != "" { |
| + t.Fatal("link changed before durable intent") |
| + } |
| + if boundary == "restart" && live != "new" { |
| + t.Fatal("did not crash after live-link change") |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/plan14_root_history_pass_test.go b/internal/release/plan14_root_history_pass_test.go| new file mode 100644 |
| index 000000000..9fe128469 |
| --- /dev/null |
| +++ b/internal/release/plan14_root_history_pass_test.go |
| @@ -0,0 +1,303 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlan14RootHistoryPassRefusesUnknown(t *testing.T) { |
| + for _, state := range []string{"missing", "programs", "refusals", "pending", "sync", "binding"} { |
| + t.Run(state, func(t *testing.T) { |
| + h, disk, b := rootHistoryFixture(t) |
| + st := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + opt, legacy := t.TempDir(), 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(context.Background(), st, priv, b.Account, b.App, "canary/x", "new"); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch state { |
| + case "programs": |
| + r.Programs.Complete = false |
| + case "refusals": |
| + r.Refusals.Complete = false |
| + case "pending": |
| + r.Attempt = &HistoryAttempt{ID: "pending", Version: "new", Source: "record", Started: time.Now()} |
| + } |
| + if err := h.write(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if state == "missing" { |
| + if err := os.Remove(filepath.Join(disk.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if state == "sync" { |
| + disk.fail = 1 |
| + } |
| + effects := 0 |
| + deps := &RootHistoryPass{History: h, Installed: func(context.Context, registry.Route, string) (string, error) { return "", nil }, Resolve: func(context.Context, registry.Route) (HistoryBinding, string, error) { |
| + if state == "binding" { |
| + return b, "", os.ErrPermission |
| + } |
| + return b, "record", nil |
| + }} |
| + err = Pass(context.Background(), reg, st, pub, opt, func(string) error { effects++; return nil }, BakeDir(legacy), WithRootHistory(deps)) |
| + if err == nil || effects != 0 { |
| + t.Fatalf("unknown %s: err=%v effects=%d", state, err, effects) |
| + } |
| + if v, _ := LiveVersion(opt, b.Host); v != "" { |
| + t.Fatalf("unknown history moved link to %s", v) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryPassRetainsRefusal(t *testing.T) { |
| + for _, attack := range []string{"delete", "rename", "recovered", "signed-retry"} { |
| + t.Run(attack, func(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) |
| + opt, legacy := t.TempDir(), 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")) |
| + at := time.Now().UTC().Truncate(time.Second) |
| + writeBackdatedPointer(t, st, priv, b.Account, b.App, "canary/x", "new", at) |
| + ref := Refusal{Host: b.Host, App: b.App, Version: "new", PointerPromotedAt: at, RefusedAt: at, Reason: "failed"} |
| + if err := h.AddRefusal(b, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := WriteRefusal(legacy, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "rename": |
| + if err := os.Rename(RefusalDir(legacy), filepath.Join(legacy, "replaced")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "recovered": |
| + ref.Recovered, ref.Version = true, "old" |
| + if err := WriteRefusal(legacy, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "delete", "signed-retry": |
| + if err := ClearRefusal(legacy, b.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if attack == "signed-retry" { |
| + if err := st.Delete(context.Background(), ChannelKey(b.Account, b.App, "canary/x")); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeBackdatedPointer(t, st, priv, b.Account, b.App, "canary/x", "new", at.Add(time.Second)) |
| + } |
| + effects := 0 |
| + 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 }, |
| + } |
| + err := Pass(context.Background(), reg, st, pub, opt, func(string) error { |
| + r, err := h.read(b) |
| + if err != nil || r.Attempt == nil { |
| + t.Fatal("restart without durable intent", err) |
| + } |
| + effects++ |
| + return nil |
| + }, BakeDir(legacy), WithRootHistory(deps)) |
| + if attack == "signed-retry" { |
| + if err != nil || effects != 1 { |
| + t.Fatalf("signed retry refused: %v, %d", err, effects) |
| + } |
| + if v, _ := LiveVersion(opt, b.Host); v != "new" { |
| + t.Fatal("retry did not adopt") |
| + } |
| + } else if effects != 0 { |
| + t.Fatalf("%s retried failed promotion", attack) |
| + } |
| + refs, readErr := h.Refusals(b) |
| + want := 1 |
| + if attack == "signed-retry" { |
| + want = 0 |
| + } |
| + if readErr != nil || len(refs) != want { |
| + t.Fatalf("lost retained refusal: %v, %v", refs, readErr) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryPassRejectsProgramForgery(t *testing.T) { |
| + good, bad := tinyProgram(t, "old/program"), tinyProgram(t, "foreign/program") |
| + for _, attack := range []string{"foreign-history", "empty-root", "same-program", "failed-observation", "changed-binding"} { |
| + t.Run(attack, func(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) |
| + opt, legacy := t.TempDir(), t.TempDir() |
| + reg := testRegistry(t) |
| + addRoute(t, reg, b.Host, b.App, "canary/x", false) |
| + if err := StageArtifact(opt, b.Account, "old", b.App, good); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PointLive(opt, b.Host, b.Account, "old", b.App); err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := bad |
| + if attack == "same-program" || attack == "failed-observation" || attack == "changed-binding" { |
| + binary = good |
| + } |
| + shipRelease(t, st, b.Account, b.App, "new", adoptPlatform, binary) |
| + if err := Promote(context.Background(), st, priv, b.Account, b.App, "canary/x", "new"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RecordProgram(legacy, b.Host, "foreign/program"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "empty-root" { |
| + r, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Programs.Values = nil |
| + if err := h.write(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + reads, effects := 0, 0 |
| + deps := &RootHistoryPass{History: h, |
| + Resolve: func(context.Context, registry.Route) (HistoryBinding, string, error) { |
| + reads++ |
| + if attack == "changed-binding" && reads >= 3 { |
| + return b, "changed-record", nil |
| + } |
| + return b, "record", nil |
| + }, |
| + Installed: func(context.Context, registry.Route, string) (string, error) { |
| + if attack == "failed-observation" { |
| + return "", os.ErrPermission |
| + } |
| + return "old/program", nil |
| + }, |
| + } |
| + err := Pass(context.Background(), reg, st, pub, opt, func(string) error { effects++; return nil }, BakeDir(legacy), WithRootHistory(deps)) |
| + v, _ := LiveVersion(opt, b.Host) |
| + switch attack { |
| + case "same-program": |
| + if err != nil || v != "new" || effects != 1 { |
| + t.Fatalf("valid adoption failed: %s %d %v", v, effects, err) |
| + } |
| + case "failed-observation": |
| + if err == nil { |
| + t.Fatal("unobserved install reported success") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("unverified attempt settled") |
| + } |
| + default: |
| + if v != "old" || effects != 0 { |
| + t.Fatalf("%s adopted: %s %d %v", attack, v, effects, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryProgramChangeNeedsSignedManifest(t *testing.T) { |
| + binary := tinyProgram(t, "foreign/program") |
| + for _, signed := range []bool{false, true} { |
| + t.Run(fmt.Sprint(signed), func(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 := context.Background() |
| + opt, legacy := t.TempDir(), t.TempDir() |
| + reg := testRegistry(t) |
| + addRoute(t, reg, b.Host, b.App, "canary/x", false) |
| + m := Manifest{App: b.App, Version: "new", Kind: "binary", ProgramChange: true, Artifacts: map[string]Artifact{adoptPlatform: {}}} |
| + if err := Ship(ctx, st, b.Account, m, map[string][]byte{adoptPlatform: binary}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if signed { |
| + if err := WriteDigestMarker(ctx, st, RootSigner(priv), b.Account, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := Promote(ctx, st, priv, b.Account, b.App, "canary/x", "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 "foreign/program", nil }} |
| + effects := 0 |
| + err := Pass(ctx, reg, st, pub, opt, func(string) error { effects++; return nil }, BakeDir(legacy), WithRootHistory(deps)) |
| + if signed && (err != nil || effects != 1) { |
| + t.Fatalf("signed consent refused: %v %d", err, effects) |
| + } |
| + if !signed && effects != 0 { |
| + t.Fatal("unsigned manifest field granted program change") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryPassRetainsBake(t *testing.T) { |
| + for _, elapsed := range []bool{false, true} { |
| + t.Run(fmt.Sprint(elapsed), func(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + start := time.Now().UTC().Add(-time.Minute) |
| + if elapsed { |
| + start = start.Add(-121 * time.Hour) |
| + } |
| + seedRootBake(t, h, b, start) |
| + clock, err := h.LoadBake([]HistoryBinding{b}, start) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + clock.Observe(b.Account, b.App, "stable", "new", start) |
| + clock.Production = map[string]*prodMemory{prodKey(b.Account, b.App): {WasProduction: true}} |
| + clock.Holds = map[string]*holdMemory{channelClockKey(b.Account, b.App, "stable"): {Window: 96 * time.Hour, SetAt: start}} |
| + if err := clock.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + opt, legacy := t.TempDir(), t.TempDir() |
| + reg := testRegistry(t) |
| + addRoute(t, reg, b.Host, b.App, "stable", false) |
| + shipRelease(t, st, b.Account, b.App, "new", adoptPlatform, []byte("new")) |
| + writeBackdatedPointer(t, st, priv, b.Account, b.App, "stable", "new", start) |
| + forged := LoadFirstSeen(FirstSeenPath(legacy)) |
| + forged.Observe(b.Account, b.App, "stable", "new", start.Add(-1000*time.Hour)) |
| + if err := forged.Save(); 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 |
| + err = Pass(context.Background(), reg, st, pub, opt, func(string) error { effects++; return nil }, BakeDir(legacy), WithRootHistory(deps)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if elapsed && effects != 1 { |
| + t.Fatal("root-observed interval never admitted adoption") |
| + } |
| + if !elapsed && effects != 0 { |
| + t.Fatal("legacy clock shortened root hold") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/plan14_root_history_test.go b/internal/release/plan14_root_history_test.go| index 9fedede24..894af96d3 100644 |
| --- a/internal/release/plan14_root_history_test.go |
| +++ b/internal/release/plan14_root_history_test.go |
| @@ -38,6 +38,23 @@ func TestPlan14RootHistoryMissingIsUnknown(t *testing.T) { |
| } |
| } |
| +func TestPlan14RootHistoryObservedProgramDoesNotInitializeRefusals(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + if err := os.Remove(filepath.Join(s.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.RecordInstalledProgram(b, "observed/program"); err != nil { |
| + t.Fatal(err) |
| + } |
| + programs, err := h.Programs(b) |
| + if err != nil || len(programs) != 1 || programs[0] != "observed/program" { |
| + t.Fatalf("observed programs: %v, %v", programs, err) |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("current service proved absence of historical failures") |
| + } |
| +} |
| + |
| func TestPlan14RootHistoryRejectsMalformed(t *testing.T) { |
| h, s, b := rootHistoryFixture(t) |
| good, err := s.Read(historyKey(b)) |
diff --git a/internal/release/rollback.go b/internal/release/rollback.go| index ab95da19d..dcdbe3831 100644 |
| --- a/internal/release/rollback.go |
| +++ b/internal/release/rollback.go |
| @@ -4,7 +4,9 @@ |
| package release |
| import ( |
| + "context" |
| "fmt" |
| + "os" |
| "strings" |
| "time" |
| @@ -115,7 +117,7 @@ type recovery struct { |
| // so each record carries the recovery's real outcome (spec §2.6). |
| func writeGroupRefusals(cfg passConfig, hosts []string, app string, ptr *ChannelPointer, |
| rejected, sha, platform string, nu *UnitNotUpError, rec recovery, now time.Time) { |
| - if cfg.bakeDir == "" { |
| + if cfg.bakeDir == "" && !cfg.requireRootHistory { |
| return |
| } |
| reason := nu.Reason |
| @@ -127,7 +129,17 @@ func writeGroupRefusals(cfg passConfig, hosts []string, app string, ptr *Channel |
| r := Refusal{Host: h, App: app, Version: rejected, SHA256: sha, Platform: platform, |
| PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(), Reason: reason, |
| RestoredVersion: rec.restored, Recovered: rec.recovered, RecoveryError: rec.err} |
| - if err := WriteRefusal(cfg.bakeDir, r); err != nil { |
| + rt := registry.Route{Host: h, App: app} |
| + if cfg.requireRootHistory { |
| + current, err := cfg.route(h) |
| + if err != nil || current == nil || current.App != app { |
| + cfg.historyError(os.ErrPermission) |
| + continue |
| + } |
| + rt = *current |
| + } |
| + if err := cfg.recordRefusal(context.Background(), rt, r); err != nil { |
| + cfg.historyError(err) |
| fmt.Printf(" %s: writing refusal record: %v\n", h, err) |
| } |
| } |
diff --git a/internal/release/rootbake.go b/internal/release/rootbake.go| new file mode 100644 |
| index 000000000..b7452cba7 |
| --- /dev/null |
| +++ b/internal/release/rootbake.go |
| @@ -0,0 +1,105 @@ |
| +package release |
| + |
| +import ( |
| + "encoding/json" |
| + "math" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +const rootBakeKey = "bake.json" |
| + |
| +type rootBakeRecord struct { |
| + Schema int `json:"schema"` |
| + Revision uint64 `json:"revision"` |
| + Complete map[string]uint64 `json:"complete"` |
| + Observed time.Time `json:"observed"` |
| + Clock FirstSeen `json:"clock"` |
| +} |
| + |
| +func validRootBake(r rootBakeRecord) bool { |
| + if r.Schema != 1 || r.Revision == 0 || r.Observed.IsZero() || r.Complete == nil || |
| + r.Clock.Channels == nil || math.IsNaN(r.Clock.Jitter) || r.Clock.Jitter < 0 || r.Clock.Jitter >= .25 { |
| + return false |
| + } |
| + for key, revision := range r.Complete { |
| + parts := strings.Split(key, clockSep) |
| + if len(parts) != 2 || !ValidAccount(parts[0]) || !ValidApp(parts[1]) || revision == 0 || revision > r.Revision { |
| + return false |
| + } |
| + } |
| + for _, versions := range r.Clock.Channels { |
| + if versions == nil || len(versions) > firstSeenKeep { |
| + return false |
| + } |
| + for version, at := range versions { |
| + if !ValidVersion(version) || at.IsZero() || at.After(r.Observed) { |
| + return false |
| + } |
| + } |
| + } |
| + for _, mem := range r.Clock.Production { |
| + if mem == nil || mem.OffFirstSeen.After(r.Observed) || mem.OffSetAt.IsZero() != mem.OffFirstSeen.IsZero() { |
| + return false |
| + } |
| + } |
| + for _, mem := range r.Clock.Holds { |
| + if mem == nil || mem.Window < 0 || mem.Pending < 0 || mem.PendingAt.After(r.Observed) || mem.DeletedAt.After(r.Observed) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func (h *RootHistory) writeBake(r rootBakeRecord) error { |
| + if h == nil || h.Store == nil || !validRootBake(r) { |
| + return os.ErrPermission |
| + } |
| + raw, err := json.Marshal(r) |
| + if err != nil { |
| + return err |
| + } |
| + if len(raw) > rootstate.MaxRecord { |
| + return os.ErrPermission |
| + } |
| + return h.Store.Write(rootBakeKey, raw) |
| +} |
| + |
| +func (h *RootHistory) LoadBake(bindings []HistoryBinding, now time.Time) (*FirstSeen, error) { |
| + if h == nil || h.Store == nil || now.IsZero() { |
| + return nil, os.ErrPermission |
| + } |
| + raw, err := h.Store.Read(rootBakeKey) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r rootBakeRecord |
| + if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &r) != nil || !validRootBake(r) || now.Before(r.Observed) { |
| + return nil, os.ErrPermission |
| + } |
| + for _, b := range bindings { |
| + if !b.valid() || r.Complete[prodKey(b.Account, b.App)] == 0 { |
| + return nil, os.ErrPermission |
| + } |
| + } |
| + if err := h.Store.Write(rootBakeKey, raw); err != nil { |
| + return nil, err |
| + } |
| + clock := r.Clock |
| + clock.migrated = true |
| + clock.save = func() error { |
| + if r.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + r.Revision++ |
| + r.Observed, r.Clock = now.UTC(), clock |
| + for key := range r.Complete { |
| + r.Complete[key] = r.Revision |
| + } |
| + return h.writeBake(r) |
| + } |
| + return &clock, nil |
| +} |
diff --git a/internal/release/roothistory.go b/internal/release/roothistory.go| index 10fa132fd..973495e43 100644 |
| --- a/internal/release/roothistory.go |
| +++ b/internal/release/roothistory.go |
| @@ -140,6 +140,25 @@ func (h *RootHistory) Programs(b HistoryBinding) ([]string, error) { |
| return slices.Clone(r.Programs.Values), nil |
| } |
| +func (h *RootHistory) RecordInstalledProgram(b HistoryBinding, program string) error { |
| + r, err := h.read(b) |
| + if errors.Is(err, os.ErrNotExist) && h != nil && h.Store != nil && b.valid() { |
| + r = historyRecord{Schema: 1, Binding: b, Revision: 1, |
| + Programs: historyPrograms{Revision: 1}, Refusals: historyRefusals{Revision: 1}} |
| + } else if err != nil { |
| + return err |
| + } |
| + if r.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + if program != "" && !slices.Contains(r.Programs.Values, program) { |
| + r.Programs.Values = append(r.Programs.Values, program) |
| + } |
| + r.Revision++ |
| + r.Programs.Complete, r.Programs.Revision = true, r.Revision |
| + return h.write(r) |
| +} |
| + |
| func (h *RootHistory) Refusals(b HistoryBinding) ([]Refusal, error) { |
| r, err := h.read(b) |
| if err != nil { |
diff --git a/internal/release/roothistory_app.go b/internal/release/roothistory_app.go| new file mode 100644 |
| index 000000000..1b0ad3c36 |
| --- /dev/null |
| +++ b/internal/release/roothistory_app.go |
| @@ -0,0 +1,106 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "os" |
| + "slices" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type historyApp struct { |
| + Schema int `json:"schema"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Revision uint64 `json:"revision"` |
| + Complete bool `json:"complete"` |
| + Hosts []string `json:"hosts"` |
| +} |
| + |
| +func historyAppKey(acct, app string) string { |
| + sum := sha256.Sum256([]byte(acct + "\x00" + app)) |
| + return hex.EncodeToString(sum[:]) + ".app" |
| +} |
| + |
| +func validHistoryApp(a historyApp) bool { |
| + if a.Schema != 1 || !ValidAccount(a.Account) || !ValidApp(a.App) || a.Revision == 0 || len(a.Hosts) > historyLimit { |
| + return false |
| + } |
| + seen := map[string]bool{} |
| + for _, host := range a.Hosts { |
| + b := HistoryBinding{Host: host, Account: a.Account, App: a.App} |
| + if !b.valid() || seen[host] { |
| + return false |
| + } |
| + seen[host] = true |
| + } |
| + return true |
| +} |
| + |
| +func (h *RootHistory) writeApp(a historyApp) error { |
| + if h == nil || h.Store == nil || !validHistoryApp(a) { |
| + return os.ErrPermission |
| + } |
| + raw, err := json.Marshal(a) |
| + if err != nil { |
| + return err |
| + } |
| + return h.Store.Write(historyAppKey(a.Account, a.App), raw) |
| +} |
| + |
| +func (h *RootHistory) AppRefusals(acct, app string) ([]Refusal, error) { |
| + if h == nil || h.Store == nil || !ValidAccount(acct) || !ValidApp(app) { |
| + return nil, os.ErrPermission |
| + } |
| + key := historyAppKey(acct, app) |
| + raw, err := h.Store.Read(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var a historyApp |
| + if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &a) != nil || !validHistoryApp(a) || !a.Complete || a.Account != acct || a.App != app { |
| + return nil, os.ErrPermission |
| + } |
| + if err := h.Store.Write(key, raw); err != nil { |
| + return nil, err |
| + } |
| + names, err := h.Store.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, name := range names { |
| + if name == rootBakeKey || !strings.HasSuffix(name, ".json") { |
| + continue |
| + } |
| + raw, err := h.Store.Read(name) |
| + var r historyRecord |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &r) != nil || !validHistory(r, r.Binding) || historyKey(r.Binding) != name { |
| + return nil, os.ErrPermission |
| + } |
| + if r.Binding.Account == acct && r.Binding.App == app && !slices.Contains(a.Hosts, r.Binding.Host) { |
| + return nil, os.ErrPermission |
| + } |
| + } |
| + var out []Refusal |
| + for _, host := range a.Hosts { |
| + refs, err := h.Refusals(HistoryBinding{Host: host, Account: acct, App: app}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, refs...) |
| + } |
| + return out, nil |
| +} |
| + |
| +func (h *RootHistory) MintRefusals(b HistoryBinding) ([]Refusal, error) { |
| + if _, err := h.Refusals(b); err != nil { |
| + return nil, err |
| + } |
| + return h.AppRefusals(b.Account, b.App) |
| +} |
diff --git a/internal/release/roothistory_pass.go b/internal/release/roothistory_pass.go| new file mode 100644 |
| index 000000000..d26bf096a |
| --- /dev/null |
| +++ b/internal/release/roothistory_pass.go |
| @@ -0,0 +1,157 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "os" |
| + "slices" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +type RootHistoryPass struct { |
| + History *RootHistory |
| + Resolve func(context.Context, registry.Route) (HistoryBinding, string, error) |
| + Installed func(context.Context, registry.Route, string) (string, error) |
| +} |
| + |
| +type historySelection struct { |
| + binding HistoryBinding |
| + source string |
| +} |
| + |
| +func WithRootHistory(h *RootHistoryPass) PassOption { |
| + return func(c *passConfig) { |
| + c.rootHistory = h |
| + c.requireRootHistory = true |
| + } |
| +} |
| + |
| +func (h *RootHistoryPass) resolve(ctx context.Context, rt registry.Route) (historySelection, error) { |
| + if h == nil || h.History == nil || h.Resolve == nil { |
| + return historySelection{}, os.ErrPermission |
| + } |
| + b, source, err := h.Resolve(ctx, rt) |
| + if err != nil { |
| + return historySelection{}, err |
| + } |
| + if !b.valid() || source == "" || b.Host != rt.Host || b.Account != RouteAccount(rt) || b.App != rt.App { |
| + return historySelection{}, os.ErrPermission |
| + } |
| + return historySelection{b, source}, nil |
| +} |
| + |
| +func (h *RootHistoryPass) selectRoute(ctx context.Context, rt registry.Route) (historySelection, error) { |
| + sel, err := h.resolve(ctx, rt) |
| + if err != nil { |
| + return sel, err |
| + } |
| + if h.Installed == nil { |
| + return sel, os.ErrPermission |
| + } |
| + if _, err := h.History.Programs(sel.binding); err != nil { |
| + return sel, err |
| + } |
| + _, err = h.History.Refusals(sel.binding) |
| + return sel, err |
| +} |
| + |
| +func (c passConfig) recheckHistory(ctx context.Context, rt registry.Route) error { |
| + if !c.requireRootHistory { |
| + return nil |
| + } |
| + return c.rootHistory.recheck(ctx, rt, c.historySelected[rt.Host]) |
| +} |
| + |
| +func (c passConfig) bindProof(ctx context.Context, optDir string, rt registry.Route, ptr *ChannelPointer, v proofVerdict, cur string, now time.Time) bool { |
| + if !c.requireRootHistory { |
| + return c.proof.bind(optDir, rt, ptr, v, cur, now) |
| + } |
| + return c.proof.bindRecording(optDir, rt, ptr, v, cur, now, func(r Refusal) error { |
| + err := c.recordRefusal(ctx, rt, r) |
| + c.historyError(err) |
| + return err |
| + }) |
| +} |
| + |
| +func (h *RootHistoryPass) recheck(ctx context.Context, rt registry.Route, want historySelection) error { |
| + got, err := h.resolve(ctx, rt) |
| + if err != nil { |
| + return err |
| + } |
| + if got != want { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func (h *RootHistoryPass) begin(ctx context.Context, rt registry.Route, sel historySelection, version string, now time.Time) (string, error) { |
| + if err := h.recheck(ctx, rt, sel); err != nil { |
| + return "", err |
| + } |
| + var id [16]byte |
| + 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) |
| +} |
| + |
| +func (h *RootHistoryPass) complete(ctx context.Context, rt registry.Route, sel historySelection, id, version string) error { |
| + if h.Installed == nil { |
| + return os.ErrPermission |
| + } |
| + if err := h.recheck(ctx, rt, sel); err != nil { |
| + return err |
| + } |
| + program, err := h.Installed(ctx, rt, version) |
| + if err != nil { |
| + return err |
| + } |
| + if err := h.recheck(ctx, rt, sel); err != nil { |
| + return err |
| + } |
| + r, err := h.History.read(sel.binding) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Attempt == nil || r.Attempt.ID != id || r.Attempt.Version != version || r.Attempt.Source != sel.source || r.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + if program != "" && !slices.Contains(r.Programs.Values, program) { |
| + r.Programs.Values = append(r.Programs.Values, program) |
| + } |
| + r.Revision++ |
| + r.Programs.Revision = r.Revision |
| + r.Refusals.Values = slices.DeleteFunc(r.Refusals.Values, func(ref Refusal) bool { return ref.Version == version }) |
| + r.Refusals.Revision = r.Revision |
| + r.Attempt = nil |
| + return h.History.write(r) |
| +} |
| + |
| +func (c passConfig) historyError(err error) { |
| + if err != nil && c.restartErrors != nil { |
| + *c.restartErrors = append(*c.restartErrors, err) |
| + } |
| +} |
| + |
| +func (c passConfig) recordRefusal(ctx context.Context, rt registry.Route, r Refusal) error { |
| + if c.requireRootHistory { |
| + sel, ok := c.historySelected[rt.Host] |
| + if !ok { |
| + return os.ErrPermission |
| + } |
| + if err := c.rootHistory.recheck(ctx, rt, sel); err != nil { |
| + return err |
| + } |
| + if err := c.rootHistory.History.AddRefusal(sel.binding, r); err != nil { |
| + return err |
| + } |
| + } |
| + if c.bakeDir != "" { |
| + return WriteRefusal(c.bakeDir, r) |
| + } |
| + return nil |
| +} |