host: the agent shares one proof gate, keeps a waiting route in the fast loop, and asks root to mint
One release.ProofGate per agent process (agentProofGate, bake dir dataDir()) rides adoptLoop, fastConvergeLoop and the cold-wake hook. fastMembers sweeps a listed route whose proof wait is due; a wait the sweep left standing backs off like an unstaged route (ruling 5). The mint asker asks for a root pass every 2 minutes, through its own requester, until the live build's proof is in the bucket, and writes the pending note. A policy that does not parse asks nothing (root mints nothing until it parses).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+454
−13
internal/host/agent.go+78 −13internal/host/proofask.go+139 −0internal/host/proofask_test.go+237 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index d938b6289..c4719edd7 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -787,8 +787,10 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st |
| optDir := release.OptDir() |
| stageReq := stageRequester(dd) |
| return func(ctx context.Context, host string) error { |
| + // The process's one proof gate (proofask.go), so a cold wake cannot |
| + // adopt an unproven build of a listed app. |
| return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host, |
| - release.OnStageBlocked(stageReq)) |
| + release.OnStageBlocked(stageReq), release.WithProofGate(agentProofGate())) |
| } |
| } |
| @@ -869,7 +871,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps) |
| release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())), |
| release.RestartHibernating(restart), |
| release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)), |
| - release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))); err != nil { |
| + release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv)), |
| + release.WithProofGate(agentProofGate())); err != nil { |
| log.Printf("agent: adopt pass: %v", err) |
| } |
| // The schedule converge's hourly floor (schedule spec §6.2), AFTER the |
| @@ -1223,14 +1226,33 @@ const stageRequestEvery = 15 * time.Second |
| // cannot be written costs latency, never correctness, because |
| // carlos-adopt.timer remains the floor underneath it. |
| func stageRequester(dd string) func(host, ver string, err error) { |
| + return markerRequester(dd, func(host, ver string, err error) { |
| + if err != nil { |
| + log.Printf("agent: cannot request root stage for %s %s: %v", host, ver, err) |
| + return |
| + } |
| + log.Printf("agent: %s needs %s staged and this process cannot write the version store — asked for a root pass", host, ver) |
| + }) |
| +} |
| + |
| +// mintRequester is the mint asker's (proofask.go) request: the same marker |
| +// and the same throttle shape as stageRequester's, with its own budget and |
| +// its own words. The asker logs why it asks; this logs only a failed write. |
| +func mintRequester(dd string) func(host, ver string, err error) { |
| + return markerRequester(dd, func(host, ver string, err error) { |
| + if err != nil { |
| + log.Printf("agent: cannot request a root pass to mint a proof for %s %s: %v", host, ver, err) |
| + } |
| + }) |
| +} |
| + |
| +// markerRequester touches the stage-request marker through a fresh |
| +// stageThrottle and hands said each write's outcome. |
| +func markerRequester(dd string, said func(host, ver string, err error)) func(host, ver string, err error) { |
| t := &stageThrottle{every: stageRequestEvery, now: time.Now, after: func(d time.Duration, f func()) { time.AfterFunc(d, f) }, |
| write: func(host, ver string) { |
| - if err := os.WriteFile(filepath.Join(dd, stageRequestMarker), |
| - []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil { |
| - log.Printf("agent: cannot request root stage for %s %s: %v", host, ver, err) |
| - return |
| - } |
| - log.Printf("agent: %s needs %s staged and this process cannot write the version store — asked for a root pass", host, ver) |
| + said(host, ver, os.WriteFile(filepath.Join(dd, stageRequestMarker), |
| + []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644)) |
| }} |
| return func(host, ver string, _ error) { t.request(host, ver) } |
| } |
| @@ -1430,6 +1452,8 @@ type fastDeps struct { |
| wakeLeg func(ctx context.Context) // the sidecar wake check (design: sidecar members), or a test hook |
| scheduleLeg func(ctx context.Context) // the schedule converge (spec §6.2), or a test hook; nil = not armed |
| systemLeg *systemLeg // the platform self-update leg (spec §4); nil = not armed |
| + proof *release.ProofGate // D3's gate (agentProofGate); nil = no proof waits to keep |
| + mintLeg func(ctx context.Context) // D3's mint asker (proofask.go); nil = not armed |
| lastSerial int64 |
| seeded bool |
| lastFull time.Time |
| @@ -1515,6 +1539,9 @@ func fastPassOpts(dd string, stageReq func(host, ver string, err error), restart |
| // - unstaged routes (no live symlink), once their backoff window has |
| // passed: a route was just minted, or its stage failed; the sweep |
| // picks it up as soon as there is something to stage. |
| +// - routes waiting for a dev proof (D3), each time the proof gate's |
| +// cached miss runs out: they have a live version, but their head is |
| +// held until its proof arrives. |
| // |
| // Computed BEFORE any store work so an empty set costs zero store reads — |
| // the idle box pays one serial GET per tick and nothing else. A registry |
| @@ -1553,6 +1580,20 @@ func (d *fastDeps) fastMembers(now time.Time) []string { |
| members = append(members, rt.Host) |
| continue |
| } |
| + // D3 (spec §4.5, codex 14): a listed route waiting for a dev proof |
| + // stays in the sweep although it has a live version, whenever its |
| + // 30 s miss has run out, so it adopts soon after the proof arrives |
| + // rather than at the next serial bump or the hourly floor. A wait the |
| + // last sweep left standing (fastTick) is inside a backoff window, and |
| + // sits it out like an unstaged route. |
| + if d.proof.WaitingDue(rt.Host) { |
| + if nb, ok := d.notBefore[rt.Host]; ok && now.Before(nb) { |
| + continue |
| + } |
| + seen[rt.Host] = true |
| + members = append(members, rt.Host) |
| + continue |
| + } |
| // A LiveVersion error reads as unstaged: the pass is the thing that |
| // can repair a broken link, and the backoff bounds the retry cost. |
| if v, err := release.LiveVersion(d.optDir, rt.Host); err == nil && v != "" { |
| @@ -1801,8 +1842,9 @@ func logSlow(what string, start time.Time, detail string) { |
| // move, last, for both of the same reasons — see the call site. |
| // 2. Fast sweep: fastMembers, and only when non-empty one PassFilter over |
| // exactly those hosts — an empty sweep costs zero store reads. After |
| -// the sweep, members still unstaged back off; staged members clear |
| -// their backoff state. |
| +// the sweep, members still unstaged back off, and so do members whose |
| +// proof wait the sweep left standing; other staged members clear their |
| +// backoff state. |
| func fastTick(ctx context.Context, d *fastDeps, now time.Time) { |
| doc, _, err := release.ReadSerial(ctx, d.store) |
| switch { |
| @@ -1864,6 +1906,13 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) { |
| d.systemLeg.tick(ctx, now) |
| } |
| + // D3's mint asker: on a proving box, ask for a root pass every 2 minutes |
| + // while a listed build has baked with no proof. Elsewhere it reads one |
| + // absent file and returns. |
| + if d.mintLeg != nil { |
| + d.mintLeg(ctx) |
| + } |
| + |
| members := d.fastMembers(now) |
| if len(members) == 0 { |
| return // zero store reads: no PassFilter, no bake-clock write |
| @@ -1878,11 +1927,19 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) { |
| log.Printf("agent: fast converge sweep: %v", err) |
| } |
| for _, h := range members { |
| - if v, err := release.LiveVersion(d.optDir, h); err == nil && v != "" { |
| + switch v, err := release.LiveVersion(d.optDir, h); { |
| + case err != nil || v == "": |
| + d.noteUnstaged(h, now) |
| + case d.proof.WaitingDue(h): |
| + // The sweep neither renewed this host's proof wait (which would |
| + // cache a fresh 30 s miss) nor cleared it: adoptHost returned |
| + // before the gate, say on a channel with no pointer. Nothing will |
| + // change that by the next tick, so it backs off like an unstaged |
| + // route instead of being swept every 2 s (plan 3 ruling 5). |
| + d.noteUnstaged(h, now) |
| + default: |
| delete(d.notBefore, h) |
| delete(d.step, h) |
| - } else { |
| - d.noteUnstaged(h, now) |
| } |
| } |
| } |
| @@ -1936,6 +1993,14 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // The rollback's restart (D1): checked like the forward one, and a replace |
| // rather than a start, so a rejected build left up is cycled out. |
| opts = append(opts, release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))) |
| + // D3: the process's one proof gate rides every pass this loop runs, and |
| + // fastMembers reads its waiting list. The mint asker has its own |
| + // requester, so its 2-minute asks never spend the stage budget and its |
| + // journal line does not claim a stage. |
| + opts = append(opts, release.WithProofGate(agentProofGate())) |
| + d.proof = agentProofGate() |
| + d.mintLeg = (&mintAsker{reg: reg, store: store, sys: systemdUnits{}, optDir: optDir, |
| + policyPath: release.ProofPolicyPath, request: mintRequester(dd), now: time.Now}).tick |
| // The restart check's memory (issue #113, narrowed requests added by |
| // discussion #16), seeded ONCE here — two reads per app at arming (the |
| // app-wide generation and the narrowed request log) and never again except |
diff --git a/internal/host/proofask.go b/internal/host/proofask.go| new file mode 100644 |
| index 000000000..0c2648aca |
| --- /dev/null |
| +++ b/internal/host/proofask.go |
| @@ -0,0 +1,139 @@ |
| +// The agent's half of D3 (spec 2026-10-06-console-release-defences-design.md |
| +// §4.3, §4.5). |
| +// |
| +// agentProofGate is the one release.ProofGate this process passes to every |
| +// pass it runs (adoptLoop, fastConvergeLoop, the cold-wake hook), so the miss |
| +// cache, the waiting list fastMembers reads and the status line all agree. |
| +// |
| +// mintAsker runs on the proving box's fast loop. Minting needs the root-only |
| +// key, and root passes run hourly or on request, so when a listed app's live |
| +// build has run for the policy's after with no proof in the bucket, the asker |
| +// requests a root pass through the stage-request relay. It asks again every |
| +// mintAskEvery until it sees the proof (codex 5): the relay can miss a request |
| +// written while carlos-stage.service is running, and a mint write can fail. |
| +// It also leaves a pending note the first time it sees a build live, which is |
| +// how `carlos proof get` can say how long the build has had. |
| +// |
| +// It cannot check everything a mint needs (it cannot read another unit's |
| +// /proc/<pid>/exe), and need not: the root pass checks every condition again. |
| +// When systemctl show is denied here, it asks anyway and root decides. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + proofGateOnce sync.Once |
| + proofGateInst *release.ProofGate |
| +) |
| + |
| +// agentProofGate's bake directory is dataDir(), the same directory every |
| +// agent pass hands release.BakeDir, so the gate's healthy-history reads and |
| +// not-proven refusal records land where the pass's own refusal gate looks. |
| +func agentProofGate() *release.ProofGate { |
| + proofGateOnce.Do(func() { proofGateInst = release.NewProofGate(release.ProofPolicyPath, dataDir()) }) |
| + return proofGateInst |
| +} |
| + |
| +const mintAskEvery = 2 * time.Minute |
| + |
| +type mintAsker struct { |
| + reg *registry.Registry |
| + store release.Store |
| + sys unitSystem |
| + optDir string |
| + policyPath string |
| + request func(host, ver string, err error) // mintRequester in production |
| + now func() time.Time |
| + |
| + last map[string]time.Time // host -> when it was last looked at |
| + proven map[string]string // host -> sha256 seen proven |
| + pending map[string]string // host -> sha256 whose pending note is written |
| + shaByPath map[string]string // staged file -> sha256 (version dirs are immutable) |
| +} |
| + |
| +func (a *mintAsker) tick(ctx context.Context) { |
| + // A policy file that does not parse is closed (Task 2's carry): root |
| + // mints nothing until it parses (proofroot.go says so on every pass, and |
| + // the gate once a minute), so there is nothing to ask root for. An |
| + // absent file, or one with no prove block, is a box that proves nothing. |
| + pol, err := release.LoadProofPolicy(a.policyPath) |
| + if err != nil || pol == nil || pol.Prove == nil { |
| + return |
| + } |
| + routes, err := a.reg.List() |
| + if err != nil { |
| + return |
| + } |
| + if a.last == nil { |
| + a.last, a.proven, a.pending, a.shaByPath = map[string]time.Time{}, map[string]string{}, map[string]string{}, map[string]string{} |
| + } |
| + now := a.now() |
| + after := pol.ProveAfter() |
| + prover := pol.Prove.Prover |
| + for _, rt := range routes { |
| + if !release.Adoptable(rt) || rt.Unit == "" || !pol.Proves(rt.App) { |
| + continue |
| + } |
| + if last, ok := a.last[rt.Host]; ok && now.Sub(last) < mintAskEvery { |
| + continue |
| + } |
| + a.last[rt.Host] = now |
| + target, err := filepath.EvalSymlinks(release.LiveLink(a.optDir, rt.Host)) |
| + if err != nil { |
| + continue |
| + } |
| + sha, ok := a.shaByPath[target] |
| + if !ok { |
| + if sha, err = release.FileSHA256(target); err != nil { |
| + continue |
| + } |
| + a.shaByPath[target] = sha |
| + } |
| + if a.proven[rt.Host] == sha { |
| + continue |
| + } |
| + ver, _ := release.LiveVersion(a.optDir, rt.Host) |
| + acct := release.RouteAccount(rt) |
| + if p, err := a.sys.Show(rt.Unit); err == nil { |
| + if p.ActiveState != "active" { |
| + continue |
| + } |
| + if !p.ActiveEnterTimestamp.IsZero() { |
| + if a.pending[rt.Host] != sha { |
| + if err := release.PutProofPending(ctx, a.store, acct, release.ProofPending{App: rt.App, |
| + Platform: release.AdoptPlatform(), SHA256: sha, Version: ver, Prover: prover, |
| + LiveSince: p.ActiveEnterTimestamp.UTC(), After: pol.Prove.After}); err != nil { |
| + log.Printf("agent: %s: writing the proof pending note: %v", rt.Host, err) |
| + } else { |
| + a.pending[rt.Host] = sha |
| + } |
| + } |
| + if now.Sub(p.ActiveEnterTimestamp) < after { |
| + continue |
| + } |
| + } |
| + } |
| + _, err = release.GetProof(ctx, a.store, acct, rt.App, sha, prover) |
| + switch { |
| + case err == nil: |
| + a.proven[rt.Host] = sha |
| + continue |
| + case !errors.Is(err, os.ErrNotExist): |
| + log.Printf("agent: %s: reading the bucket for %s's proof: %v", rt.Host, ver, err) |
| + continue |
| + } |
| + log.Printf("agent: %s has run %s for %s with no proof in the bucket; asking for a root pass to mint one", rt.Host, ver, after) |
| + a.request(rt.Host, ver, nil) |
| + } |
| +} |
diff --git a/internal/host/proofask_test.go b/internal/host/proofask_test.go| new file mode 100644 |
| index 000000000..d1fa0488b |
| --- /dev/null |
| +++ b/internal/host/proofask_test.go |
| @@ -0,0 +1,237 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func mintAskerFixture(t *testing.T, activeFor time.Duration) (*mintAsker, *int, *time.Time, release.Store, string) { |
| + t.Helper() |
| + opt, etc := t.TempDir(), t.TempDir() |
| + liveTo(t, opt, observedHost, "v1") |
| + reg := restartTestRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: observedHost, Kind: "instance", App: "console", |
| + Channel: "canary/console", Unit: "console.service", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + policy := filepath.Join(etc, "proof-policy.json") |
| + if err := os.WriteFile(policy, []byte(provePolicy("/etc/carlos/proving.key")), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t0 := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| + clock := t0 |
| + asked := 0 |
| + store := release.NewDirStore(t.TempDir()) |
| + a := &mintAsker{reg: reg, store: store, optDir: opt, policyPath: policy, |
| + sys: &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7, |
| + ActiveEnterTimestamp: t0.Add(-activeFor)}}}, |
| + request: func(string, string, error) { asked++ }, |
| + now: func() time.Time { return clock }} |
| + sha, err := release.FileSHA256(filepath.Join(opt, "versions", "v1", "console")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return a, &asked, &clock, store, sha |
| +} |
| + |
| +// Review focus 5: the relay can drop a request and a mint write can fail, so |
| +// the agent asks every 2 minutes until it sees the proof, then stops. |
| +func TestMintAskerReasksUntilTheProofAppears(t *testing.T) { |
| + a, asked, clock, store, sha := mintAskerFixture(t, 20*time.Minute) |
| + ctx := context.Background() |
| + t0 := *clock |
| + at := func(d time.Duration) { *clock = t0.Add(d); a.tick(ctx) } |
| + at(0) |
| + if *asked != 1 { |
| + t.Fatalf("asked %d times at first sight, want 1", *asked) |
| + } |
| + at(time.Minute) |
| + if *asked != 1 { |
| + t.Fatalf("asked %d times inside 2 minutes, want 1", *asked) |
| + } |
| + at(2 * time.Minute) |
| + if *asked != 2 { |
| + t.Fatalf("asked %d times after 2 minutes, want 2", *asked) |
| + } |
| + seed, _, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := release.ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := release.SignProof(key, release.Proof{App: "console", Platform: release.AdoptPlatform(), SHA256: sha, |
| + Version: "v1", Prover: "dev", ActiveSince: t0.Add(-20 * time.Minute), ProvenAt: t0}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PutProof(ctx, store, "bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + at(4 * time.Minute) |
| + at(6 * time.Minute) |
| + if *asked != 2 { |
| + t.Fatalf("asked %d times after the proof appeared, want 2", *asked) |
| + } |
| + pend, err := release.GetProofPending(ctx, store, "bab", "console", sha, "dev") |
| + if err != nil || !pend.LiveSince.Equal(t0.Add(-20*time.Minute)) { |
| + t.Fatalf("pending = %+v, %v", pend, err) |
| + } |
| +} |
| + |
| +func TestMintAskerWaitsOutTheBake(t *testing.T) { |
| + a, asked, _, store, sha := mintAskerFixture(t, 5*time.Minute) |
| + a.tick(context.Background()) |
| + if *asked != 0 { |
| + t.Fatalf("asked %d times five minutes into a fifteen-minute bake", *asked) |
| + } |
| + if _, err := release.GetProofPending(context.Background(), store, "bab", "console", sha, "dev"); err != nil { |
| + t.Fatalf("no pending note for a baking build: %v", err) |
| + } |
| +} |
| + |
| +// Task 2's carry: a policy file that does not parse is closed. Root mints |
| +// nothing until it parses, so the asker has nothing to ask for and stays |
| +// quiet rather than reading the broken file as "no prove block" and guessing. |
| +func TestMintAskerAsksNothingOnABrokenPolicy(t *testing.T) { |
| + a, asked, _, store, sha := mintAskerFixture(t, 20*time.Minute) |
| + if err := os.WriteFile(a.policyPath, []byte(`{"schema":1,"prove":`), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.tick(context.Background()) |
| + if *asked != 0 { |
| + t.Fatalf("asked %d times with a broken policy, want 0", *asked) |
| + } |
| + if _, err := release.GetProofPending(context.Background(), store, "bab", "console", sha, "dev"); err == nil { |
| + t.Fatal("pending note written under a broken policy") |
| + } |
| +} |
| + |
| +// Spec §4.5, codex 14: a non-canary listed route with a live version stays in |
| +// the fast set while it waits (at the gate's 30 s cadence), and leaves once |
| +// it adopts. |
| +func TestFastMembersKeepsAWaitingRoute(t *testing.T) { |
| + f := newFastFixture(t) |
| + ctx := context.Background() |
| + host := "edge.example.com" |
| + f.addRoute(t, host, "edge") |
| + f.stageLive(t, host, "v1") |
| + plat := release.AdoptPlatform() |
| + for ver, body := range map[string]string{"v1": "staged build", "v2": "build two"} { |
| + m := release.Manifest{App: "fastapp", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| + if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte(body)}); err != nil { |
| + t.Fatalf("Ship %s: %v", ver, err) |
| + } |
| + } |
| + if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + prover, err := release.ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policy := filepath.Join(t.TempDir(), "proof-policy.json") |
| + if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + clock := time.Now() |
| + gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock }) |
| + d := f.deps(nil) |
| + d.passOpts = append(d.passOpts, release.WithProofGate(gate)) |
| + d.proof = gate |
| + |
| + if got := d.fastMembers(clock); len(got) != 0 { |
| + t.Fatalf("members = %v before anything waits", got) |
| + } |
| + // The serial-triggered full pass sees v2 and finds no proof. |
| + if err := release.Pass(ctx, f.reg, f.store, f.pub, f.optDir, d.restart, d.passOpts...); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := release.LiveVersion(f.optDir, host); v != "v1" { |
| + t.Fatalf("live = %q, want v1 while unproven", v) |
| + } |
| + clock = clock.Add(31 * time.Second) |
| + if got := d.fastMembers(clock); len(got) != 1 || got[0] != host { |
| + t.Fatalf("members = %v, want the waiting non-canary route", got) |
| + } |
| + sum := sha256.Sum256([]byte("build two")) |
| + p, err := release.SignProof(prover, release.Proof{App: "fastapp", Platform: plat, SHA256: hex.EncodeToString(sum[:]), |
| + Version: "v2", Prover: "dev", ActiveSince: clock.Add(-time.Hour).UTC().Truncate(time.Second), |
| + ProvenAt: clock.UTC().Truncate(time.Second)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PutProof(ctx, f.store, "bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + fastTick(ctx, d, clock) |
| + if v, _ := release.LiveVersion(f.optDir, host); v != "v2" { |
| + t.Fatalf("live = %q, want v2 once proven", v) |
| + } |
| + clock = clock.Add(31 * time.Second) |
| + if got := d.fastMembers(clock); len(got) != 0 { |
| + t.Fatalf("members = %v after adopting, want none", got) |
| + } |
| +} |
| + |
| +// Ruling 5's purpose, seen from the fast loop: a wait the sweep neither |
| +// renews nor clears (here the route moved to a channel with no pointer, so |
| +// adoptHost returns before the gate) must not keep its host in the sweep |
| +// every tick. It backs off like an unstaged route. |
| +func TestFastMembersBacksOffAWaitTheSweepLeftStanding(t *testing.T) { |
| + f := newFastFixture(t) |
| + ctx := context.Background() |
| + host := "edge.example.com" |
| + f.addRoute(t, host, "edge") |
| + f.stageLive(t, host, "v1") |
| + plat := release.AdoptPlatform() |
| + m := release.Manifest{App: "fastapp", Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| + if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte("build two")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policy := filepath.Join(t.TempDir(), "proof-policy.json") |
| + if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + clock := time.Now() |
| + gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock }) |
| + d := f.deps(nil) |
| + d.passOpts = append(d.passOpts, release.WithProofGate(gate)) |
| + d.proof = gate |
| + if err := release.Pass(ctx, f.reg, f.store, f.pub, f.optDir, d.restart, d.passOpts...); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.addRoute(t, host, "nowhere") // no pointer: the pass never reaches the gate |
| + clock = clock.Add(31 * time.Second) |
| + if got := d.fastMembers(clock); len(got) != 1 { |
| + t.Fatalf("members = %v, want the waiting route", got) |
| + } |
| + fastTick(ctx, d, clock) |
| + if got := d.fastMembers(clock.Add(time.Second)); len(got) != 0 { |
| + t.Fatalf("members = %v a second after a sweep that left the wait standing, want none", got) |
| + } |
| + if d.backoffFor(host) == 0 { |
| + t.Fatal("no backoff recorded for the standing wait") |
| + } |
| +} |