retries: the box reads grants from the host-retry log and a legacy signed grant from the restart-scope log; neither is seed-skipped at agent restart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+297
−40
internal/host/restart.go+22 −27internal/host/restartscope.go+116 −5internal/host/restartscope_test.go+155 −5internal/release/hostretry.go+4 −3
diff --git a/internal/host/restart.go b/internal/host/restart.go| index 68b641d05..692e1e1a4 100644 |
| --- a/internal/host/restart.go |
| +++ b/internal/host/restart.go |
| @@ -844,37 +844,20 @@ func restartRequestLeg(ctx context.Context, routes []registry.Route, store relea |
| // other for the hourly floor. |
| scoped, scopeErrs := restartScopePass(ctx, routes, store, mem.scope) |
| readErrs = append(readErrs, scopeErrs...) |
| + // `carlos instances retry` grants ride their own log (release.HostRetryKey), |
| + // read on the same move. |
| + grants, grantErrs := hostRetryPass(ctx, routes, store, mem) |
| + readErrs = append(readErrs, grantErrs...) |
| if len(readErrs) > 0 { |
| // ONE line per pass, never one per app: the count carries the scale, |
| // the first error the cause — they are the same cause in practice. |
| log.Printf("agent: restart check: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| } |
| - // A retry clears a hold only on the console's root-signed grant for this |
| - // entry (spec §11, Astra impl-6 finding 5): every box can write the log, |
| - // so an unsigned or foreign entry is dropped here, before anything is |
| - // cleared. The grant's epoch is checked against the book where the |
| - // clear is (rollout.Manager.RetryHost, or failureHolds.retryHost on a |
| - // box that declined the flag but honours its book). |
| - for _, rq := range retryRequests(routes, scoped) { |
| - if mem.retryHost == nil { |
| - log.Printf("agent: retry requested for %s, dropped: rollout manager not armed on this box, so nothing is held to clear", rq.host) |
| - continue |
| - } |
| - g, why := verifyRetry(mem.pub, rq, time.Now()) |
| - if why != "" { |
| - log.Printf("agent: retry requested for %s, dropped: %s", rq.host, why) |
| - continue |
| - } |
| - if mem.retryHost(rq.host, g.Build, g.Book, g.Epoch) { |
| - next := "its next attempt runs as a canary" |
| - if armedRollout() == nil { |
| - next = "it starts the build as today" // a declined box runs no canary |
| - } |
| - log.Printf("agent: retry requested for %s: its failure hold on %s is cleared and %s", rq.host, g.Build, next) |
| - } else { |
| - log.Printf("agent: retry for %s: nothing held to clear for %s at epoch %d of book %q (cleared already, a newer failure since the grant, or another book's grant)", rq.host, g.Build, g.Epoch, g.Book) |
| - } |
| - } |
| + // Each grant is verified, then applied where the hold is (applyRetries): |
| + // the host-retry log's, and a signed retry a console built before that |
| + // log left in the narrowed one, with the same checks. |
| + applyRetries(routes, grants, mem) |
| + applyRetries(routes, scoped, mem) |
| scoped = withoutRetries(scoped) |
| if len(moved) == 0 && len(scoped) == 0 { |
| return false |
| @@ -949,8 +932,20 @@ func restartSeed(ctx context.Context, reg *registry.Registry, store release.Stor |
| return |
| } |
| _, readErrs := restartGenPass(ctx, routes, store, mem.gen) |
| - _, scopeErrs := restartScopePass(ctx, routes, store, mem.scope) |
| + // A first sight cycles nothing, but it hands over the retry grants in |
| + // both logs: they are not seed-skipped (Astra plan 9), so a grant |
| + // written before this agent started, and not yet applied, is applied |
| + // now. A grant spent or made stale since clears nothing. |
| + scoped, scopeErrs := restartScopePass(ctx, routes, store, mem.scope) |
| readErrs = append(readErrs, scopeErrs...) |
| + grants, grantErrs := hostRetryPass(ctx, routes, store, mem) |
| + if len(grantErrs) > 0 { |
| + // Not a baseline hole: the host-retry log is no restart signal, and |
| + // an app it could not be read for is read again on the next move. |
| + log.Printf("agent: retry baseline: %d apps unreadable: %v", len(grantErrs), grantErrs[0]) |
| + } |
| + applyRetries(routes, grants, mem) |
| + applyRetries(routes, scoped, mem) |
| if len(readErrs) > 0 { |
| log.Printf("agent: restart baseline: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| return |
diff --git a/internal/host/restartscope.go b/internal/host/restartscope.go| index 919f539d5..4b15e5bd7 100644 |
| --- a/internal/host/restartscope.go |
| +++ b/internal/host/restartscope.go |
| @@ -52,6 +52,10 @@ import ( |
| type restartMemory struct { |
| gen map[[2]string]string // app -> restart-gen.json etag, or genAbsent |
| scope map[[2]string]string // app -> last applied request id, or release.RestartScopeNone |
| + // retries is app -> the ids of the host-retry log's entries this |
| + // process has handled (hostRetryPass). It is not a seed mark: an empty |
| + // memory, at agent start, handles every entry in the log. |
| + retries map[[2]string]map[string]bool |
| // retryHost clears one host's failure hold, for a RestartScopeRetry |
| // request whose root-signed grant verified: the grant's build, and the |
| @@ -69,7 +73,7 @@ type restartMemory struct { |
| } |
| func newRestartMemory() *restartMemory { |
| - return &restartMemory{gen: map[[2]string]string{}, scope: map[[2]string]string{}} |
| + return &restartMemory{gen: map[[2]string]string{}, scope: map[[2]string]string{}, retries: map[[2]string]map[string]bool{}} |
| } |
| // scopedRestart is one app's outstanding narrowed work: the requests this box |
| @@ -94,6 +98,14 @@ type scopedRestart struct { |
| // so. Restart requests are point-in-time events: a dropped one is recovered by |
| // typing the verb again, while a replayed one cycles production for something |
| // somebody asked for last week. |
| +// |
| +// A retry an earlier console left in this log is the one exception to both |
| +// (Astra plan 9): it is not a restart, and a grant is not seed-skipped. On |
| +// a first sight and on a lost position the log's retry entries are |
| +// reported anyway, for the caller to verify and apply exactly as on a move |
| +// (retryRequests, applyRetries): a grant spent or made stale since is a |
| +// no-op under the failure book's generation rule, and an unsigned one is |
| +// dropped with a line. withoutRetries keeps them out of every restart. |
| func restartScopePass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (out []scopedRestart, readErrs []error) { |
| apps := restartApps(routes) |
| live := make(map[[2]string]bool, len(apps)) |
| @@ -111,7 +123,8 @@ func restartScopePass(ctx context.Context, routes []registry.Route, store releas |
| newest := g.Newest() |
| prev, seen := cache[k] |
| if !seen { |
| - cache[k] = newest // first sight: record, ask for nothing |
| + cache[k] = newest // first sight: record, cycle nothing |
| + out = appendRetries(out, k, g.Requests) |
| continue |
| } |
| if prev == newest { |
| @@ -121,6 +134,7 @@ func restartScopePass(ctx context.Context, routes []registry.Route, store releas |
| cache[k] = newest |
| if !found { |
| log.Printf("agent: narrowed restarts for %s/%s: this box's position is no longer in the log — re-baselined, nothing cycled (run the verb again if something is still waiting)", k[0], k[1]) |
| + out = appendRetries(out, k, g.Requests) |
| continue |
| } |
| if len(pending) > 0 { |
| @@ -192,9 +206,10 @@ type retryRequest struct { |
| signed string |
| } |
| -// retryRequests is the host-retry half of the narrowed log: the entries, |
| -// oldest first, whose host is one of this box's routes in the requesting |
| -// app. A host on another box names nothing here. Entries are not merged by |
| +// retryRequests is the retry entries of work, from the host-retry log |
| +// (hostRetryPass) or a legacy retry in the narrowed log (restartScopePass): |
| +// the entries, oldest first, whose host is one of this box's routes in the |
| +// requesting app. A host on another box names nothing here. Entries are not merged by |
| // host: each carries its own grant, and the box verifies each (a forged |
| // entry must not hide a real one behind it). A grant spent by an earlier |
| // entry moves its restriction's epoch, so a second one for the same failure |
| @@ -217,6 +232,102 @@ func retryRequests(routes []registry.Route, work []scopedRestart) []retryRequest |
| return out |
| } |
| +// appendRetries adds app's retry entries among requests to out, as one |
| +// scopedRestart, when there are any. |
| +func appendRetries(out []scopedRestart, app [2]string, requests []release.RestartRequest) []scopedRestart { |
| + var rs []release.RestartRequest |
| + for _, req := range requests { |
| + if req.Scope.Kind == release.RestartScopeRetry { |
| + rs = append(rs, req) |
| + } |
| + } |
| + if len(rs) == 0 { |
| + return out |
| + } |
| + return append(out, scopedRestart{app: app, requests: rs}) |
| +} |
| + |
| +// hostRetryPass reads each app's host-retry log (release.HostRetryKey) and |
| +// returns the entries this process has not handled yet, for retryRequests |
| +// and applyRetries. It rides the serial like restartScopePass, one GET per |
| +// app on a move, and none at all on a box with nothing that could clear a |
| +// hold (retryHost nil), which no grant could change while the agent runs. |
| +// |
| +// It is not seed-skipped (Astra plan 9): a memory with no entry for an app, |
| +// at agent start or for an app this box has just gained, hands over every |
| +// entry in the log, so a grant written before an agent restart is applied |
| +// after it. That is safe because a grant is checked, not trusted: one past |
| +// its window, unsigned, or forged is dropped with a line, and one spent or |
| +// made stale since is a no-op under the failure book's generation rule |
| +// (RetryHostAt). Each entry is handled once per process: cache is the ids |
| +// handled, rebuilt from the log on each read so it stays as small as the |
| +// log, and an unchanged log hands over nothing, so nothing is verified |
| +// twice. A read or parse error leaves the app's ids as they were. |
| +func hostRetryPass(ctx context.Context, routes []registry.Route, store release.Store, mem *restartMemory) (out []scopedRestart, readErrs []error) { |
| + if mem.retryHost == nil { |
| + return nil, nil |
| + } |
| + apps := restartApps(routes) |
| + live := make(map[[2]string]bool, len(apps)) |
| + for _, k := range apps { |
| + live[k] = true |
| + g, _, err := release.LoadHostRetries(ctx, store, k[0], k[1]) |
| + if err != nil { |
| + readErrs = append(readErrs, fmt.Errorf("%s/%s host retries: %w", k[0], k[1], err)) |
| + continue |
| + } |
| + done := mem.retries[k] |
| + next := make(map[string]bool, len(g.Requests)) |
| + var pending []release.RestartRequest |
| + for _, req := range g.Requests { |
| + if !done[req.ID] && !next[req.ID] { |
| + pending = append(pending, req) |
| + } |
| + next[req.ID] = true |
| + } |
| + mem.retries[k] = next |
| + out = appendRetries(out, k, pending) |
| + } |
| + for k := range mem.retries { |
| + if !live[k] { |
| + delete(mem.retries, k) |
| + } |
| + } |
| + return out, readErrs |
| +} |
| + |
| +// applyRetries verifies and applies each retry in work naming a host this |
| +// box serves (retryRequests), the agent's one path for a grant from either |
| +// log. A retry clears a hold only on the console's root-signed grant for |
| +// its entry (spec §11, Astra impl-6 finding 5): every box can write both |
| +// logs, so an unsigned or foreign entry is dropped here, with a line, |
| +// before anything is cleared. The grant's epoch is checked against the |
| +// book where the clear is (rollout.Manager.RetryHost, or |
| +// failureHolds.retryHost on a box that declined the flag but honours its |
| +// book). |
| +func applyRetries(routes []registry.Route, work []scopedRestart, mem *restartMemory) { |
| + for _, rq := range retryRequests(routes, work) { |
| + if mem.retryHost == nil { |
| + log.Printf("agent: retry requested for %s, dropped: rollout manager not armed on this box, so nothing is held to clear", rq.host) |
| + continue |
| + } |
| + g, why := verifyRetry(mem.pub, rq, time.Now()) |
| + if why != "" { |
| + log.Printf("agent: retry requested for %s, dropped: %s", rq.host, why) |
| + continue |
| + } |
| + if mem.retryHost(rq.host, g.Build, g.Book, g.Epoch) { |
| + next := "its next attempt runs as a canary" |
| + if armedRollout() == nil { |
| + next = "it starts the build as today" // a declined box runs no canary |
| + } |
| + log.Printf("agent: retry requested for %s: its failure hold on %s is cleared and %s", rq.host, g.Build, next) |
| + } else { |
| + log.Printf("agent: retry for %s: nothing held to clear for %s at epoch %d of book %q (cleared already, a newer failure since the grant, or another book's grant)", rq.host, g.Build, g.Epoch, g.Book) |
| + } |
| + } |
| +} |
| + |
| // verifyRetry is the box's check of one retry entry (spec §11, Paul's |
| // ruling on plan 6 Q4): the grant must verify against the root key and |
| // name this entry's account, app and host, within its window |
diff --git a/internal/host/restartscope_test.go b/internal/host/restartscope_test.go| index cecdb2a8d..3a5bcf780 100644 |
| --- a/internal/host/restartscope_test.go |
| +++ b/internal/host/restartscope_test.go |
| @@ -635,13 +635,63 @@ func askRetry(t *testing.T, ctx context.Context, s release.Store, priv ed25519.P |
| return wire |
| } |
| +// askSignedRetry is a console's append of a signed grant to the host-retry |
| +// log (release.AppendHostRetry). |
| func askSignedRetry(t *testing.T, ctx context.Context, s release.Store, acct, app, host, wire string) { |
| t.Helper() |
| - if _, err := release.AppendRestartScope(ctx, s, acct, app, release.RestartScope{Kind: release.RestartScopeRetry, Value: host, Signed: wire}); err != nil { |
| + if _, err := release.AppendHostRetry(ctx, s, acct, app, host, wire); err != nil { |
| t.Fatal(err) |
| } |
| } |
| +// rawRetry appends a retry entry to the log at key with no validation: an |
| +// unsigned one in the host-retry log (any box can write it), or a retry in |
| +// the restart-scope log as a console built from main before the host-retry |
| +// log wrote it (legacyRetry), signed or, from an older one, not. |
| +func rawRetry(t *testing.T, ctx context.Context, s release.Store, key, host, wire string) string { |
| + t.Helper() |
| + g := &release.ScopedRestartGeneration{Schema: 1} |
| + raw, etag, err := s.Get(ctx, key) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + t.Fatal(err) |
| + default: |
| + if err := json.Unmarshal(raw, g); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + id := fmt.Sprintf("raw-%d-%d", len(g.Requests), time.Now().UnixNano()) |
| + g.Requests = append(g.Requests, release.RestartRequest{ID: id, RequestedAt: time.Now().UTC().Format(time.RFC3339), |
| + Scope: release.RestartScope{Kind: release.RestartScopeRetry, Value: host, Signed: wire}}) |
| + body, err := json.Marshal(g) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + return id |
| +} |
| + |
| +// legacyRetry is a retry in the restart-scope log, as a console built from |
| +// main before the host-retry log appended it. |
| +func legacyRetry(t *testing.T, ctx context.Context, s release.Store, acct, app, host, wire string) { |
| + t.Helper() |
| + rawRetry(t, ctx, s, release.RestartScopeKey(acct, app), host, wire) |
| +} |
| + |
| +// legacySignedRetry signs a grant as legacyRetry's console did. |
| +func legacySignedRetry(t *testing.T, ctx context.Context, s release.Store, priv ed25519.PrivateKey, acct, app, host, build, book string, epoch uint64) { |
| + t.Helper() |
| + wire, err := release.SignHostRetry(release.RootSigner(priv), acct, app, host, build, book, epoch, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + legacyRetry(t, ctx, s, acct, app, host, wire) |
| +} |
| + |
| // Spec §11, Astra impl-6 finding 5: every box can write the restart-scope |
| // log, so only a grant the root signed for this entry's account, app and |
| // host reaches the manager. An unsigned entry (another box's, or an older |
| @@ -661,7 +711,7 @@ func TestRestartLegDropsARetryThatIsNotTheRootsGrant(t *testing.T) { |
| bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } |
| restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline |
| - askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeRetry, "a.example.com") |
| + rawRetry(t, ctx, s, release.HostRetryKey("bab", "eleven"), "a.example.com", "") |
| askRetry(t, ctx, s, other, "bab", "eleven", "a.example.com", "v2", "bk", 5) |
| forB, _ := release.SignHostRetry(release.RootSigner(priv), "bab", "eleven", "b.example.com", "v2", "bk", 5, time.Now()) |
| askSignedRetry(t, ctx, s, "bab", "eleven", "a.example.com", forB) |
| @@ -767,7 +817,10 @@ func TestRestartLegRetryGrantsAgainstTheBook(t *testing.T) { |
| } |
| // With no manager armed (flag off, or a box that declined or refused it) |
| -// nothing is held: the request is consumed, logged, and cycles nothing. |
| +// nothing is held: a retry an older console left in the restart-scope log |
| +// is consumed, logged, and cycles nothing. Such a box does not read the |
| +// host-retry log at all (hostRetryPass): nothing it holds could be |
| +// cleared. |
| func TestRestartLegDropsARetryWithNoManager(t *testing.T) { |
| ctx, s := genKit(t) |
| dd := t.TempDir() |
| @@ -775,7 +828,7 @@ func TestRestartLegDropsARetryWithNoManager(t *testing.T) { |
| mem := newRestartMemory() |
| bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true } |
| restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) |
| - askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeRetry, "a.example.com") |
| + legacyRetry(t, ctx, s, "bab", "eleven", "a.example.com", "") |
| var logbuf bytes.Buffer |
| log.SetOutput(&logbuf) |
| @@ -816,7 +869,7 @@ func TestScopedHostsAndRootPassStepOverARetry(t *testing.T) { |
| ctx, s := genKit(t) |
| marks := restartMarksPath(t.TempDir()) |
| restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) // baseline |
| - askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeRetry, "bill.example.com") |
| + legacyRetry(t, ctx, s, "bab", "titogo", "bill.example.com", "") |
| rec := &recordingRestart{} |
| restartPass(ctx, routes, s, marks, rec.fn) |
| if len(rec.hosts) != 0 { |
| @@ -865,3 +918,100 @@ func TestRestartLegLogsARetryWithNothingHeld(t *testing.T) { |
| t.Fatalf("log = %q", got) |
| } |
| } |
| + |
| +// Plan 9 (Astra), a console built from main before the host-retry log: its |
| +// signed, book-bound grant in the restart-scope log is verified and applied |
| +// with the same checks as one in the host-retry log, and an unsigned or |
| +// foreign one there is dropped with a line and clears nothing. An unchanged |
| +// log is not handled twice. |
| +func TestRestartLegAppliesALegacySignedRetryWithTheSameChecks(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} |
| + mem, priv := retryMemory(t) |
| + _, other, _ := ed25519.GenerateKey(nil) |
| + var retried []string |
| + mem.retryHost = func(h, b, bk string, e uint64) bool { |
| + retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e)) |
| + return true |
| + } |
| + bounce := func(h string) bool { t.Errorf("%s was bounced for a retry", h); return true } |
| + restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline |
| + |
| + legacySignedRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v2", "bk", 5) |
| + legacyRetry(t, ctx, s, "bab", "eleven", "a.example.com", "") |
| + legacySignedRetry(t, ctx, s, other, "bab", "eleven", "a.example.com", "v2", "bk", 5) |
| + var logbuf bytes.Buffer |
| + log.SetOutput(&logbuf) |
| + defer log.SetOutput(os.Stderr) |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked { |
| + t.Fatal("a retry-only move asked for a root restart pass") |
| + } |
| + if !slices.Equal(retried, []string{"b.example.com v2 bk 5"}) { |
| + t.Fatalf("retried %v, want the legacy signed grant for b.example.com only", retried) |
| + } |
| + got := logbuf.String() |
| + if strings.Count(got, "agent: retry requested for a.example.com, dropped:") != 2 || !strings.Contains(got, "unsigned") { |
| + t.Fatalf("log = %q, want the unsigned and the foreign legacy entries dropped", got) |
| + } |
| + |
| + logbuf.Reset() |
| + restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) |
| + if len(retried) != 1 || strings.Contains(logbuf.String(), "retry") { |
| + t.Fatalf("an unchanged log was handled again: retried %v, log %q", retried, logbuf.String()) |
| + } |
| +} |
| + |
| +// Plan 9 (Astra), the ruling that grants are not seed-skipped: a grant |
| +// written before an agent restart, and not yet applied, is applied after |
| +// it, whichever log it rides, because the restart memory is rebuilt and a |
| +// spent or stale grant is a no-op under the generation rule. A restart |
| +// request written before the restart keeps today's seed-skip: it cycles |
| +// nothing. The grants are handled once. |
| +func TestARetryGrantWrittenBeforeAnAgentRestartIsApplied(t *testing.T) { |
| + ctx, s := genKit(t) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")} |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + dd := t.TempDir() |
| + first, priv := retryMemory(t) |
| + first.retryHost = func(h, b, _ string, e uint64) bool { |
| + t.Errorf("the first agent applied %s %s %d", h, b, e) |
| + return true |
| + } |
| + restartSeed(ctx, reg, s, dd, first) |
| + |
| + // The grants and a restart land; the agent restarts before its next |
| + // serial move. |
| + askRetry(t, ctx, s, priv, "bab", "eleven", "a.example.com", "v2", "bk", 5) |
| + legacySignedRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v3", "bk", 6) |
| + askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeHost, "a.example.com") |
| + |
| + second := newRestartMemory() |
| + second.pub = first.pub |
| + var retried []string |
| + second.retryHost = func(h, b, bk string, e uint64) bool { |
| + retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e)) |
| + return true |
| + } |
| + restartSeed(ctx, reg, s, dd, second) |
| + slices.Sort(retried) |
| + if !slices.Equal(retried, []string{"a.example.com v2 bk 5", "b.example.com v3 bk 6"}) { |
| + t.Fatalf("after the restart retried %v, want both grants written before it", retried) |
| + } |
| + bounce := func(h string) bool { t.Errorf("%s was bounced: restarted before the agent", h); return true } |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, second); asked { |
| + t.Fatal("the first move after the restart asked for a root pass") |
| + } |
| + if len(retried) != 2 { |
| + t.Fatalf("the grants were handled again: %v", retried) |
| + } |
| +} |
diff --git a/internal/release/hostretry.go b/internal/release/hostretry.go| index 2aba99e9b..905c836b6 100644 |
| --- a/internal/release/hostretry.go |
| +++ b/internal/release/hostretry.go |
| @@ -11,9 +11,10 @@ import ( |
| // a box clears a host's failure hold only on a grant the deployment root |
| // signed, naming that account, app, host and the build that restricts it. |
| // |
| -// The restart-scope log it rides is writable by every box (Astra impl-6 |
| -// finding 5), so an unsigned entry is a request anyone with a box could |
| -// have written, and it clears nothing. The signature closes forgery; Epoch |
| +// The host-retry log it rides (HostRetryKey; the restart-scope log before |
| +// readiness round 2, whose legacy entries boxes still honour) is writable |
| +// by every box (Astra impl-6 finding 5), so an unsigned entry is a request |
| +// anyone with a box could have written, and it clears nothing. The signature closes forgery; Epoch |
| // and Book close replay (plan review finding 13; the crash-epoch hard limit, |
| // readiness round 2 Task 1): Epoch is the app's restriction generation as |
| // the box published it (rollout.FailureBook.PublishedEpoch), which the box |