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>
5 files changed,
+1024
−22
internal/host/agent.go+12 −3internal/host/updatesroot.go+33 −0internal/release/adopt.go+282 −18internal/release/adoptsettings_test.go+650 −0internal/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 |
| +} |