carlos / platform Public

settings-4 task 6: root installs what the settings call for, with §4.1's evidence

adoptHost asks release.Target which build each exec instance installs.
The signed head (digest-checked) authorizes today's adoption and a
request whose T is still the head; everything else needs a promotion
record with a matching manifest digest, or recovery from this box's own
staged, verified copy, and is otherwise refused (settings.evidence.no-record).
Only root's pass resolves requests and writes /opt/carlos/consumed/<host>,
before staging, through the pure release.NextConsumed (R-MERGE). The
proof gate, D1, D2 and the refusal records judge the selected build (sel).

The failure book is opened per pass. failed = Blocks || HeldDown. An
unreadable swap-failures.json holds every route with settings and every
rollout.Managed route; a default unmanaged route keeps today's adoption
byte for byte and never reads the book.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f805dd7ce4477ccd1476fe0b30f3c72903a1f11b parent 8bb475a
5 files changed, +1024 −22
  • internal/host/agent.go +12 −3
  • internal/host/updatesroot.go +33 −0
  • internal/release/adopt.go +282 −18
  • internal/release/adoptsettings_test.go +650 −0
  • internal/release/updatetarget.go +47 −1
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 4a656abaf..365e8894e 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1002,7 +1002,8 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st
// adopt an unproven build of a listed app.
return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host,
release.OnStageBlocked(stageReq), release.WithProofGate(agentProofGate()),
- release.WithDigestRatchet(pointerRatchet(false, optDir, dd, os.Getenv)))
+ release.WithDigestRatchet(pointerRatchet(false, optDir, dd, os.Getenv)),
+ updatesPassOption(dd, false))
}
}
@@ -1085,7 +1086,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps,
release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)),
release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv)),
release.WithProofGate(agentProofGate()), release.WithHostWrites(writes),
- release.WithDigestRatchet(pointerRatchet(false, optDir, dataDir(), os.Getenv))); err != nil {
+ release.WithDigestRatchet(pointerRatchet(false, optDir, dataDir(), os.Getenv)),
+ updatesPassOption(dataDir(), false)); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
// The schedule converge's hourly floor (schedule spec §6.2), AFTER the
@@ -1783,6 +1785,10 @@ func fastPassOpts(dd string, stageReq func(host, ver string, err error), restart
release.RestartHibernating(restart),
release.WithStartUnit(start),
release.WithDigestRatchet(pointerRatchet(false, release.OptDir(), dd, os.Getenv)),
+ // Settings spec §4: the agent applies root's decisions, never
+ // resolves. The book is opened per pass, not here: these options are
+ // built once per agent run.
+ updatesPassOption(dd, false),
}
}
@@ -4307,7 +4313,10 @@ func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensu
// at the start of this pass (BeginPass) like every other. Its bake
// directory is BakeDir's above: the healthy history it reads is the
// one this pass and recordSettled write.
- release.WithProofGate(release.NewProofGate(release.ProofPolicyPath, dataDir()))}
+ release.WithProofGate(release.NewProofGate(release.ProofPolicyPath, dataDir())),
+ // Settings spec §4.3: root alone resolves install requests and
+ // writes /opt/carlos/consumed/<host>, before it stages.
+ updatesPassOption(dataDir(), true)}
if !*stageOnly {
passOpts = append(passOpts, release.NagExpiredCanaries(os.Stdout))
}
diff --git a/internal/host/updatesroot.go b/internal/host/updatesroot.go
new file mode 100644
index 000000000..2b41306d7
--- /dev/null
+++ b/internal/host/updatesroot.go
@@ -0,0 +1,33 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rollout"
+)
+
+// updatesPassOption arms the instance update settings on an adoption pass
+// (settings spec §4). root is set on carlos-adopt's pass only: root resolves
+// install requests and writes the consumed file; the agent's passes apply
+// the same decisions and write nothing.
+//
+// The failure book is the zero-downtime plans' (§4.3: failure records
+// outrank every rule), read from dd's swap-failures.json at the start of
+// every pass, never once per process: the fast loop's options are built once
+// per agent run. A build is failed for a host when the book blocks it
+// (Blocks: a record the host has not served or been retried past) or holds
+// the host down on it (HeldDown: a hold attempt onto it failed there). An
+// unreadable book holds every route with settings and every managed route
+// (rollout.Managed), as refuseManaged does; a default unmanaged route keeps
+// today's adoption, which never read the book.
+func updatesPassOption(dd string, root bool) release.PassOption {
+ open := func() (release.FailedBuild, error) {
+ book, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ return nil, err
+ }
+ return func(acct, app, build, host string, served bool) bool {
+ return book.Blocks(acct, app, build, host, served) || book.HeldDown(acct, app, build, host)
+ }, nil
+ }
+ return release.WithUpdateSettings(open, rollout.Managed, root)
+}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index debba5df8..2a423474f 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -19,9 +19,11 @@ import (
"time"
"github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/controlname"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/safefile"
+ "github.com/carlosframework/platform/internal/updatewords"
)
// adoptPlatform picks which manifest artifact a box installs — a var so
@@ -98,8 +100,32 @@ type passConfig struct {
// stage is what ensureStagedChecked needs to judge a staged copy it is
// about to reuse (spec §4.2). PassFilter sets it once per pass.
stage stageCheck
+
+ // settings arms the instance update settings (settings spec §4,
+ // WithUpdateSettings): adoptHost asks Target which build to install for
+ // each exec instance. resolve is root's alone: only root writes the
+ // consumed file. openFailed opens the zero-downtime failure book;
+ // PassFilter calls it once per pass into failed (or failedErr), so a
+ // long-lived option never reads a stale book. managed is
+ // rollout.Managed: an unreadable book holds those routes too.
+ settings bool
+ resolve bool
+ openFailed func() (FailedBuild, error)
+ managed func(registry.Route) bool
+ failed FailedBuild
+ failedErr error
+ // pub verifies promotion records (§4.1 evidence); PassFilter sets it.
+ pub ed25519.PublicKey
+ // tags is "<acct>/<app>" -> release version -> its newest semver tag, read
+ // once per pass for apps with a route under a major setting.
+ tags map[string]map[string]string
}
+// FailedBuild reports whether host may not be moved onto build: the
+// zero-downtime failure book (rollout.FailureBook), taken as a func because
+// rollout imports this package. served is build == the route's GoodVersion.
+type FailedBuild func(acct, app, build, host string, served bool) bool
+
// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
// line per expired canary/* route the call sees, written to w.
//
@@ -301,6 +327,24 @@ func WithDigestRatchet(r *DigestRatchet) PassOption {
return func(c *passConfig) { c.ratchet = r }
}
+// WithUpdateSettings arms settings spec §4 for this pass: each exec
+// instance installs what Target decides, with §4.1's evidence. book opens
+// the failure book; it is called at the start of every pass, so pass the
+// opener, never a book read once (nil: no failure records). managed is
+// rollout.Managed (nil: none). resolve is set on root's pass only: root
+// alone resolves install requests and writes the consumed file (§4.3 rule
+// 2); the agent's passes apply the same decisions and write nothing.
+//
+// A route with default settings and no consumed file takes today's
+// adoption unchanged and never consults the book. An unreadable book holds
+// every route with settings and every managed route (the zero-downtime
+// plans' refuseManaged posture).
+func WithUpdateSettings(book func() (FailedBuild, error), managed func(registry.Route) bool, resolve bool) PassOption {
+ return func(c *passConfig) {
+ c.settings, c.openFailed, c.managed, c.resolve = true, book, managed, resolve
+ }
+}
+
// WithPinSource adds src's references to the pin set a reused staged copy is
// checked against (spec §4.2), on top of the live links, the registry rows
// and the generations file, which every pass with a data dir reads itself.
@@ -372,6 +416,15 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
for _, opt := range opts {
opt(&cfg)
}
+ cfg.pub = pub
+ // Settings spec §4.3: the failure book is read fresh for every pass, so a
+ // record the agent persisted since the last one counts now.
+ if cfg.settings && cfg.openFailed != nil {
+ cfg.failed, cfg.failedErr = cfg.openFailed()
+ if cfg.failedErr != nil {
+ fmt.Printf(" failure book unreadable (%v): routes with update settings and managed routes install nothing this pass\n", cfg.failedErr)
+ }
+ }
// D3: re-read the proof policy at the start of every pass, so an edit
// reaches the agent and root alike without a restart (spec §4.5).
cfg.proof.BeginPass()
@@ -521,6 +574,36 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
pointers[k] = ptr
}
+ // Settings spec §3.1: a major setting holds a head whose semver tag is
+ // absent or in another major. Tags are read once per app that has a route
+ // under one. ListTags is semver-descending, so the first tag seen for a
+ // release is its newest. An unreadable list leaves the app's map empty:
+ // every head reads as untagged, so a major-held instance stays held.
+ if cfg.settings {
+ cfg.tags = map[string]map[string]string{}
+ for _, rt := range routes {
+ u, err := ParseUpdateSettings(rt.UpdateSettings)
+ if err != nil || u.Major == nil || !adopts(rt) {
+ continue
+ }
+ k := RouteAccount(rt) + "/" + rt.App
+ if _, done := cfg.tags[k]; done {
+ continue
+ }
+ m := map[string]string{}
+ tags, err := ListTags(ctx, s, RouteAccount(rt), rt.App)
+ if err != nil {
+ fmt.Printf(" %s: tags unreadable (%v): every head reads as untagged\n", k, err)
+ }
+ for _, tg := range tags {
+ if _, seen := m[tg.Release]; !seen {
+ m[tg.Release] = tg.Version
+ }
+ }
+ cfg.tags[k] = m
+ }
+ }
+
// Resolve each app's pipeline once per pass — for every app with a
// resolved NON-CANARY head, never one read per channel or per route.
// canary/* lives outside the pipeline entirely (spec §2): no pipeline
@@ -1061,7 +1144,11 @@ func RouteAccount(rt registry.Route) string {
// this box (not declared production, or declared off and past the ratchet);
// the caller computes it once per route from the flag it read this pass.
func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time, cfg passConfig, holds map[string]time.Duration, ignore bool) {
- if ptr == nil {
+ // settings: this route's build is the instance update settings' decision
+ // (settings spec §4.3). Rules 1-3 need no channel head, so a route with
+ // settings is not skipped when the pointer is unreadable (Astra I1 #9).
+ settings := cfg.settings && rt.Kind == "instance" && rt.Unit == ""
+ if ptr == nil && !settings {
return
}
nag := cfg.nag
@@ -1075,7 +1162,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
// must stay silent on the 2-3-second canary fast loop and speak only on
// the hourly root adopt pass, or an expired canary sitting still would
// nag every few seconds forever instead of once an hour.
- if nag != nil && CanaryExpired(ptr, now) {
+ if nag != nil && ptr != nil && CanaryExpired(ptr, now) {
// FormatCanaryAge(CanaryAge(...)), NOT a raw now.Sub(ptr.PromotedAt):
// that would print the pointer's TOTAL age (CanaryAge + CanaryTTL),
// overstating how long it's been expired by exactly one lease length
@@ -1088,7 +1175,10 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
rt.Host, ptr.Channel, ptr.Version, FormatCanaryAge(CanaryAge(ptr, now)))
}
channel := routeChannel(rt)
- ver := ptr.Version
+ ver := ""
+ if ptr != nil {
+ ver = ptr.Version
+ }
cur, err := LiveVersion(optDir, rt.Host)
if err != nil {
fmt.Printf(" %s: read live link: %v — skipped\n", rt.Host, err)
@@ -1103,11 +1193,34 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
// be stuck missing forever for exactly the version actually running.
// This call never fails the pass: an absent or unreadable sibling is the
// safe leave-alone state, so a repeated failure here is only a log line.
- if cur != "" {
+ if cur != "" && ptr != nil {
backfillManifestDigest(ctx, s, optDir, RouteAccount(rt), rt.App, cur, rt.Host, ptr.DigestOf(cur))
}
+ // Instance update settings (settings spec §4): one decision per route.
+ // Everything below (bake for the decisions that take it, the proof gate,
+ // refusals, D2, caps, restart) applies to whatever it chooses. A route
+ // with default settings and no consumed file gets today's head.
+ var dec TargetDecision
+ if settings {
+ var stop bool
+ ver, dec, stop = settingsTarget(optDir, bake, rt, ptr, cur, now, cfg, holds, ignore)
+ if stop {
+ return
+ }
+ }
+ // sel is the build this pass installs, as the gates below read it (Astra
+ // I2 #8): the head itself when the decision is the head, else a pointer
+ // naming only the selected build, the way heldProofAdmit's stand-in does
+ // (materializeheld.go). The bake waiver, the proof gate (admit and bind),
+ // D1, D2 and the refusal records judge the build that would run, never a
+ // head the decision passed over. ptr stays the real head, nil or not, for
+ // the staging digest below.
+ sel := ptr
+ if ptr == nil || ver != ptr.Version {
+ sel = &ChannelPointer{Channel: channel, Version: ver}
+ }
if cur == ver {
- cfg.proof.clear(rt.Host) // live on its head: nothing left to wait for
+ cfg.proof.clear(rt.Host) // live on its target: nothing left to wait for
// A refusal that no longer blocks the pointer is stale even though
// nothing moves: v2 was refused and rolled back to v1, then the
// channel went back to v1. Without this every later pass returns
@@ -1116,8 +1229,8 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
// agent cannot remove root's file, so its failure is ignored and the
// next root pass retries. A record that still blocks stays: the link
// is on the refused build only when its rollback failed, and that is
- // worth showing.
- if cfg.bakeDir != "" {
+ // worth showing. With no readable head nothing is known to be stale.
+ if cfg.bakeDir != "" && ptr != nil {
if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err == nil && r != nil && !r.Blocks(ptr) {
_ = ClearRefusal(cfg.bakeDir, rt.Host)
}
@@ -1136,10 +1249,16 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
// (cfg.proof.clear). A wait left behind for a head the channel has moved
// off would stay due for ever, and the fast loop would sweep the host
// every tick for the whole hold (plan 3 ruling 5).
- if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, holds, ignore || ptr.FastTrack, now); hold != "" {
- fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, ver, channel, hold)
- cfg.proof.clear(rt.Host)
- return
+ //
+ // Under settings only rule 4 and a new held instance's first head bake
+ // (dec.Bake): a pin, a resolved T and a recovery waive it (§4.3). Both
+ // baking decisions name the head, so sel is ptr there.
+ if !settings || dec.Bake {
+ if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, holds, ignore || sel.FastTrack, now); hold != "" {
+ fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, ver, channel, hold)
+ cfg.proof.clear(rt.Host)
+ return
+ }
}
if poisoned[ver] {
fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver)
@@ -1160,7 +1279,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
// records never depend on the policy.
if cfg.bakeDir != "" {
r, err := ReadRefusal(cfg.bakeDir, rt.Host)
- if err == nil && r.Blocks(ptr) && r.Kind == RefusalNotProven {
+ if err == nil && r.Blocks(sel) && r.Kind == RefusalNotProven {
if required, _ := cfg.proof.Requires(rt.App); !required {
if ClearRefusal(cfg.bakeDir, rt.Host) == nil {
fmt.Printf(" %s: cleared the not-proven refusal of %s: this box no longer requires a proof for %s\n", rt.Host, ver, rt.App)
@@ -1170,7 +1289,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
}
if err != nil {
fmt.Printf(" %s: reading refusal record: %v\n", rt.Host, err)
- } else if r.Blocks(ptr) {
+ } else if r.Blocks(sel) {
switch r.Kind {
case RefusalWrongProgram:
// --retry would re-stage the same bytes and be refused again,
@@ -1194,11 +1313,33 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
}
// 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, ptr, cur)
+ verdict, admitted := cfg.proof.admit(ctx, s, optDir, rt, sel, cur)
if !admitted {
return
}
- m, err := ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host, ptr.DigestOf(ver), cfg.stage)
+ // The evidence that authorizes ver (settings spec §4.1). Only today's
+ // adoption of the head, and a request whose T is still the head, rest on
+ // the signed pointer and its digest; everything else needs a promotion
+ // record with a matching manifest digest (stageAuthorized). The pointer
+ // branch is refused for any build but the head itself: a non-head build
+ // has no pointer digest, and staging it unbound on a digested channel is
+ // the #253 replay (invariant 3).
+ var m *Manifest
+ switch {
+ case !settings || dec.Evidence == EvidencePointer:
+ if ptr == nil || ver != ptr.Version {
+ fmt.Printf(" %s: %s: not the signed head, and the decision named no other evidence; not staged\n", rt.Host, ver)
+ cfg.proof.clear(rt.Host)
+ return
+ }
+ m, err = ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host, ptr.DigestOf(ver), cfg.stage)
+ case dec.Evidence == EvidenceRecord:
+ m, err = stageAuthorized(ctx, s, cfg.pub, optDir, RouteAccount(rt), rt.App, ver, rt.Host, cfg.stage)
+ default:
+ fmt.Printf(" %s: %s: no evidence this pass can check; not staged\n", rt.Host, ver)
+ cfg.proof.clear(rt.Host)
+ return
+ }
if err != nil {
fmt.Printf(" %s: %v\n", rt.Host, err)
// A version this uid cannot write is the one staging failure a
@@ -1252,7 +1393,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
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,
- Platform: adoptPlatform, PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(),
+ Platform: adoptPlatform, PointerPromotedAt: sel.PromotedAt, RefusedAt: now.UTC(),
Reason: "wrong program: " + newProg,
Kind: RefusalWrongProgram, Recovered: true, RestoredVersion: cur}); err != nil {
fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err)
@@ -1263,7 +1404,7 @@ 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, ptr, verdict, cur, now) {
+ if !cfg.proof.bind(optDir, rt, sel, verdict, cur, now) {
return
}
@@ -1600,7 +1741,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
if m != nil {
sha = m.Artifacts[adoptPlatform].SHA256
}
- writeGroupRefusals(cfg, group, rt.App, ptr, ver, sha, adoptPlatform, notUp, rec, now)
+ writeGroupRefusals(cfg, group, rt.App, sel, ver, sha, adoptPlatform, notUp, rec, now)
}
return
}
@@ -1981,3 +2122,126 @@ func backfillManifestDigest(ctx context.Context, s Store, optDir, acct, app, ver
fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
}
}
+
+// settingsTarget runs Target for one exec instance (settings spec §4.3) and
+// does what the decision needs before staging: on root's pass, write the
+// consumed file (a resolution BEFORE staging); refuse what installs nothing.
+// It returns the version to install, the decision, and stop: nothing is
+// installed this pass. Every stop takes the host off the proof waiting list,
+// as each of adoptHost's pre-admit returns does (plan 3 ruling 5).
+//
+// A route with default settings and no consumed file is today's adoption:
+// the head, with bake, on the pointer's evidence, and no book, no Target and
+// no consumed write. Only an unreadable failure book on a managed route
+// changes that (the zero-downtime plans' refuseManaged posture).
+func settingsTarget(optDir string, bake *FirstSeen, rt registry.Route, ptr *ChannelPointer, cur string, now time.Time,
+ cfg passConfig, holds map[string]time.Duration, ignore bool) (string, TargetDecision, bool) {
+ stop := func(dec TargetDecision) (string, TargetDecision, bool) {
+ cfg.proof.clear(rt.Host)
+ return "", dec, true
+ }
+ acct, channel := RouteAccount(rt), routeChannel(rt)
+ u, uerr := ParseUpdateSettings(rt.UpdateSettings)
+ if uerr != nil {
+ if cfg.resolve {
+ fmt.Printf(" %s: update settings unreadable (%v): following the channel\n", rt.Host, uerr)
+ }
+ u = UpdateSettings{Updates: UpdatesAuto}
+ }
+ plain := uerr == nil && u.Default()
+ managed := cfg.managed != nil && cfg.managed(rt)
+ consumed, cerr := ReadConsumed(optDir, rt.Host)
+ if plain && consumed == nil && (cerr == nil || !controlname.ValidHostShape(rt.Host)) {
+ if ptr == nil {
+ return "", TargetDecision{}, true // today's quiet skip
+ }
+ if managed && cfg.failedErr != nil {
+ return stop(TargetDecision{})
+ }
+ return ptr.Version, TargetDecision{Version: ptr.Version, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: ptr.Version}, false
+ }
+ if cerr != nil {
+ fmt.Printf(" %s: consumed file unreadable (%v): nothing installed\n", rt.Host, cerr)
+ return stop(TargetDecision{})
+ }
+ if cfg.failedErr != nil && (!plain || managed) {
+ return stop(TargetDecision{}) // PassFilter said why, once
+ }
+ bs, berr := ParseBuildStateCopy(rt.BuildState)
+ if berr != nil {
+ if cfg.resolve {
+ fmt.Printf(" %s: %v: read as unknown\n", rt.Host, berr)
+ }
+ bs = BuildStateCopy{}
+ }
+ in := TargetInput{Route: rt, Pointer: ptr, Settings: u, Build: bs, Consumed: consumed, Installed: cur}
+ if ptr != nil {
+ in.HeadHeld = Hold(bake, acct, rt.App, channel, ptr.Version, holds, ignore || ptr.FastTrack, now)
+ in.HeadTag = cfg.tags[acct+"/"+rt.App][ptr.Version]
+ }
+ // The failure records outrank every rule for a route with settings
+ // (§4.3). A route with default settings keeps today's adoption, which
+ // never read the book.
+ if !plain && cfg.failed != nil {
+ in.Failed = func(v string) bool { return cfg.failed(acct, rt.App, v, rt.Host, v == rt.GoodVersion) }
+ }
+ dec := Target(in)
+ if cfg.resolve {
+ if err := WriteConsumed(optDir, NextConsumed(in, dec)); err != nil {
+ fmt.Printf(" %s: recording the update decision: %v; nothing installed\n", rt.Host, err)
+ return stop(dec)
+ }
+ } else if dec.Resolve != nil && dec.Reason == ReasonRequest {
+ // Only root resolves (§4.3 rule 2), and only root's write makes T
+ // the request's: the next root pass does both. A decision that
+ // carries a Resolve for another reason (a pin, a hold, a failure:
+ // R-TARGET-FAILEDHEAD) does not depend on it, so it goes ahead.
+ return stop(dec)
+ }
+ if dec.Version == "" {
+ if cfg.resolve && dec.Reason != "" {
+ fmt.Printf(" %s: %s%s: nothing installed\n", rt.Host, dec.Reason, orDetail(dec.Detail))
+ }
+ return stop(dec)
+ }
+ return dec.Version, dec, false
+}
+
+// orDetail renders a decision's detail after its reason, or nothing.
+func orDetail(d string) string {
+ if d == "" {
+ return ""
+ }
+ return " (" + d + ")"
+}
+
+// stageAuthorized stages a build settings spec §4.1 authorizes by its
+// promotion record: the record's digest binds the manifest before anything
+// is downloaded (ensureStagedDigest). A build with no record (promoted
+// before records existed) is recovered from this box's own staged copy when
+// its bytes still hash to the artifact digest in the copy's sibling, the
+// manifest it was verified against when it was staged; anywhere else it is
+// refused. Any other failure to read the record is a refusal too, never read
+// as "no record". Nothing is backfilled.
+func stageAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, optDir, acct, app, ver, host string, chk stageCheck) (*Manifest, error) {
+ rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, ver)
+ if err == nil {
+ if rec.ManifestDigest == "" {
+ return nil, fmt.Errorf("%s: promotion record carries no manifest digest", ver)
+ }
+ return ensureStagedDigest(ctx, s, optDir, acct, app, ver, host, rec.ManifestDigest, chk)
+ }
+ if !errors.Is(err, ErrNoPromotionRecord) {
+ return nil, fmt.Errorf("%s: promotion record: %w", ver, err)
+ }
+ path := VersionPath(optDir, acct, ver, app)
+ if m, merr := ReadStagedManifest(optDir, acct, ver, app); merr == nil && m != nil {
+ if a, ok := m.Artifacts[adoptPlatform]; ok {
+ if verr := VerifyStaged(StagedIdentity{Version: ver, StagedPath: path, Digest: a.SHA256}); verr == nil {
+ fmt.Printf(" %s: %s has no promotion record; recovered from this box's staged copy\n", host, ver)
+ return m, nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("%s: %s", ver, updatewords.Text("settings.evidence.no-record"))
+}
diff --git a/internal/release/adoptsettings_test.go b/internal/release/adoptsettings_test.go
new file mode 100644
index 000000000..bb2354dbd
--- /dev/null
+++ b/internal/release/adoptsettings_test.go
@@ -0,0 +1,650 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+func settingsRoute(t *testing.T, reg *registry.Registry, host, app, channel string, u UpdateSettings, bs BuildStateCopy) {
+ t.Helper()
+ addRoute(t, reg, host, app, channel, true)
+ if err := reg.SetUpdateSettings(host, u.Column()); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState(host, bs.Column()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func passWithSettings(t *testing.T, reg *registry.Registry, s Store, pub []byte, opt string, more ...PassOption) string {
+ t.Helper()
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ opts := append([]PassOption{WithUpdateSettings(nil, nil, true)}, more...)
+ if err := Pass(context.Background(), reg, s, pub, opt, nil, opts...); err != nil {
+ t.Fatal(err)
+ }
+ })
+ return out.String()
+}
+
+// Spec §8 "Root pass": a manual instance on a fresh box installs the build
+// state's build, not the head.
+func TestAManualInstanceOnAFreshBoxInstallsItsLastBuild(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ for _, v := range []string{"v1", "v2"} {
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"},
+ BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}})
+ passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q, want v1 (the data's build), not the head", v)
+ }
+}
+
+// Spec §8: an existing instance with a replica but no build state is held as
+// unknown, never moved to the head.
+func TestAnUnknownBuildInstallsNothing(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}, BuildStateCopy{Known: true, Replica: true})
+ passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "" {
+ t.Fatalf("live %q for an unknown build", v)
+ }
+ if c, _ := ReadConsumed(opt, "a.example.com"); c == nil || c.Reason != string(ReasonUnknown) {
+ t.Fatalf("consumed = %+v; want reason unknown recorded", c)
+ }
+}
+
+// Spec §8 "Evidence": a pin of a pruned and reused version name, with a
+// replayed pointer, is refused for lack of a matching record.
+func TestAPinNeedsAMatchingRecord(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("promoted"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("nobody promoted these"))
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true})
+ out := passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" {
+ t.Fatal("the pin installed a reused name's new bytes")
+ }
+ if !strings.Contains(out, updatewords.Text("settings.evidence.no-record")) {
+ t.Fatalf("no refusal:\n%s", out)
+ }
+}
+
+// §4.1, invariant 7: a record whose digest the reshipped manifest does not
+// match is a refusal, never a fallback to unbound staging.
+func TestAPinWithAMismatchedRecordIsRefused(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ for _, v := range []string{"v1", "v2"} {
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reship(t, s, "bab", "km", "v1", []byte("not what was promoted")) // the record stays
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true})
+ out := passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "" {
+ t.Fatalf("live %q: the pin installed bytes its record does not bind", v)
+ }
+ if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil {
+ t.Fatal("the reshipped bytes were staged")
+ }
+ if !strings.Contains(out, "a.example.com") {
+ t.Fatalf("no refusal line:\n%s", out)
+ }
+}
+
+// Spec §8: a build promoted before the feature is recovered from a staged
+// copy, refused elsewhere.
+func TestAPreFeatureBuildIsRecoveredFromAStagedCopy(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // promoted "before the feature"
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"},
+ BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}})
+ passWithSettings(t, reg, s, pub, opt) // no staged copy: refused
+ if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" {
+ t.Fatal("recovered with neither a record nor a staged copy")
+ }
+ if _, err := ensureStaged(ctx, s, opt, "bab", "km", "v1", "seed"); err != nil { // the box already had it
+ t.Fatal(err)
+ }
+ // A staged copy whose bytes changed since it was verified recovers nothing.
+ path := VersionPath(opt, "bab", "v1", "km")
+ good, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("tampered"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" {
+ t.Fatal("recovered from a staged copy that no longer matches its manifest")
+ }
+ if err := os.WriteFile(path, good, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q, want v1 recovered from the staged copy", v)
+ }
+}
+
+// Spec §8 "Root pass": an install request is consumed exactly once.
+func TestAnInstallRequestResolvesOnceAndWaitsOnBake(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1},
+ BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v0"}})
+ passWithSettings(t, reg, s, pub, opt)
+ c, _ := ReadConsumed(opt, "a.example.com")
+ if c == nil || c.Resolved == nil || c.Resolved.ID != 1 || c.Resolved.Target != "v1" {
+ t.Fatalf("consumed = %+v; want {1, v1} written", c)
+ }
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q, want T", v)
+ }
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("v2"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ passWithSettings(t, reg, s, pub, opt)
+ if c2, _ := ReadConsumed(opt, "a.example.com"); c2 == nil || c2.Resolved == nil || c2.Resolved.Target != "v1" {
+ t.Fatalf("the request was resolved again: %+v", c2)
+ }
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q, want T (v1) kept, not the new head", v)
+ }
+}
+
+// Astra I1 #9: a record-authorized pin installs on a box that cannot read
+// the channel pointer.
+func TestAPinInstallsWithoutAChannelPointer(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Delete(ctx, ChannelKey("bab", "km", "canary/x")); err != nil { // the pointer is gone
+ t.Fatal(err)
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true})
+ passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q, want the pin v1 with no pointer", v)
+ }
+}
+
+// Astra I1 #8: a request waiting on bake is reported beside a major hold,
+// and never replaces it; once the window passes, root resolves it to the
+// head and installs it.
+func TestABakingRequestKeepsTheHeldReason(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ ratchetedProductionApp(t, s, ctx, priv, pub, opt, reg) // adopt_test.go: myapp's production head v2 inside its 24h window on this box
+ settingsRoute(t, reg, "held.example.com", "myapp", "production", UpdateSettings{Updates: UpdatesManual, Channel: "production", InstallRequest: 1},
+ BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1"}})
+ // A new held instance's first head bakes too (§4.3 rule 3), as rule 4
+ // does for p.example.com, the fixture's default route.
+ settingsRoute(t, reg, "new.example.com", "myapp", "production", UpdateSettings{Updates: UpdatesManual, Channel: "production"},
+ BuildStateCopy{Known: true})
+ passWithSettings(t, reg, s, pub, opt)
+ c, _ := ReadConsumed(opt, "held.example.com")
+ if c == nil || c.Reason != string(ReasonManual) || c.Baking != "v2" || c.Resolved != nil {
+ t.Fatalf("consumed = %+v; want reason manual, the baking head v2 beside it, nothing resolved", c)
+ }
+ if v, _ := LiveVersion(opt, "held.example.com"); v != "v1" {
+ t.Fatalf("live %q while baking, want the held build v1", v)
+ }
+ for _, h := range []string{"new.example.com", "p.example.com"} {
+ if v, _ := LiveVersion(opt, h); v == "v2" {
+ t.Fatalf("%s live on the baking head v2", h)
+ }
+ }
+
+ // The window passes on this box's own clock.
+ key := channelClockKey("bab", "myapp", "production")
+ st := LoadFirstSeen(FirstSeenPath(opt))
+ seen, ok := st.Channels[key]["v2"]
+ if !ok {
+ t.Fatal("no first sighting recorded for v2")
+ }
+ st.Channels[key]["v2"] = seen.Add(-31 * time.Hour)
+ if err := st.Save(); err != nil {
+ t.Fatal(err)
+ }
+ passWithSettings(t, reg, s, pub, opt)
+ c, _ = ReadConsumed(opt, "held.example.com")
+ if c == nil || c.Resolved == nil || c.Resolved.ID != 1 || c.Resolved.Target != "v2" || c.Baking != "" || c.Reason != string(ReasonRequest) {
+ t.Fatalf("consumed = %+v; want {1, v2} resolved, nothing baking, reason request", c)
+ }
+ if v, _ := LiveVersion(opt, "held.example.com"); v != "v2" {
+ t.Fatalf("live %q, want the resolved T v2", v)
+ }
+ if v, _ := LiveVersion(opt, "new.example.com"); v != "v2" {
+ t.Fatalf("new.example.com live %q, want the head v2 once eligible", v)
+ }
+}
+
+func TestDefaultSettingsAreTodaysAdoption(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, "a.example.com", "km", "canary/x", true)
+ // The same box twice: once without the option (today), once with it. The
+ // journal and the outcome must match line for line.
+ optToday := t.TempDir()
+ var today bytes.Buffer
+ withCapturedStdout(t, &today, func() {
+ if err := Pass(ctx, reg, s, pub, optToday, nil); err != nil {
+ t.Fatal(err)
+ }
+ })
+ out := passWithSettings(t, reg, s, pub, opt)
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q", v)
+ }
+ if c, _ := ReadConsumed(opt, "a.example.com"); c != nil {
+ t.Fatalf("a default route wrote a consumed file: %+v", c)
+ }
+ if strings.ReplaceAll(out, opt, "OPT") != strings.ReplaceAll(today.String(), optToday, "OPT") {
+ t.Fatalf("journal differs from today's:\nwith settings:\n%s\ntoday:\n%s", out, today.String())
+ }
+ // A failure book never reaches a default unmanaged route, readable or not.
+ reg2, opt2 := testRegistry(t), t.TempDir()
+ addRoute(t, reg2, "a.example.com", "km", "canary/x", true)
+ broken := func() (FailedBuild, error) { return nil, errors.New("swap-failures.json: unreadable") }
+ var buf bytes.Buffer
+ withCapturedStdout(t, &buf, func() {
+ if err := Pass(ctx, reg2, s, pub, opt2, nil, WithUpdateSettings(broken, func(registry.Route) bool { return false }, true)); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if v, _ := LiveVersion(opt2, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q: an unreadable book held a default unmanaged route", v)
+ }
+}
+
+// Astra I2 #8: the proof gate checks the build the decision selected, not
+// the channel head. Pinned to v1 with the head at v2: a proof for v2 alone
+// installs nothing, and a proof for v1 installs v1.
+func TestProofGateSeesTheSelectedBuild(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg, bake := t.TempDir(), testRegistry(t), t.TempDir()
+ for _, v := range []string{"v1", "v2"} {
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true})
+ prover, _, proverPub := proverKey(t) // proof_test.go
+ policy := filepath.Join(t.TempDir(), "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"km","prover":"dev","key":%q}]}`, proverPub)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gate := NewProofGate(policy, bake)
+ prove := func(v string) {
+ sha := etagOf([]byte(v))
+ p, err := SignProof(prover, Proof{App: "km", Platform: adoptPlatform, SHA256: sha, Version: v, Prover: "dev",
+ ActiveSince: time.Now().Add(-time.Hour), ProvenAt: time.Now()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := json.Marshal(p)
+ if _, err := s.Put(ctx, ProofKey("bab", "km", sha, "dev"), b, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ pass := func() {
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, nil, BakeDir(bake), WithProofGate(gate), WithUpdateSettings(nil, nil, true)); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ prove("v2") // the head is proven, the pinned build is not
+ pass()
+ if v, _ := LiveVersion(opt, "a.example.com"); v == "v1" {
+ t.Fatal("the pinned v1 went live on the head's proof")
+ }
+ prove("v1")
+ gate.clear("a.example.com") // drop the 30-second miss cache
+ pass()
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q, want the proven pin v1", v)
+ }
+}
+
+// §4.3 rule 2: only root resolves. An agent pass applies the decision, never
+// writes the consumed file, and installs nothing a resolution would decide.
+func TestAnAgentPassNeverResolves(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ settingsRoute(t, reg, "a.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1},
+ BuildStateCopy{Known: true, Replica: true})
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, nil, WithUpdateSettings(nil, nil, false)); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if c, err := ReadConsumed(opt, "a.example.com"); c != nil || err != nil {
+ t.Fatalf("an agent pass wrote the consumed file: %+v %v", c, err)
+ }
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "" {
+ t.Fatalf("live %q: an agent pass installed an unresolved request's head", v)
+ }
+ passWithSettings(t, reg, s, pub, opt) // root
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q after root's pass, want T v1", v)
+ }
+ // The agent now follows root's resolution from the consumed file.
+ if err := os.Remove(LiveLink(opt, "a.example.com")); err != nil {
+ t.Fatal(err)
+ }
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, nil, WithUpdateSettings(nil, nil, false)); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" {
+ t.Fatalf("live %q: the agent did not apply root's resolution", v)
+ }
+}
+
+// §4.3: the failure records outrank every rule, for settings routes. A
+// default route keeps today's adoption, which never read the book; an
+// unreadable book holds every settings route and every managed route.
+func TestTheFailureBookBindsSettingsRoutes(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ for _, v := range []string{"v1", "v2"} {
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ settingsRoute(t, reg, "pin.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true})
+ addRoute(t, reg, "plain.example.com", "km", "canary/x", true)
+ // cleared.example.com's settings were cleared after root recorded a held
+ // decision: rule 4, byte for byte, and the record follows it.
+ addRoute(t, reg, "cleared.example.com", "km", "canary/x", true)
+ if err := WriteConsumed(opt, Consumed{Host: "cleared.example.com", Desired: "v1", Reason: string(ReasonManual), Detail: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+ type call struct {
+ build, host string
+ served bool
+ }
+ var calls []call
+ book := func() (FailedBuild, error) {
+ return func(acct, app, build, host string, served bool) bool {
+ calls = append(calls, call{build, host, served})
+ return acct == "bab" && app == "km" && !served
+ }, nil
+ }
+ none := func(registry.Route) bool { return false }
+ passWithSettings(t, reg, s, pub, opt, WithUpdateSettings(book, none, true))
+ if v, _ := LiveVersion(opt, "pin.example.com"); v != "" {
+ t.Fatalf("live %q: a pin onto a failed build installed", v)
+ }
+ if c, _ := ReadConsumed(opt, "pin.example.com"); c == nil || c.Reason != string(ReasonFailed) || c.Detail != "v1" {
+ t.Fatalf("consumed = %+v; want reason failed naming v1", c)
+ }
+ for _, h := range []string{"plain.example.com", "cleared.example.com"} {
+ if v, _ := LiveVersion(opt, h); v != "v2" {
+ t.Fatalf("%s live %q: the book reached a default route", h, v)
+ }
+ }
+ if c, _ := ReadConsumed(opt, "cleared.example.com"); c == nil || c.Reason != string(ReasonAdopt) || c.Desired != "v2" {
+ t.Fatalf("cleared consumed = %+v; want root's record to follow rule 4", c)
+ }
+ for _, c := range calls {
+ if c.host != "pin.example.com" {
+ t.Fatalf("the book was asked about %s", c.host)
+ }
+ }
+
+ // served is v == GoodVersion.
+ calls = nil
+ if err := reg.SetGoodBuild("pin.example.com", registry.GoodBuild{}, registry.GoodBuild{Version: "v1", Digest: etagOf([]byte("v1"))}); err != nil {
+ t.Fatal(err)
+ }
+ passWithSettings(t, reg, s, pub, opt, WithUpdateSettings(book, none, true))
+ if v, _ := LiveVersion(opt, "pin.example.com"); v != "v1" {
+ t.Fatalf("live %q: a build the instance served is not blocked", v)
+ }
+ if len(calls) == 0 || !calls[0].served {
+ t.Fatalf("calls = %+v; want served for the GoodVersion build", calls)
+ }
+
+ // An unreadable book: nothing for a settings route or a managed default
+ // route, today's adoption for an unmanaged default one; no consumed write.
+ opt2, reg2 := t.TempDir(), testRegistry(t)
+ settingsRoute(t, reg2, "pin.example.com", "km", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true})
+ addRoute(t, reg2, "managed.example.com", "km", "canary/x", true)
+ addRoute(t, reg2, "plain.example.com", "km", "canary/x", true)
+ broken := func() (FailedBuild, error) { return nil, errors.New("swap-failures.json: unreadable") }
+ managed := func(rt registry.Route) bool { return rt.Host == "managed.example.com" }
+ out := passWithSettings(t, reg2, s, pub, opt2, WithUpdateSettings(broken, managed, true))
+ for _, h := range []string{"pin.example.com", "managed.example.com"} {
+ if v, _ := LiveVersion(opt2, h); v != "" {
+ t.Fatalf("%s live %q with an unreadable failure book", h, v)
+ }
+ if c, _ := ReadConsumed(opt2, h); c != nil {
+ t.Fatalf("%s: an unreadable book wrote a decision: %+v", h, c)
+ }
+ }
+ if v, _ := LiveVersion(opt2, "plain.example.com"); v != "v2" {
+ t.Fatalf("plain live %q, want today's adoption", v)
+ }
+ if !strings.Contains(out, "failure book unreadable") {
+ t.Fatalf("no line for the unreadable book:\n%s", out)
+ }
+}
+
+// R-MERGE: the consumed-file update is one pure function, the one root runs
+// and the simulation runs.
+func TestNextConsumed(t *testing.T) {
+ head := &ChannelPointer{Channel: "stable", Version: "v9"}
+ known := func(st *BuildState) BuildStateCopy { return BuildStateCopy{Known: true, Replica: true, State: st} }
+ manual := UpdateSettings{Updates: UpdatesManual}
+ req := func(id uint64) UpdateSettings { return UpdateSettings{Updates: UpdatesManual, InstallRequest: id} }
+ at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ cases := []struct {
+ name string
+ in TargetInput
+ dec TargetDecision
+ want Consumed
+ }{
+ {
+ name: "fresh: the decision only",
+ in: TargetInput{Pointer: head, Settings: manual, Build: known(&BuildState{LastExecuted: "v1"})},
+ dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"},
+ want: Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1"},
+ },
+ {
+ name: "a resolution is recorded",
+ in: TargetInput{Pointer: head, Settings: req(3), Build: known(nil)},
+ dec: TargetDecision{Version: "v9", Reason: ReasonRequest, Resolve: &ResolvedRequest{ID: 3, Target: "v9"}, Detail: "v9"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v9"}, Desired: "v9", Reason: "request", Detail: "v9"},
+ },
+ {
+ name: "a failed T completes above the old watermark",
+ in: TargetInput{Pointer: head, Settings: req(3), Build: known(nil),
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 2}},
+ dec: TargetDecision{Reason: ReasonManual, Fail: 3},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 3, CompletedFailed: true, Reason: "manual"},
+ },
+ {
+ name: "a completed T that failed later is marked once, not reopened",
+ in: TargetInput{Pointer: head, Settings: req(3), Build: known(nil),
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 3}},
+ dec: TargetDecision{Reason: ReasonFailed, Fail: 3, Detail: "v8"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v8"}, Completed: 3, CompletedFailed: true, Reason: "failed", Detail: "v8"},
+ },
+ {
+ name: "an older failure never lowers the watermark",
+ in: TargetInput{Pointer: head, Settings: req(5), Build: known(nil),
+ Consumed: &Consumed{Host: "a.example.com", Completed: 5}},
+ dec: TargetDecision{Reason: ReasonManual, Fail: 4},
+ want: Consumed{Host: "a.example.com", Completed: 5, Reason: "manual"},
+ },
+ {
+ name: "R-TARGET-FAILEDHEAD: resolved and completed failed in one write",
+ in: TargetInput{Pointer: head, Settings: req(4), Build: known(&BuildState{LastExecuted: "v1"})},
+ dec: TargetDecision{Version: "v1", Reason: ReasonManual, Resolve: &ResolvedRequest{ID: 4, Target: "v9"}, Fail: 4, Detail: "v1"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 4, Target: "v9"}, Completed: 4, CompletedFailed: true, Desired: "v1", Reason: "manual", Detail: "v1"},
+ },
+ {
+ name: "baking is reported beside the held reason",
+ in: TargetInput{Pointer: head, HeadHeld: "in its supply-chain bake window", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1"})},
+ dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"},
+ want: Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1", Baking: "v9"},
+ },
+ {
+ name: "baking for a new request beside an older, completed resolution",
+ in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1", Completed: 1}),
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 1, Target: "v1"}, Completed: 1}},
+ dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 1, Target: "v1"}, Completed: 1, Desired: "v1", Reason: "manual", Detail: "v1", Baking: "v9"},
+ },
+ {
+ name: "no baking while the build state is unread",
+ in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: BuildStateCopy{}},
+ dec: TargetDecision{Reason: ReasonUnknown},
+ want: Consumed{Host: "a.example.com", Reason: "unknown"},
+ },
+ {
+ name: "no baking for a fast-tracked request",
+ in: TargetInput{Pointer: head, HeadHeld: "held", Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2, InstallFastTrack: true}, Build: known(nil)},
+ dec: TargetDecision{Version: "v9", Reason: ReasonRequest, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Detail: "v9"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Desired: "v9", Reason: "request", Detail: "v9"},
+ },
+ {
+ name: "no baking once R has a T",
+ in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: known(&BuildState{Resolved: &ResolvedRequest{ID: 2, Target: "v8"}}),
+ Consumed: &Consumed{Host: "a.example.com", Baking: "v8"}},
+ dec: TargetDecision{Version: "v8", Reason: ReasonRequest, Evidence: EvidenceRecord, Detail: "v8"},
+ want: Consumed{Host: "a.example.com", Desired: "v8", Reason: "request", Detail: "v8"},
+ },
+ {
+ name: "no baking for a completed request; a stale At is dropped",
+ in: TargetInput{Pointer: head, HeadHeld: "held", Settings: req(2), Build: known(&BuildState{Completed: 2}),
+ Consumed: &Consumed{Host: "a.example.com", Baking: "v9", Desired: "v1", Reason: "manual", At: at}},
+ dec: TargetDecision{Version: "v9", Reason: ReasonAdopt, Bake: true, Detail: "v9"},
+ want: Consumed{Host: "a.example.com", Desired: "v9", Reason: "adopt", Detail: "v9"},
+ },
+ {
+ name: "no baking with no readable head",
+ in: TargetInput{HeadHeld: "", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1"})},
+ dec: TargetDecision{Version: "v1", Reason: ReasonManual, Detail: "v1"},
+ want: Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1"},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tc.in.Route = registry.Route{Host: "a.example.com"}
+ var before *Consumed
+ if tc.in.Consumed != nil {
+ cp := *tc.in.Consumed
+ before = &cp
+ }
+ got := NextConsumed(tc.in, tc.dec)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("NextConsumed =\n %+v\nwant\n %+v", got, tc.want)
+ }
+ if before != nil && !reflect.DeepEqual(*tc.in.Consumed, *before) {
+ t.Fatalf("NextConsumed changed its input: %+v", *tc.in.Consumed)
+ }
+ if got.Resolved != nil && tc.in.Consumed != nil && got.Resolved == tc.in.Consumed.Resolved {
+ t.Fatal("NextConsumed shares the input's Resolved")
+ }
+ })
+ }
+}
diff --git a/internal/release/updatetarget.go b/internal/release/updatetarget.go
index 8bf3d2ebb..e02d33a24 100644
--- a/internal/release/updatetarget.go
+++ b/internal/release/updatetarget.go
@@ -1,6 +1,10 @@
package release
-import "github.com/carlosframework/platform/internal/registry"
+import (
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
// TargetReason is why Target chose what it did; plan 5 renders each.
type TargetReason string
@@ -200,3 +204,45 @@ func tagInMajor(tag string, major int) bool {
v, ok := ParseSemVer(tag)
return ok && v.Major == major
}
+
+// NextConsumed is root's consumed-file update for one decision (settings
+// spec §4.3 rule 2; ruling R-MERGE): in is what Target decided on, and
+// in.Consumed the file as read. root's pass writes the result before it
+// stages anything; the simulation calls the same function.
+//
+// - dec.Fail completes that request as failed: above the file's watermark
+// it becomes the new watermark; equal to it (T failed after it first
+// ran, Astra I1 #10) the outcome changes and the request stays closed;
+// below it, nothing.
+// - dec.Resolve records {R, T}. With R-TARGET-FAILEDHEAD a decision can
+// resolve R and complete it failed in the same write.
+// - Baking names the head a pending, unresolved request waits on, beside
+// the decision's reason, never in its place (Astra I1 #8): Reason is what
+// the held wake check reads. Only with the build state read
+// (in.Build.Known), as Target does no request bookkeeping without it.
+// - Desired, Reason and Detail are the decision; At is the writer's.
+func NextConsumed(in TargetInput, dec TargetDecision) Consumed {
+ next := Consumed{Host: in.Route.Host}
+ if in.Consumed != nil {
+ next = *in.Consumed
+ next.Host = in.Route.Host
+ next.Resolved = copyResolved(in.Consumed.Resolved)
+ }
+ switch {
+ case dec.Fail > next.Completed:
+ next.Completed, next.CompletedFailed = dec.Fail, true
+ case dec.Fail != 0 && dec.Fail == next.Completed:
+ next.CompletedFailed = true
+ }
+ if dec.Resolve != nil {
+ next.Resolved = copyResolved(dec.Resolve)
+ }
+ u, state := in.Settings, in.Build.State
+ next.Baking = ""
+ if in.Build.Known && in.Pointer != nil && in.HeadHeld != "" && !u.InstallFastTrack && dec.Resolve == nil &&
+ u.InstallRequest > Watermark(&next, state) && resolvedFor(&next, state, u.InstallRequest) == nil {
+ next.Baking = in.Pointer.Version
+ }
+ next.Desired, next.Reason, next.Detail, next.At = dec.Version, string(dec.Reason), dec.Detail, time.Time{}
+ return next
+}