console: hotfix stays inside the pipeline; rollback meets the same per-channel rules
Two adjacent-door authority gaps found in review of the previous commit:
- handleAPIPromote honoured `hotfix` for a target OUTSIDE the app's pipeline.
A pipelined app's `stable` carries no pipeline rules, so a hotfixed
promote skipped the order check and wrote the stable pointer from a
bearer token — a target that has been an unconditional 403 at this door
since it existed. The flag is now honoured only for a channel the
pipeline names; the stable rung outside a pipeline keeps the
byte-identical legacy refusal, and every other out-of-pipeline target
falls through with the flag CLEARED so PipelinePromotionAllowed's own
refusal answers it. canary/* stays always-allowed (spec §2).
- The rollback doors had no per-channel gate at all: a channel carrying
{passkey, promote_approvals} could be rolled back by any member or token.
Both doors now meet the same rules a promotion onto that channel meets —
403 finish-in-console / propose over the API, requireFreshAuth LAST then
propose in the browser. Proposals pin cur.History[0] and are hotfix-
flagged (a rollback is out-of-order by nature). Legacy apps unchanged,
and the API's unconditional stable refusal stays exactly where it was.
executePending now restates its own preconditions as refusals rather than
assumptions — same account and app, quorum actually met — because Task 9
adds a caller and this is the one place a gated write happens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+348
−12
internal/console/apicli.go+75 −11internal/console/pendingflow_test.go+221 −0internal/console/pendinghandlers.go+17 −0internal/console/server.go+35 −1
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 93f9ba32c..0b8886c8d 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -649,26 +649,39 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| // non-member meets the byte-identical 404 in apiAccountApp before any of |
| // this is mentioned. Below it, membership (already proved) is the gate; |
| // PromoteOnLadder enforces the order itself. |
| - if pipe == nil { |
| - if rung, _ := release.Rung(channel); rung == release.RungStable { |
| + // |
| + // hotfix is honoured only where the pipeline can judge what it bypasses: |
| + // a target INSIDE the app's declared order. Cleared for anything outside |
| + // one, and the reason is a hole this door had for exactly one commit: a |
| + // pipelined app's `stable` is not in its pipeline, so it carried no |
| + // rules, and a hotfixed promote onto it would have skipped the order |
| + // check and written the stable pointer FROM A BEARER TOKEN — a target |
| + // that has been an unconditional 403 here since this door existed. |
| + // Bypassing an order the pipeline never declared is not a bypass, it is |
| + // an escape from the pipeline; the sanctioned escape hatch is a |
| + // direct-bucket promote with the root key in hand (spec §6). |
| + hotfix := req.Hotfix |
| + rung, _ := release.Rung(channel) |
| + switch { |
| + case pipe == nil: |
| + if rung == release.RungStable { |
| writeJSON(w, http.StatusForbidden, map[string]string{ |
| "error": "stable needs a passkey — finish in the console", |
| "console_url": s.appConsoleURL(acct, app), |
| }) |
| return |
| } |
| - } else { |
| + case pipe.Index(channel) >= 0: |
| switch rules := channelRules(pipe, channel); { |
| case rules.PromoteApprovals >= 1: |
| // Order is checked HERE, before a proposal that could never |
| // execute goes out for signatures — the same refusal, at the |
| // same 409, PromoteOnLadder would give below. This is the first |
| - // line in the handler that reads the hotfix flag, and it is |
| + // line in the handler that acts on the hotfix flag, and it is |
| // strictly after the rules decision this switch is making. |
| - // pipe is non-nil in this arm by construction (channelRules |
| - // answers the zero rules for nil), which matters because |
| - // VersionStage dereferences the pipeline it is handed. |
| - if !req.Hotfix { |
| + // pipe is non-nil in this arm by construction, which matters |
| + // because VersionStage dereferences the pipeline it is handed. |
| + if !hotfix { |
| stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe) |
| if serr != nil { |
| log.Printf("console: api promote %s/%s: stage: %v", acct.Sqid, app, serr) |
| @@ -681,7 +694,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| s.proposeOverAPI(w, acct, app, kindPromote, |
| - promotePayload{Version: version, Channel: channel, Hotfix: req.Hotfix}, |
| + promotePayload{Version: version, Channel: channel, Hotfix: hotfix}, |
| rules.PromoteApprovals, addr) |
| return |
| case rules.Passkey: |
| @@ -696,9 +709,27 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| + default: |
| + // A pipelined app, aimed at a channel its pipeline does not name. |
| + // The stable RUNG keeps the byte-identical legacy refusal it has |
| + // always had here — an app-defined pipeline is a reason to govern |
| + // its own channels, never a way to reach one it left out. Everything |
| + // else falls through with hotfix cleared, so |
| + // PipelinePromotionAllowed's own refusal (409, naming the app's |
| + // channels) answers it. canary/* lands here too and is deliberately |
| + // NOT refused: spec §2 keeps it outside every pipeline and always |
| + // allowed, and PipelinePromotionAllowed waves it through below. |
| + if rung == release.RungStable { |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": s.appConsoleURL(acct, app), |
| + }) |
| + return |
| + } |
| + hotfix = false |
| } |
| res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, |
| - release.PromoteOptions{Actor: addr, Hotfix: req.Hotfix}) |
| + release.PromoteOptions{Actor: addr, Hotfix: hotfix}) |
| if err != nil { |
| log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err) |
| // The ladder's own refusal text names the rungs — as useful over the |
| @@ -774,6 +805,16 @@ func (s *Server) channelHeld(ctx context.Context, p *release.Pipeline, acct, app |
| // SAME byte-identical 403 handleAPIPromote gives stable — before |
| // release.Rollback is ever called, and before the channel's current pointer |
| // is even read, so the refusal cannot vary with what that pointer contains. |
| +// That refusal stays UNCONDITIONAL, pipeline or no pipeline: this door only |
| +// ever adds gates below, never removes the one it already had. |
| +// |
| +// A PIPELINED channel's own rules gate it too (release pipelines v2 §2), for |
| +// the reason above stated the other way round: a rollback is a pointer write, |
| +// and what it puts in front of every user of the app is a build the channel's |
| +// rules exist to govern. A channel demanding a passkey refuses here; one |
| +// demanding approvals proposes, pinned to the version this rollback would |
| +// land on and flagged hotfix — a rollback is out-of-order by nature, so the |
| +// order re-check at execution is the one check it must not meet. |
| func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| addr, acct, app, ok := s.apiAccountApp(w, r) |
| if !ok { |
| @@ -810,12 +851,18 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| if rung, _ := release.Rung(channel); rung == release.RungStable { |
| writeJSON(w, http.StatusForbidden, map[string]string{ |
| "error": "stable needs a passkey — finish in the console", |
| - "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| + "console_url": s.appConsoleURL(acct, app), |
| }) |
| return |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| + pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if perr != nil { |
| + log.Printf("console: api rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| + jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app) |
| + return |
| + } |
| cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| if err != nil { |
| jsonError(w, http.StatusNotFound, "could not read channel "+channel) |
| @@ -826,6 +873,23 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| target := cur.History[0] |
| + // The channel's declared rules, applied to the version this would land |
| + // on. Read after the pointer because a proposal must PIN that version; |
| + // nothing above it varies with what the pointer contains, so the |
| + // stable refusal is still answered before the bucket is touched. |
| + switch rules := channelRules(pipe, channel); { |
| + case rules.PromoteApprovals >= 1: |
| + s.proposeOverAPI(w, acct, app, kindPromote, |
| + promotePayload{Version: target, Channel: channel, Hotfix: true}, |
| + rules.PromoteApprovals, addr) |
| + return |
| + case rules.Passkey: |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": channel + " needs a passkey — finish in the console", |
| + "console_url": s.appConsoleURL(acct, app), |
| + }) |
| + return |
| + } |
| if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| log.Printf("console: api rollback %s %s: %v", app, channel, err) |
| jsonError(w, http.StatusConflict, err.Error()) |
diff --git a/internal/console/pendingflow_test.go b/internal/console/pendingflow_test.go| index 7d9789c75..b2ea2362c 100644 |
| --- a/internal/console/pendingflow_test.go |
| +++ b/internal/console/pendingflow_test.go |
| @@ -335,6 +335,227 @@ func TestPolicyEditPinsSetAt(t *testing.T) { |
| } |
| } |
| +// TestHotfixOnlyReachesPipelineChannels closes the hole review found: on a |
| +// PIPELINED app, `stable` is not one of its channels, so it carried no rules |
| +// — and a hotfixed promote would have skipped the order check and written the |
| +// stable pointer from a bearer token, a target that has been an unconditional |
| +// 403 at this door since it existed. The flag now applies only INSIDE the |
| +// app's declared order; outside it there is no order to bypass, only a |
| +// pipeline to escape, and the sanctioned escape is a direct-bucket promote |
| +// with the root key (spec §6). |
| +func TestHotfixOnlyReachesPipelineChannels(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "beta"}, |
| + release.PipelineChannel{Name: "production"}) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-scope") |
| + promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" |
| + |
| + // The stable RUNG, which this pipeline does not name: the byte-identical |
| + // legacy refusal, hotfix or no hotfix. |
| + plain, plainBody := postRaw(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "stable"}, token) |
| + hot, hotBody := postRaw(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token) |
| + if plain != http.StatusForbidden || hot != http.StatusForbidden { |
| + t.Fatalf("stable on a pipelined app = plain %d, hotfixed %d, want both 403", plain, hot) |
| + } |
| + if plainBody != hotBody { |
| + t.Fatalf("the hotfix flag changed the stable refusal:\nplain: %q\nhot: %q", plainBody, hotBody) |
| + } |
| + if !strings.Contains(hotBody, "stable needs a passkey — finish in the console") { |
| + t.Fatalf("stable refusal = %q, want today's exact sentence", hotBody) |
| + } |
| + channelAbsent(t, fx, "widget", "stable") |
| + |
| + // Any other channel the pipeline does not name: refused, and the refusal |
| + // names the channels it does. |
| + status, out := postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "wip", "hotfix": true}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("hotfixed promote onto an out-of-pipeline channel = %d %v, want 409", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "not one of this app's channels") { |
| + t.Fatalf("out-of-pipeline refusal = %v, want it to name the pipeline's channels", out["error"]) |
| + } |
| + channelAbsent(t, fx, "widget", "wip") |
| + |
| + // canary/* is outside every pipeline BY DESIGN (spec §2) and stays |
| + // always-allowed — the fleet runs on canary channels. |
| + status, out = postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| + t.Fatalf("canary promote on a pipelined app = %d %v, want it still allowed", status, out) |
| + } |
| + |
| + // And INSIDE the pipeline the flag does what it is for: abc1234 sits on |
| + // edge, production is entered from beta, and the flag carries it there. |
| + status, out = postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token) |
| + if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| + t.Fatalf("hotfixed in-pipeline promote = %d %v, want 200 promoted", status, out) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { |
| + t.Fatalf("production serves %q, want abc1234", got.Version) |
| + } |
| +} |
| + |
| +// TestRollbackMeetsChannelRules: a rollback is a pointer write, and what it |
| +// puts in front of every user of the app is a build the channel's rules exist |
| +// to govern. Review found a guarded channel could be rolled back by any |
| +// member or bearer token with no gate at all. |
| +func TestRollbackMeetsChannelRules(t *testing.T) { |
| + t.Run("api", func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "widget", "v1") |
| + shipVersion(t, fx, "widget", "v2") |
| + seedChannel(t, fx, "widget", "production", "v1") |
| + seedChannel(t, fx, "widget", "production", "v2") |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-rules") |
| + rollbackURL := fx.ts.URL + "/api/cli/apps/bab/widget/rollback" |
| + |
| + status, out := postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("rollback of a passkey channel = %d %v, want 403", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "production needs a passkey") { |
| + t.Fatalf("rollback refusal = %v, want it to name the channel and the passkey", out["error"]) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { |
| + t.Fatalf("production serves %q after a refused rollback, want v2", got.Version) |
| + } |
| + |
| + // Approvals instead: the door proposes, pinned to the version the |
| + // rollback would land on. |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + status, out = postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("rollback of an approvals channel = %d %v, want 202", status, out) |
| + } |
| + if str(t, out, "result") != "proposed" || num(t, out, "needed") != 2 { |
| + t.Fatalf("rollback proposal = %v, want a proposal needing 2", out) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { |
| + t.Fatalf("production serves %q after a proposal, want v2 — nothing moved", got.Version) |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil || len(pending) != 1 { |
| + t.Fatalf("pending = %v, %v, want one", pending, err) |
| + } |
| + if !strings.Contains(pendingSummary(pending[0]), "v1") { |
| + t.Fatalf("summary %q does not pin the version the rollback lands on", pendingSummary(pending[0])) |
| + } |
| + }) |
| + |
| + t.Run("legacy apps unchanged", func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "v1") |
| + shipVersion(t, fx, "titogo", "v2") |
| + seedChannel(t, fx, "titogo", "beta", "v1") |
| + seedChannel(t, fx, "titogo", "beta", "v2") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-legacy") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", |
| + map[string]any{"channel": "beta"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("legacy rollback = %d %v, want 200", status, out) |
| + } |
| + if got := fx.channelVersion(t, "titogo", "beta"); got.Version != "v1" { |
| + t.Fatalf("beta serves %q after a legacy rollback, want v1", got.Version) |
| + } |
| + }) |
| + |
| + t.Run("browser proposes and quorum executes", func(t *testing.T) { |
| + fx, c, csrf := memberFixture(t) // paul owns, kim is a member |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "widget", "v1") |
| + shipVersion(t, fx, "widget", "v2") |
| + seedChannel(t, fx, "widget", "production", "v1") |
| + seedChannel(t, fx, "widget", "production", "v2") |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + |
| + status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"), |
| + url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("browser rollback proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { |
| + t.Fatalf("production serves %q after a proposal, want v2", got.Version) |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil || len(pending) != 1 || len(pending[0].Confirms) != 1 { |
| + t.Fatalf("pending = %v, %v, want one at 1 of 2", pending, err) |
| + } |
| + |
| + kim := newBrowser(t) |
| + fx.setUser("kim@keymail.dev") |
| + signUp(t, fx, kim, "code-kim-rollback") |
| + kimCSRF := sessionToken(t, fx, kim) |
| + status, loc, body = postGated(t, fx, kim, |
| + fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("confirmation = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "v1" { |
| + t.Fatalf("production serves %q after quorum, want the rolled-back v1", got.Version) |
| + } |
| + }) |
| +} |
| + |
| +// TestExecutePendingRefusesItsOwnPreconditions: executePending is billed as |
| +// the one place a gated write happens, and Task 9 adds a caller. Neither an |
| +// under-confirmed change nor one belonging to another app may write, and |
| +// neither may panic — a bug in a caller has to refuse, not take the console |
| +// down or land an unapproved promotion. |
| +func TestExecutePendingRefusesItsOwnPreconditions(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production"}) |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("account: %v, %v", acct, err) |
| + } |
| + payload := []byte(`{"version":"abc1234","channel":"production"}`) |
| + ctx := context.Background() |
| + |
| + short, err := fx.st.ProposeChange(OpsAccountID, "widget", "promote", payload, 2, |
| + "paul@keymail.dev", true, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", short); err == nil { |
| + t.Fatalf("executing a 1-of-2 change succeeded (%q), want a refusal", msg) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| + |
| + elsewhere, err := fx.st.ProposeChange(OpsAccountID, "kass", "promote", payload, 1, |
| + "paul@keymail.dev", true, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", elsewhere); err == nil { |
| + t.Fatalf("executing another app's change succeeded (%q), want a refusal", msg) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| +} |
| + |
| // TestFastTrackProposesWhenGuarded: change_approvals covers overriding a |
| // bake as well as editing a channel's entry (spec §2), and the waiver it |
| // gates is pinned to the build it was proposed for. |
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go| index 0c8bc9a4f..8ec00bce8 100644 |
| --- a/internal/console/pendinghandlers.go |
| +++ b/internal/console/pendinghandlers.go |
| @@ -230,7 +230,24 @@ func policyProposal(cur *release.Pipeline, next []release.PipelineChannel) polic |
| // An error return means nothing landed and the change is still pending: a |
| // caller must not mark it executed, and the member who confirmed it is told |
| // why rather than left believing it went through. |
| +// |
| +// The first two checks are this function's OWN preconditions, restated rather |
| +// than assumed. Every caller today establishes them — quorum via |
| +// ConfirmChange's `met`, the app via pendingScoped's 404 — but this is billed |
| +// as the one place a gated write happens, and a future caller (the console's |
| +// own pipeline page, Task 9) that got either wrong would otherwise turn a |
| +// mistake into an unapproved signed write. They are errors, never panics: a |
| +// bug here must refuse, not take the console down. |
| func (s *Server) executePending(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| + if pc.AccountID != acct.ID || pc.App != app { |
| + log.Printf("console: execute %s %s: change belongs to %d/%q, not %d/%q", |
| + pc.Kind, pc.ID, pc.AccountID, pc.App, acct.ID, app) |
| + return "", errors.New("that change belongs to a different app") |
| + } |
| + if len(pc.Confirms) < pc.Quorum { |
| + log.Printf("console: execute %s %s: %d of %d confirmations", pc.Kind, pc.ID, len(pc.Confirms), pc.Quorum) |
| + return "", fmt.Errorf("that change has %d of %d confirmations", len(pc.Confirms), pc.Quorum) |
| + } |
| if s.cfg.Key == nil { |
| return "", errors.New("no signing key on this host — the console is running read-only") |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index b74281328..b542d6f95 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -2823,7 +2823,10 @@ func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw, |
| return s.requireFreshAuth(w, r, addr, raw) |
| } |
| -// handleRollback swaps a channel back to its previous version. |
| +// handleRollback swaps a channel back to its previous version. On a pipelined |
| +// channel it meets that channel's own rules first (see the gate below the |
| +// confirm card): the same passkey and the same quorum a promotion onto it |
| +// would meet, because it is the same pointer and the same blast radius. |
| func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| if !ok { |
| @@ -2875,6 +2878,37 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| back("err", "Channel "+channel+" changed while you were confirming — review the rollback again.") |
| return |
| } |
| + // A pipelined channel's own rules gate a rollback exactly as they gate a |
| + // promotion (release pipelines v2 §2): a rollback is a pointer write, and |
| + // the build it puts in front of every user of the app is one the |
| + // channel's rules exist to govern. requireFreshAuth stays LAST, after the |
| + // confirm card and the pin — and a channel with no rules, or an app with |
| + // no pipeline, behaves exactly as it did. |
| + pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if perr != nil { |
| + log.Printf("console: rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| + back("err", "Could not read the pipeline for "+app+". Nothing has been changed.") |
| + return |
| + } |
| + if rules := channelRules(pipe, channel); rules.Passkey || rules.PromoteApprovals >= 1 { |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + if rules.PromoteApprovals >= 1 { |
| + // Pinned to the version this rollback lands on, and flagged |
| + // hotfix: a rollback is out-of-order by nature, so the order |
| + // re-check at execution is the one check it must not meet. The |
| + // pointer ends up serving the pinned build either way; what |
| + // differs from release.Rollback is the shape of the trail it |
| + // leaves — a promotion of the older build, confirmed by a |
| + // quorum, rather than a history pop. |
| + kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote, |
| + promotePayload{Version: target, Channel: channel, Hotfix: true}, |
| + rules.PromoteApprovals, addr) |
| + back(kind, msg) |
| + return |
| + } |
| + } |
| if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| log.Printf("console: rollback %s %s: %v", app, channel, err) |
| back("err", "Rollback failed: "+err.Error()) |