retries: signed host retries ride their own object; the console asks the box's report first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 files changed,
+257
−52
internal/console/apiinstances.go+6 −2internal/console/apiretry.go+51 −16internal/console/apiretry_test.go+101 −14internal/console/browserretry.go+10 −0internal/console/browserretry_test.go+37 −3internal/console/claim_invariant_fixtures_test.go+5 −5internal/console/rolloutfleet_test.go+2 −1internal/host/agent.go+3 −1internal/host/restart.go+12 −1internal/host/restartscope.go+5 −5internal/host/rolloutdeclined.go+2 −1internal/host/rolloutdeclined_test.go+1 −1internal/provision/plan9_b8_census_test.go+1 −1internal/rollout/retrygrant_test.go+7 −0internal/rollout/status.go+3 −1internal/status/snapshot.go+11 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 69198c09b..c385461e6 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -376,6 +376,10 @@ type rolloutView struct { |
| // shown: the retry doors sign both into their grant (retryHostBuild). |
| heldEpoch uint64 |
| heldBook string |
| + // retryV is the retry protocol the box takes (status.HostRollout.RetryV): |
| + // 0 is a box built before the host-retry log, which reads no grant |
| + // there, so the retry doors write it none (retryHostBuild). |
| + retryV int |
| // refused marks a view built from an Unavailable box, which has no |
| // account or app to check (hosts are unique; the caller filtered by app). |
| refused bool |
| @@ -590,7 +594,7 @@ func (j *boxJoin) foldRollout(b status.Box, override bool) { |
| } |
| for host, hr := range ro.Hosts { |
| put(host, rolloutView{Box: b.Label, Installed: hr.Installed, Good: hr.Good, Attempt: hr.Attempt, AttemptBuild: hr.AttemptBuild, |
| - HeldBy: hr.HeldBy, Reason: hr.Reason, Unavailable: ro.Unavailable, account: hr.Account, app: hr.App, heldEpoch: hr.HeldEpoch, heldBook: hr.HeldBook}) |
| + HeldBy: hr.HeldBy, Reason: hr.Reason, Unavailable: ro.Unavailable, account: hr.Account, app: hr.App, heldEpoch: hr.HeldEpoch, heldBook: hr.HeldBook, retryV: hr.RetryV}) |
| } |
| return |
| } |
| @@ -611,7 +615,7 @@ func (j *boxJoin) foldRollout(b status.Box, override bool) { |
| Box: b.Label, Installed: hr.Installed, Serving: hr.Serving, Good: hr.Good, Awake: hr.Awake, |
| Attempt: hr.Attempt, AttemptBuild: hr.AttemptBuild, HeldBy: hr.HeldBy, Reason: hr.Reason, |
| ConfigPending: hr.ConfigPending, Transient: hr.Transient, SchedulerIdle: a.Queued == 0 && a.Running == 0 && a.Settling == 0, |
| - account: hr.Account, app: hr.App, heldEpoch: hr.HeldEpoch, heldBook: hr.HeldBook, |
| + account: hr.Account, app: hr.App, heldEpoch: hr.HeldEpoch, heldBook: hr.HeldBook, retryV: hr.RetryV, |
| }) |
| } |
| } |
diff --git a/internal/console/apiretry.go b/internal/console/apiretry.go| index cab729851..90ec3347e 100644 |
| --- a/internal/console/apiretry.go |
| +++ b/internal/console/apiretry.go |
| @@ -3,15 +3,17 @@ |
| // runs as a canary. The app-wide retry is `carlos promote --retry`, which |
| // re-signs the channel pointer. |
| // |
| -// The request rides the narrowed-restart log as selector kind "retry" |
| -// (release.RestartScopeRetry). A box built before that kind steps over it; |
| -// one built after hands it to its rollout manager. Every box can write that |
| -// log (Astra impl-6 finding 5), so the entry carries a grant the root key |
| -// signs here (release.HostRetryGrant, spec §11; Paul's ruling on plan 6 Q4, |
| -// amadan #272), naming the held build and its restriction's epoch as the |
| -// box reported them, and a box clears nothing on an entry without one. A |
| -// console with no key (read-only) answers 503 and writes nothing. ROLE: |
| -// operate, as `carlos restart` is. |
| +// The request rides the app's host-retry log (release.AppendHostRetry, |
| +// readiness round 2, Task 3), which no older console rewrites and no older |
| +// box reads. Every box can write that log (Astra impl-6 finding 5), so the |
| +// entry carries a grant the root key signs here (release.HostRetryGrant, |
| +// spec §11; Paul's ruling on plan 6 Q4, amadan #272), naming the held build |
| +// and its restriction's epoch and book as the box reported them, and a box |
| +// clears nothing on an entry without one. The door writes only for a box |
| +// whose report says it can take the grant (retryHostBuild): an older box, |
| +// or one whose failure book is not saved yet, is refused with a message |
| +// and nothing is written. A console with no key (read-only) answers 503 |
| +// and writes nothing. ROLE: operate, as `carlos restart` is. |
| package console |
| import ( |
| @@ -35,12 +37,27 @@ const ( |
| // apiRetryNoKey is a read-only console's answer: a retry is a grant the |
| // root key signs (spec §11), and there is no key here to sign it. |
| apiRetryNoKey = "no signing key on this host. this console cannot request a retry" |
| + // apiRetryUnsupported answers a box whose report has no retry_v or no |
| + // held_book: it reads no grant from the host-retry log, so a retry |
| + // written for it would clear nothing. |
| + apiRetryUnsupported = "this instance's box is too old to take a retry. update the box, then try again" // TODO-COPY(zdd-readiness2.retry-unsupported.api) |
| + // apiRetryUnsaved answers a held host reported at epoch 0: its box's |
| + // failure book owes a save, and no grant clears anything until it lands. |
| + apiRetryUnsaved = "this instance's box has not saved its failure yet. try again in a minute" // TODO-COPY(zdd-readiness2.retry-unsaved.api) |
| ) |
| var ( |
| errRetryNoRecord = errors.New("retry: no such instance") |
| errRetryNotHeld = errors.New("retry: not held") |
| errRetryNoKey = errors.New("retry: no signing key on this console") |
| + // errRetryUnsupported is a held host whose box cannot take a retry: |
| + // its report has no retry_v (a box built before the host-retry log) or |
| + // no held_book (one built before the book's identity). |
| + errRetryUnsupported = errors.New("retry: the box cannot take a retry") |
| + // errRetryUnsaved is a held host whose box reports retry_v but epoch 0: |
| + // its failure book is not saved (readiness round 2, Task 1), and a grant |
| + // signed now would name no epoch the box accepts. |
| + errRetryUnsaved = errors.New("retry: the box has not saved its failure book") |
| ) |
| // retryHostBuild is the build holding host back, and the epoch of that |
| @@ -53,9 +70,16 @@ var ( |
| // flag (a decline code, rolloutwords.UnavailableDeclines) and reports the |
| // host held by the failure book it still honours: that box verifies and |
| // applies a signed retry too (Task 10, plan review #5). A refusing box, or |
| -// one whose code this console does not know, is never retried here. A box |
| -// older than the epoch reports 0, and one older than the book reports "": |
| -// each is signed as it is, and no box accepts it. |
| +// one whose code this console does not know, is never retried here. |
| +// |
| +// Then the box's report is asked whether it can take the grant (readiness |
| +// round 2, Task 3), and a retry it could not take is refused with a |
| +// message rather than written to clear nothing: a report with no retry_v |
| +// is a box built before the host-retry log, which never reads it, and one |
| +// with no held_book is older than the book's identity |
| +// (errRetryUnsupported); a held host at epoch 0 is a box whose failure |
| +// book owes a save, and no grant clears anything until it lands |
| +// (errRetryUnsaved). |
| func (s *Server) retryHostBuild(ctx context.Context, acct *Account, app, host string) (heldRestriction, error) { |
| if _, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -68,6 +92,12 @@ func (s *Server) retryHostBuild(ctx context.Context, acct *Account, app, host st |
| if ro == nil || ro.HeldBy == "" || (ro.Unavailable != "" && !rolloutwords.UnavailableDeclines(ro.Unavailable)) { |
| return heldRestriction{}, errRetryNotHeld |
| } |
| + switch { |
| + case ro.retryV == 0 || ro.heldBook == "": |
| + return heldRestriction{}, errRetryUnsupported |
| + case ro.heldEpoch == 0: |
| + return heldRestriction{}, errRetryUnsaved |
| + } |
| return heldRestriction{build: ro.HeldBy, book: ro.heldBook, epoch: ro.heldEpoch}, nil |
| } |
| @@ -79,8 +109,8 @@ type heldRestriction struct { |
| } |
| // requestHostRetry signs the retry grant for held (its build, book and |
| -// epoch) with the root key, appends it as a retry request, by the same |
| -// append the narrowed-restart door uses, and announces it on the serial. With no key |
| +// epoch) with the root key, appends it to the app's host-retry log |
| +// (release.AppendHostRetry), and announces it on the serial. With no key |
| // it writes nothing (errRetryNoKey): an unsigned entry is one every box |
| // drops. |
| func (s *Server) requestHostRetry(ctx context.Context, acct, app, host string, held heldRestriction) (release.RestartRequest, error) { |
| @@ -91,8 +121,7 @@ func (s *Server) requestHostRetry(ctx context.Context, acct, app, host string, h |
| if err != nil { |
| return release.RestartRequest{}, err |
| } |
| - req, err := release.AppendRestartScope(ctx, s.cfg.Releases, acct, app, |
| - release.RestartScope{Kind: release.RestartScopeRetry, Value: host, Signed: signed}) |
| + req, err := release.AppendHostRetry(ctx, s.cfg.Releases, acct, app, host, signed) |
| if err != nil { |
| return release.RestartRequest{}, err |
| } |
| @@ -144,6 +173,12 @@ func (s *Server) handleAPIInstanceRetry(w http.ResponseWriter, r *http.Request) |
| case errors.Is(err, errRetryNotHeld): |
| jsonError(w, http.StatusConflict, fmt.Sprintf(apiRetryNotHeld, host)) |
| return |
| + case errors.Is(err, errRetryUnsupported): |
| + jsonError(w, http.StatusConflict, apiRetryUnsupported) |
| + return |
| + case errors.Is(err, errRetryUnsaved): |
| + jsonError(w, http.StatusConflict, apiRetryUnsaved) |
| + return |
| case err != nil: |
| log.Printf("console: api instance retry %s/%s/%s: %v", acct.Sqid, app, host, err) |
| jsonError(w, http.StatusBadGateway, "could not read the instance") |
diff --git a/internal/console/apiretry_test.go b/internal/console/apiretry_test.go| index 3f39b4979..66b2f39da 100644 |
| --- a/internal/console/apiretry_test.go |
| +++ b/internal/console/apiretry_test.go |
| @@ -13,16 +13,33 @@ import ( |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| -// lastScoped reads the newest request in the app's narrowed log. |
| +// lastScoped reads the newest request in the app's host-retry log, where |
| +// a retry's grant rides (readiness round 2, Task 3), and checks the |
| +// restart-scope log took none. |
| func lastScoped(t *testing.T, fx *consoleFixture, app string) release.RestartRequest { |
| t.Helper() |
| - g, found, err := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, app) |
| + g, found, err := release.LoadHostRetries(context.Background(), fx.rel, fx.sqid, app) |
| if err != nil || !found || len(g.Requests) == 0 { |
| - t.Fatalf("restart scopes = %+v, %v, %v", g, found, err) |
| + t.Fatalf("host retries = %+v, %v, %v", g, found, err) |
| + } |
| + if old, found, _ := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, app); found && len(old.Requests) != 0 { |
| + t.Fatalf("a retry wrote the restart-scope log: %+v", old.Requests) |
| } |
| return g.Requests[len(g.Requests)-1] |
| } |
| +// noRetryWritten checks neither log holds a request for app. |
| +func noRetryWritten(t *testing.T, fx *consoleFixture, app string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + if g, found, _ := release.LoadHostRetries(ctx, fx.rel, fx.sqid, app); found && len(g.Requests) != 0 { |
| + t.Fatalf("a retry was written: %+v", g.Requests) |
| + } |
| + if g, found, _ := release.LoadRestartScopes(ctx, fx.rel, fx.sqid, app); found && len(g.Requests) != 0 { |
| + t.Fatalf("a request was written to the restart-scope log: %+v", g.Requests) |
| + } |
| +} |
| + |
| // heldEpoch is the restriction epoch writeHeldBox reports, and heldBook the |
| // failure book it counts in (final review round 1, finding 3). |
| const ( |
| @@ -34,7 +51,7 @@ func writeHeldBox(t *testing.T, fx *consoleFixture, host, app, build string) { |
| t.Helper() |
| box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| - host: {Account: fx.sqid, App: app, Installed: build, Serving: "v1", Good: "v1", Awake: true, HeldBy: build, HeldEpoch: heldEpoch, HeldBook: heldBook, Reason: status.ReasonHealth}, |
| + host: {Account: fx.sqid, App: app, Installed: build, Serving: "v1", Good: "v1", Awake: true, HeldBy: build, HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV, Reason: status.ReasonHealth}, |
| }}} |
| if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| t.Fatal(err) |
| @@ -128,7 +145,7 @@ func TestAPIInstanceRetryOfADeclinedBoxsHold(t *testing.T) { |
| ro := &status.RolloutReport{Unavailable: code, FailureBook: "honoured"} |
| if held { |
| ro.Hosts = map[string]status.HostRollout{host: {Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", |
| - Attempt: status.AttemptDown, AttemptBuild: "v2", HeldBy: "v2", HeldEpoch: heldEpoch, HeldBook: heldBook}} |
| + Attempt: status.AttemptDown, AttemptBuild: "v2", HeldBy: "v2", HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV}} |
| } |
| box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| box.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{host: "v2"}, Rollout: ro} |
| @@ -159,9 +176,7 @@ func TestAPIInstanceRetryOfADeclinedBoxsHold(t *testing.T) { |
| if code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) { |
| t.Fatalf("retry = %d %s, want 409 naming %s as not held", code, body, host) |
| } |
| - if g, found, _ := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, "kass"); found && len(g.Requests) != 0 { |
| - t.Fatalf("a retry was written: %+v", g.Requests) |
| - } |
| + noRetryWritten(t, fx, "kass") |
| return |
| } |
| if code != http.StatusOK { |
| @@ -210,9 +225,7 @@ func TestAPIInstanceRetryRefusesAHostThatIsNotHeld(t *testing.T) { |
| if code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) { |
| t.Fatalf("a healthy host = %d %s, want 409 naming %s as not held", code, body, host) |
| } |
| - if g, found, _ := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, "kass"); found && len(g.Requests) != 0 { |
| - t.Fatalf("a retry was written for a host that is not held: %+v", g.Requests) |
| - } |
| + noRetryWritten(t, fx, "kass") |
| } |
| // A console with no root key (read-only) cannot sign a retry: it answers |
| @@ -230,10 +243,84 @@ func TestAPIInstanceRetryWithNoKeyWritesNothing(t *testing.T) { |
| if code != http.StatusServiceUnavailable || !strings.Contains(body, apiRetryNoKey) { |
| t.Fatalf("no key = %d %s, want 503", code, body) |
| } |
| - if g, found, _ := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, "kass"); found && len(g.Requests) != 0 { |
| - t.Fatalf("a console with no key wrote %+v", g.Requests) |
| - } |
| + noRetryWritten(t, fx, "kass") |
| if n := serial(t, fx); n != 0 { |
| t.Fatalf("serial = %d, want no announcement", n) |
| } |
| } |
| + |
| +// writeRetryReport publishes host held by v2 with the given retry fields, on |
| +// an armed box or, with code set, a declined one honouring its book. |
| +func writeRetryReport(t *testing.T, fx *consoleFixture, host, code string, retryV int, epoch uint64, book string) { |
| + t.Helper() |
| + hr := status.HostRollout{Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", HeldBy: "v2", Reason: status.ReasonHealth, |
| + HeldEpoch: epoch, HeldBook: book, RetryV: retryV} |
| + ro := &status.RolloutReport{Hosts: map[string]status.HostRollout{host: hr}} |
| + if code != "" { |
| + ro.Unavailable, ro.FailureBook = code, "honoured" |
| + hr.Attempt, hr.AttemptBuild, hr.Reason = status.AttemptDown, "v2", "" |
| + ro.Hosts[host] = hr |
| + } |
| + box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| + box.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{host: "v2"}, Rollout: ro} |
| + if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Readiness round 2, Task 3 (plan 9's transitions): the door asks the |
| +// box's report before it writes a retry. A box with no retry_v (built |
| +// before the host-retry log) or no held_book cannot take one: 409, and |
| +// nothing is written or announced. A box whose failure book is not saved |
| +// publishes epoch 0, and no grant for it could clear anything: 409, try |
| +// again. A box with retry_v, an epoch and a book gets the signed grant in |
| +// host-retry.json, and nothing in restart-scope.json. Armed and declined |
| +// boxes alike. |
| +func TestAPIInstanceRetryAsksTheBoxsReportFirst(t *testing.T) { |
| + const host = "web.kass.carlosapp.dev" |
| + for _, box := range []string{"", "no-cgroup-delegation"} { |
| + for _, tc := range []struct { |
| + name string |
| + retryV int |
| + epoch uint64 |
| + book string |
| + want string // "" is the grant written |
| + }{ |
| + {"an older box", 0, heldEpoch, heldBook, apiRetryUnsupported}, |
| + {"an older box with no epoch", 0, 0, heldBook, apiRetryUnsupported}, |
| + {"no book", status.HostRetryV, heldEpoch, "", apiRetryUnsupported}, |
| + {"book not saved", status.HostRetryV, 0, heldBook, apiRetryUnsaved}, |
| + {"a box that takes it", status.HostRetryV, heldEpoch, heldBook, ""}, |
| + } { |
| + t.Run(box+" "+tc.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + sibling(t, fx, "kass", host, "stable") |
| + writeRetryReport(t, fx, host, box, tc.retryV, tc.epoch, tc.book) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-v") |
| + u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry" |
| + code, _, body := rawReq(t, http.MethodPost, u, token, `{}`) |
| + if tc.want != "" { |
| + if code != http.StatusConflict || !strings.Contains(body, tc.want) { |
| + t.Fatalf("retry = %d %s, want 409 %q", code, body, tc.want) |
| + } |
| + noRetryWritten(t, fx, "kass") |
| + if n := serial(t, fx); n != 0 { |
| + t.Fatalf("serial = %d, want no announcement", n) |
| + } |
| + return |
| + } |
| + if code != http.StatusOK { |
| + t.Fatalf("retry = %d %s", code, body) |
| + } |
| + r := lastScoped(t, fx, "kass") |
| + g, err := release.VerifyHostRetry(fx.pub, r.Scope.Signed, fx.sqid, "kass", host, time.Now()) |
| + if r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != host || err != nil || g.Build != "v2" || g.Epoch != heldEpoch || g.Book != heldBook { |
| + t.Fatalf("stored %+v, grant %+v, %v", r, g, err) |
| + } |
| + if n := serial(t, fx); n != 1 { |
| + t.Fatalf("serial = %d", n) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/console/browserretry.go b/internal/console/browserretry.go| index d20bf1e42..042c50e79 100644 |
| --- a/internal/console/browserretry.go |
| +++ b/internal/console/browserretry.go |
| @@ -17,6 +17,10 @@ import ( |
| const ( |
| browserRetryNoKeyTitle = "Retry unavailable" |
| browserRetryNoKey = "No signing key on this host, so this console cannot request a retry." |
| + // The flashes for errRetryUnsupported and errRetryUnsaved |
| + // (apiRetryUnsupported's and apiRetryUnsaved's twins). |
| + browserRetryUnsupported = "This instance's box is too old to take a retry. Update the box, then try again." // TODO-COPY(zdd-readiness2.retry-unsupported.browser) |
| + browserRetryUnsaved = "This instance's box has not saved its failure yet. Try again in a minute." // TODO-COPY(zdd-readiness2.retry-unsaved.browser) |
| ) |
| // handleInstanceRetry is the instance page's Retry (spec §9.2): the browser |
| @@ -66,6 +70,12 @@ func (s *Server) handleInstanceRetry(w http.ResponseWriter, r *http.Request) { |
| case errors.Is(err, errRetryNotHeld): |
| flash("err", strings.ReplaceAll(swapCopy.NotHeld, "{host}", host)) |
| return |
| + case errors.Is(err, errRetryUnsupported): |
| + flash("err", browserRetryUnsupported) |
| + return |
| + case errors.Is(err, errRetryUnsaved): |
| + flash("err", browserRetryUnsaved) |
| + return |
| case err != nil: |
| log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err) |
| flash("err", swapCopy.RetryFailed) |
diff --git a/internal/console/browserretry_test.go b/internal/console/browserretry_test.go| index 4942ee739..8b0430a9f 100644 |
| --- a/internal/console/browserretry_test.go |
| +++ b/internal/console/browserretry_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "fmt" |
| + "html" |
| "net/http" |
| "net/url" |
| "strings" |
| @@ -117,9 +118,7 @@ func TestBrowserRetryWithNoKeyWritesNothing(t *testing.T) { |
| if code != http.StatusServiceUnavailable || !strings.Contains(body, browserRetryNoKey) { |
| t.Fatalf("no key = %d, want 503 naming the missing key", code) |
| } |
| - if g, found, _ := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, "kass"); found && len(g.Requests) != 0 { |
| - t.Fatalf("a console with no key wrote %+v", g.Requests) |
| - } |
| + noRetryWritten(t, fx, "kass") |
| } |
| func TestBrowserRetryRefusals(t *testing.T) { |
| @@ -134,3 +133,38 @@ func TestBrowserRetryRefusals(t *testing.T) { |
| t.Fatalf("unknown host = %d %q", code, body) |
| } |
| } |
| + |
| +// The browser twin of TestAPIInstanceRetryAsksTheBoxsReportFirst: the |
| +// refusal is flashed on the instance page, before any confirmation, and |
| +// nothing is written. |
| +func TestBrowserRetryAsksTheBoxsReportFirst(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + retryV int |
| + epoch uint64 |
| + want string |
| + }{ |
| + {"an older box", 0, heldEpoch, browserRetryUnsupported}, |
| + {"book not saved", status.HostRetryV, 0, browserRetryUnsaved}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| + sibling(t, fx, "kass", heldHost, "stable") |
| + writeRetryReport(t, fx, heldHost, "", tc.retryV, tc.epoch, heldBook) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + endpoint := fx.u("/apps/kass/instances/" + heldHost + "/retry") |
| + for _, form := range []url.Values{{"csrf": {csrf}}, {"csrf": {csrf}, "confirmed": {"1"}}} { |
| + code, loc, body := postGated(t, fx, c, endpoint, form) |
| + if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) { |
| + t.Fatalf("retry = %d -> %q, want the flash's redirect", code, loc) |
| + } |
| + if page := flashAfter(t, fx, c, code, loc, body); !strings.Contains(page, html.EscapeString(tc.want)) { |
| + t.Fatalf("the page after the refusal lacks %q", tc.want) |
| + } |
| + } |
| + noRetryWritten(t, fx, "kass") |
| + }) |
| + } |
| +} |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 4d4d29378..0d2298742 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -287,7 +287,7 @@ func (cx *claimCase) heldInstance(t *testing.T, host string) { |
| box := status.CollectBox(nil, status.Probes{MachineID: "claim-box"}, time.Now()) |
| box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| host: {Account: "bab", App: "titogo", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, |
| - HeldBy: "v2", Reason: status.ReasonHealth}, |
| + HeldBy: "v2", Reason: status.ReasonHealth, HeldEpoch: 3, HeldBook: "claim-fixture-book", RetryV: status.HostRetryV}, |
| }}} |
| if err := status.WriteBox(context.Background(), cx.rel, box); err != nil { |
| t.Fatal(err) |
| @@ -939,7 +939,7 @@ var claimFixtures = map[string]claimFixture{ |
| setup: func(t *testing.T, cx *claimCase) { cx.heldInstance(t, "web.go.tito.io") }, |
| do: claimForm("/a/bab/apps/titogo/instances/web.go.tito.io/retry", "confirmed", "1"), |
| check: func(t *testing.T, cx *claimCase, open bool) string { |
| - g, found, err := release.LoadRestartScopes(context.Background(), cx.rel, "bab", "titogo") |
| + g, found, err := release.LoadHostRetries(context.Background(), cx.rel, "bab", "titogo") |
| if err != nil { |
| return err.Error() |
| } |
| @@ -951,7 +951,7 @@ var claimFixtures = map[string]claimFixture{ |
| } |
| switch { |
| case open && !asked: |
| - return "the retry request was not written: the handler never reached the narrowed log" |
| + return "the retry request was not written: the handler never reached the host-retry log" |
| case !open && asked: |
| return "a retry request was written while the app was closed under a foreign exclusive operation" |
| } |
| @@ -1262,7 +1262,7 @@ var claimFixtures = map[string]claimFixture{ |
| setup: func(t *testing.T, cx *claimCase) { cx.heldInstance(t, "web.go.tito.io") }, |
| do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/retry", `{}`), |
| check: func(t *testing.T, cx *claimCase, open bool) string { |
| - g, found, err := release.LoadRestartScopes(context.Background(), cx.rel, "bab", "titogo") |
| + g, found, err := release.LoadHostRetries(context.Background(), cx.rel, "bab", "titogo") |
| if err != nil { |
| return err.Error() |
| } |
| @@ -1274,7 +1274,7 @@ var claimFixtures = map[string]claimFixture{ |
| } |
| switch { |
| case open && !asked: |
| - return "the retry request was not written: the handler never reached the narrowed log" |
| + return "the retry request was not written: the handler never reached the host-retry log" |
| case !open && asked: |
| return "a retry request was written while the app was closed under a foreign exclusive operation" |
| } |
diff --git a/internal/console/rolloutfleet_test.go b/internal/console/rolloutfleet_test.go| index fedc1ddd2..29d6df9e6 100644 |
| --- a/internal/console/rolloutfleet_test.go |
| +++ b/internal/console/rolloutfleet_test.go |
| @@ -31,7 +31,8 @@ func writeFleetHeldBox(t *testing.T, fx *consoleFixture, fleet, owner, host, app |
| } |
| box := status.CollectBox(nil, status.Probes{MachineID: fleet + "-box"}, time.Now()) |
| box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| - host: {Account: fx.sqid, App: app, Installed: build, Serving: "v1", Good: "v1", Awake: true, HeldBy: build, Reason: status.ReasonHealth}, |
| + host: {Account: fx.sqid, App: app, Installed: build, Serving: "v1", Good: "v1", Awake: true, HeldBy: build, Reason: status.ReasonHealth, |
| + HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV}, |
| }}} |
| if err := status.WriteBox(context.Background(), release.NewPrefixStore(fx.rel, "fleets/"+fleet+"/"), box); err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index cea8a0b21..89d942c37 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -2390,7 +2390,9 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // first request to establish itself. |
| restartMem := newRestartMemory() |
| // `carlos instances retry` (zero-downtime spec §8.4) reaches the rollout |
| - // manager through the narrowed-restart log, or, on a box that declined |
| + // manager through the host-retry log (a third read per app at arming, |
| + // and on a move, only where retryHost is set; hostRetryPass) or a legacy |
| + // entry in the narrowed-restart log, or, on a box that declined |
| // the flag but honours its failure book, that book (Task 10); nil |
| // otherwise. Each entry's grant is verified against the root key first |
| // (spec §11). |
diff --git a/internal/host/restart.go b/internal/host/restart.go| index 692e1e1a4..4d6c35531 100644 |
| --- a/internal/host/restart.go |
| +++ b/internal/host/restart.go |
| @@ -925,6 +925,12 @@ func restartRequestLeg(ctx context.Context, routes []registry.Route, store relea |
| // requested" and then cycling nothing unit-shaped. It is one marker touch, |
| // once, at arming: the pass it starts is the ordinary one, and once that pass |
| // has recorded the narrowed marks no later agent start asks again. |
| +// |
| +// Retry grants are the exception to "record, act on nothing" (Astra plan |
| +// 9): every grant in the host-retry log, and every legacy retry in the |
| +// narrowed log, is verified and applied here (applyRetries), so one written |
| +// before this agent started is not lost to the baseline. One spent or made |
| +// stale since clears nothing. |
| func restartSeed(ctx context.Context, reg *registry.Registry, store release.Store, dd string, mem *restartMemory) { |
| routes, err := reg.List() |
| if err != nil { |
| @@ -945,7 +951,12 @@ func restartSeed(ctx context.Context, reg *registry.Registry, store release.Stor |
| log.Printf("agent: retry baseline: %d apps unreadable: %v", len(grantErrs), grantErrs[0]) |
| } |
| applyRetries(routes, grants, mem) |
| - applyRetries(routes, scoped, mem) |
| + if mem.retryHost != nil { |
| + // A box with nothing that could clear a hold reads every legacy |
| + // retry at its start as history, silently, as it always has: its |
| + // line would only say, at every start, that nothing is held. |
| + 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 4b15e5bd7..bf1945832 100644 |
| --- a/internal/host/restartscope.go |
| +++ b/internal/host/restartscope.go |
| @@ -209,11 +209,11 @@ type retryRequest struct { |
| // 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 |
| -// clears nothing. |
| +// 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 clears nothing. |
| func retryRequests(routes []registry.Route, work []scopedRestart) []retryRequest { |
| var out []retryRequest |
| for _, w := range work { |
diff --git a/internal/host/rolloutdeclined.go b/internal/host/rolloutdeclined.go| index f58b31df9..0938e0807 100644 |
| --- a/internal/host/rolloutdeclined.go |
| +++ b/internal/host/rolloutdeclined.go |
| @@ -262,7 +262,8 @@ func (h *failureHolds) report() map[string]status.HostRollout { |
| } |
| out[rt.Host] = status.HostRollout{Account: acct, App: rt.App, Installed: rt.Version, Good: rt.GoodVersion, |
| Attempt: attempt, AttemptBuild: hd.by, HeldBy: hd.by, HeldEpoch: h.book.PublishedEpoch(acct, rt.App, hd.by, rt.Host), |
| - HeldBook: h.book.ID()} // the book the epoch counts in (final review round 1, finding 3) |
| + HeldBook: h.book.ID(), // the book the epoch counts in (final review round 1, finding 3) |
| + RetryV: status.HostRetryV} // grants ride the host-retry log (readiness round 2, Task 3) |
| } |
| return out |
| } |
diff --git a/internal/host/rolloutdeclined_test.go b/internal/host/rolloutdeclined_test.go| index e7b816e5a..c198144ad 100644 |
| --- a/internal/host/rolloutdeclined_test.go |
| +++ b/internal/host/rolloutdeclined_test.go |
| @@ -871,7 +871,7 @@ func TestDeclinedBoxProcessesAVerifiedSignedRetry(t *testing.T) { |
| } |
| hr, ok := declinedHostReport(t, host) |
| if !ok || hr.Account != acct || hr.App != "blog" || hr.Installed != "v2" || hr.HeldBy != "v2" || hr.HeldEpoch != epoch || hr.Attempt != status.AttemptDown || |
| - hr.HeldBook == "" || hr.HeldBook != heldHolds().book.ID() { |
| + hr.HeldBook == "" || hr.HeldBook != heldHolds().book.ID() || hr.RetryV != status.HostRetryV { |
| t.Fatalf("published %+v (%v), want v2 holding %s down at epoch %d of the box's book", hr, ok, host, epoch) |
| } |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 48a98a00c..e19776e74 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -635,7 +635,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers. |
| {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "d254edd637189a7e407f27c402ef1950d106ec373025c530d398ebb6397b6069"}, |
| {"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"}, |
| - {"internal/console/browserretry.go", "Server.handleInstanceRetry", "key-site", "lit:instances", "8a950e4bf96ffd30634f3ea2ea71a37519da8a68e9ca31a4d11e68587696019a"}, |
| + {"internal/console/browserretry.go", "Server.handleInstanceRetry", "key-site", "lit:instances", "e51c5e8d622a727c486ec24a0433212e08a168e983e2819aa10b16fa976c38f1"}, |
| {"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "91a2b3157bde9af832e8d95b17d0c384910229982945818f8b52f62dafddb342"}, |
| {"internal/console/channelmove.go", "Server.executePendingRouteChannel", "record-writer", "key:UpdateRecord", "b3b9a12213261a70b680ad5290957ebe7434eff633a73c2d80c75cc24d026d9a"}, |
| {"internal/console/claim_alias.go", "claimAliasStore.Put", "nonrecord", "def:Put,sel:Put", "a21b7cce328ebad9955211657ca8353adc7c2495b065fe09909b93c85ea7defb"}, |
diff --git a/internal/rollout/retrygrant_test.go b/internal/rollout/retrygrant_test.go| index ecd7dde3c..667cb823a 100644 |
| --- a/internal/rollout/retrygrant_test.go |
| +++ b/internal/rollout/retrygrant_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| // retry is the console's signed host retry as the box applies it: a grant |
| @@ -258,6 +259,12 @@ func TestStatusPublishesTheHeldEpoch(t *testing.T) { |
| if got := r.Hosts["b"]; got.HeldBook == "" || got.HeldBook != h.book.ID() || r.Hosts["a"].HeldBook != h.book.ID() { |
| t.Fatalf("held books %q and %q: want the book's identity %q on both arms", r.Hosts["a"].HeldBook, got.HeldBook, h.book.ID()) |
| } |
| + // Beside HeldBook, the retry protocol this box takes (readiness round 2, |
| + // Task 3): its grants ride the host-retry log, which the console checks |
| + // before it writes one. |
| + if r.Hosts["a"].RetryV != status.HostRetryV || r.Hosts["b"].RetryV != status.HostRetryV || status.HostRetryV != 1 { |
| + t.Fatalf("retry_v %d and %d: want %d on both arms", r.Hosts["a"].RetryV, r.Hosts["b"].RetryV, status.HostRetryV) |
| + } |
| if !h.m.RetryHost("b", r.Hosts["b"].HeldBy, r.Hosts["b"].HeldBook, r.Hosts["b"].HeldEpoch) { |
| t.Fatal("a grant signed from the status report cleared nothing") |
| } |
diff --git a/internal/rollout/status.go b/internal/rollout/status.go| index 7c6bde9b5..6d3bbe429 100644 |
| --- a/internal/rollout/status.go |
| +++ b/internal/rollout/status.go |
| @@ -88,7 +88,8 @@ func (m *Manager) Status() status.RolloutReport { |
| // (plan review finding 13), 0 while the book owes a |
| // save (the crash-epoch hard limit, rule 2). |
| hr.HeldEpoch = m.d.Book.PublishedEpoch(s.key.acct, s.key.app, rec.Build, host) |
| - hr.HeldBook = m.d.Book.ID() // final review round 1, finding 3 |
| + hr.HeldBook = m.d.Book.ID() // final review round 1, finding 3 |
| + hr.RetryV = status.HostRetryV // grants ride the host-retry log |
| if m.d.Book.HeldDown(s.key.acct, s.key.app, h.wantBuild, host) { |
| hr.Attempt = status.AttemptDown |
| } |
| @@ -110,6 +111,7 @@ func (m *Manager) Status() status.RolloutReport { |
| hr.HeldBy = mark.Build |
| hr.HeldEpoch = m.d.Book.PublishedEpoch(s.key.acct, s.key.app, mark.Build, host) |
| hr.HeldBook = m.d.Book.ID() |
| + hr.RetryV = status.HostRetryV |
| if rec, ok := m.d.Book.Lookup(s.key.acct, s.key.app, mark.Build); ok { |
| hr.Reason = reasonWord(rec.Reason) |
| } |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 375ffa728..2b35c9766 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -338,6 +338,12 @@ type HostRollout struct { |
| // only for its own book (final review round 1, finding 3). Absent from |
| // an older box: a grant signed without it clears nothing. |
| HeldBook string `json:"held_book,omitempty"` |
| + // RetryV is the `carlos instances retry` protocol this box takes, set |
| + // beside HeldBook: HostRetryV is a root-signed, book-bound grant in the |
| + // host-retry log (release.HostRetryKey). Absent (0) from a box built |
| + // before that log, which reads no grant there, so the console writes |
| + // it none and says the box is too old (readiness round 2, Task 3). |
| + RetryV int `json:"retry_v,omitempty"` |
| // Transient is set when the activator owns the host's process but has no |
| // current generation for it: the process is starting or stopping, so the |
| // instance is neither asleep nor awake and no serving build is reported. |
| @@ -378,6 +384,11 @@ const ( |
| AttemptDown = "down" |
| ) |
| +// HostRetryV is HostRollout.RetryV for a box that reads root-signed, |
| +// book-bound retry grants from the host-retry log and honours a legacy |
| +// signed one in the restart-scope log with the same checks. |
| +const HostRetryV = 1 |
| + |
| // The reason words: the closed vocabulary a member-facing event or status |
| // line may give for a failure. internal/rollout maps every diagnostic text |
| // onto one of them (reasonWord). |