| 1 | // The console UI half of release pipelines v2 |
| 2 | // (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md): the |
| 3 | // pending-changes card every app page carries (PendingChangeView, |
| 4 | // pendingChangeViews) and the pipeline settings page, GET/POST |
| 5 | // /a/{sqid}/apps/{app}/pipeline (pipelinepage.go's own name). |
| 6 | // |
| 7 | // The settings POST is deliberately NOT a second copy of the API PUT's |
| 8 | // guarded-write logic (apipipeline.go's handleAPIPipelinePut): both doors |
| 9 | // share guardedEdit's decision (pending.go's rule for what counts as |
| 10 | // touching a guarded channel) and writePipeline's write tail (SetPipeline, |
| 11 | // the old-binary bake fallback, the audit row, the serial bump) below. What |
| 12 | // differs, and stays separate, is how each door turns a guarded edit into a |
| 13 | // pending change — proposeOverAPI's zero-confirmation bearer shape for the |
| 14 | // CLI, proposeThenExecute's passkey-backed shape here, called only after |
| 15 | // requireFreshAuth has actually run. |
| 16 | package console |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "crypto/subtle" |
| 21 | "fmt" |
| 22 | "log" |
| 23 | "net/http" |
| 24 | "strconv" |
| 25 | "strings" |
| 26 | |
| 27 | "github.com/carlosframework/platform/internal/release" |
| 28 | ) |
| 29 | |
| 30 | // ---- the pending-changes card ------------------------------------------- |
| 31 | |
| 32 | // PendingChangeView is one row of the app page's pending-changes card: the |
| 33 | // human-legible summary (pendingSummary, pendinghandlers.go) and quorum |
| 34 | // progress a viewer needs to decide whether to confirm or reject it. It |
| 35 | // carries nothing pendinghandlers.go's own doors don't already recompute |
| 36 | // from the id on the confirm/reject POST — this is display only. |
| 37 | type PendingChangeView struct { |
| 38 | ID string |
| 39 | Kind string |
| 40 | Summary string |
| 41 | Confirmed int |
| 42 | Needed int |
| 43 | } |
| 44 | |
| 45 | // pendingChangeViews folds a store listing into the card's rows, oldest |
| 46 | // first — PendingChangesFor's own order, unchanged, so the card and the |
| 47 | // CLI's `carlos pipeline` listing (Task 10) read the same queue the same way. |
| 48 | // |
| 49 | // The two numbers are the ones EXECUTION will use, not the ones the row was |
| 50 | // written with (PR #258 review, I3): Needed is max(stored quorum, the target |
| 51 | // channel's current rule) and Confirmed counts only confirmations from |
| 52 | // current members. Showing the raw row instead let a change sit at "2 of 2" |
| 53 | // with a live Confirm button and refuse every time it was pressed — a card |
| 54 | // that says a change is ready when it is not is worse than no card. |
| 55 | // |
| 56 | // pipe is the app's pipeline, read ONCE by the caller for the whole listing |
| 57 | // (nil is fine — a legacy app, or a read that failed: the tally then degrades |
| 58 | // to the stored quorum, which is exactly what it was before this existed). |
| 59 | func (s *Server) pendingChangeViews(pcs []PendingChange, pipe *release.Pipeline) []PendingChangeView { |
| 60 | out := make([]PendingChangeView, len(pcs)) |
| 61 | for i, pc := range pcs { |
| 62 | confirmed, needed := s.pendingTally(pc, pipe) |
| 63 | out[i] = PendingChangeView{ |
| 64 | ID: pc.ID, Kind: pc.Kind, Summary: pendingSummary(pc), |
| 65 | Confirmed: confirmed, Needed: needed, |
| 66 | } |
| 67 | } |
| 68 | return out |
| 69 | } |
| 70 | |
| 71 | // ---- the settings page's form rows --------------------------------------- |
| 72 | |
| 73 | // PipelineFormRow is one editable row of the pipeline settings form — a |
| 74 | // draft of a release.PipelineChannel, indexed ch.<i>.name / ch.<i>.bake / |
| 75 | // ch.<i>.passkey / ch.<i>.promote_approvals / ch.<i>.change_approvals on the |
| 76 | // wire, matching the brief's field-naming exactly. |
| 77 | type PipelineFormRow struct { |
| 78 | Name string |
| 79 | Bake string |
| 80 | Passkey bool |
| 81 | PromoteApprovals int |
| 82 | ChangeApprovals int |
| 83 | } |
| 84 | |
| 85 | // pipelineRowsFromChannels drafts the form from a real (or default) channel |
| 86 | // list — GET's starting point, and what a save error re-renders on top of. |
| 87 | func pipelineRowsFromChannels(channels []release.PipelineChannel) []PipelineFormRow { |
| 88 | rows := make([]PipelineFormRow, len(channels)) |
| 89 | for i, c := range channels { |
| 90 | rows[i] = PipelineFormRow{ |
| 91 | Name: c.Name, Bake: c.Bake, Passkey: c.Rules.Passkey, |
| 92 | PromoteApprovals: c.Rules.PromoteApprovals, ChangeApprovals: c.Rules.ChangeApprovals, |
| 93 | } |
| 94 | } |
| 95 | return rows |
| 96 | } |
| 97 | |
| 98 | // pipelineChannelsFromRows is pipelineRowsFromChannels backwards, for the |
| 99 | // POST that writes the draft. A row left with a blank name — exactly what |
| 100 | // "Add channel" produces until it's named — is dropped silently rather than |
| 101 | // refused: SetPipeline's own validation is the one place a genuinely bad |
| 102 | // name gets a real error, so a still-blank scratch row never blocks it. |
| 103 | func pipelineChannelsFromRows(rows []PipelineFormRow) []release.PipelineChannel { |
| 104 | out := make([]release.PipelineChannel, 0, len(rows)) |
| 105 | for _, r := range rows { |
| 106 | name := strings.TrimSpace(r.Name) |
| 107 | if name == "" { |
| 108 | continue |
| 109 | } |
| 110 | out = append(out, release.PipelineChannel{ |
| 111 | Name: name, Bake: strings.TrimSpace(r.Bake), |
| 112 | Rules: release.ChannelRules{ |
| 113 | Passkey: r.Passkey, PromoteApprovals: r.PromoteApprovals, ChangeApprovals: r.ChangeApprovals, |
| 114 | }, |
| 115 | }) |
| 116 | } |
| 117 | return out |
| 118 | } |
| 119 | |
| 120 | // pipelineIsDefault reports whether rows is still exactly the untouched |
| 121 | // one-channel default (release.DefaultPipelineChannels()) a fresh app is |
| 122 | // created with — the state in which the two starter templates below are |
| 123 | // worth offering at all; past it, they'd silently discard an edit. |
| 124 | func pipelineIsDefault(rows []PipelineFormRow) bool { |
| 125 | return len(rows) == 1 && rows[0] == PipelineFormRow{Name: "edge"} |
| 126 | } |
| 127 | |
| 128 | // edgeProductionTemplate is spec §5's first starter: a single production |
| 129 | // channel behind a passkey. The CONTROLLER RULING this brief carries: any |
| 130 | // template that sets a rule on a channel also sets change_approvals >= 1 on |
| 131 | // it — a passkey any member can strip in one PUT is decoration, and a solo |
| 132 | // account still clears it (1 <= its own member count) with one console |
| 133 | // confirmation of their own passkey. |
| 134 | func edgeProductionTemplate() []PipelineFormRow { |
| 135 | return []PipelineFormRow{ |
| 136 | {Name: "edge"}, |
| 137 | {Name: "production", Bake: "24h", Passkey: true, ChangeApprovals: 1}, |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // fullLadderTemplate is spec §5's second starter: today's edge/beta/stable |
| 142 | // defaults, reproduced as a pipeline — beta bakes 24h with no rule of its |
| 143 | // own, stable bakes 72h behind a passkey and the same change_approvals |
| 144 | // ruling as the template above. |
| 145 | func fullLadderTemplate() []PipelineFormRow { |
| 146 | return []PipelineFormRow{ |
| 147 | {Name: "edge"}, |
| 148 | {Name: "beta", Bake: "24h"}, |
| 149 | {Name: "stable", Bake: "72h", Passkey: true, ChangeApprovals: 1}, |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // parsePipelineRows reads the submitted ch.<i>.* fields back into rows, in |
| 154 | // order, stopping at the first index whose name field is simply absent — |
| 155 | // every row the settings template rendered posts a ch.<i>.name field (even |
| 156 | // an empty one, for a still-unnamed scratch row), so absence is genuinely |
| 157 | // "no more rows" rather than "this one happens to be blank". |
| 158 | func parsePipelineRows(r *http.Request) []PipelineFormRow { |
| 159 | r.ParseForm() |
| 160 | var rows []PipelineFormRow |
| 161 | for i := 0; ; i++ { |
| 162 | prefix := "ch." + strconv.Itoa(i) + "." |
| 163 | if _, ok := r.PostForm[prefix+"name"]; !ok { |
| 164 | break |
| 165 | } |
| 166 | rows = append(rows, PipelineFormRow{ |
| 167 | Name: r.PostFormValue(prefix + "name"), |
| 168 | Bake: r.PostFormValue(prefix + "bake"), |
| 169 | Passkey: r.PostFormValue(prefix+"passkey") != "", |
| 170 | PromoteApprovals: nonNegativeAtoi(r.PostFormValue(prefix + "promote_approvals")), |
| 171 | ChangeApprovals: nonNegativeAtoi(r.PostFormValue(prefix + "change_approvals")), |
| 172 | }) |
| 173 | } |
| 174 | return rows |
| 175 | } |
| 176 | |
| 177 | // nonNegativeAtoi parses a form field as a non-negative count, treating |
| 178 | // anything unparsable or negative as 0 rather than refusing the request — |
| 179 | // the same forgiving posture the rest of this form takes toward a |
| 180 | // half-edited row; SetPipeline's own validation still refuses a genuinely |
| 181 | // negative value if one somehow reaches it another way. |
| 182 | func nonNegativeAtoi(s string) int { |
| 183 | n, err := strconv.Atoi(strings.TrimSpace(s)) |
| 184 | if err != nil || n < 0 { |
| 185 | return 0 |
| 186 | } |
| 187 | return n |
| 188 | } |
| 189 | |
| 190 | // ---- the shared write tail ------------------------------------------------ |
| 191 | |
| 192 | // writePipeline lands an UNGUARDED edit — the one place either the API PUT |
| 193 | // or this settings POST actually writes: SetPipeline, the old-binary bake |
| 194 | // fallback (ensureProductionForBakes, apipipeline.go), the audit row and the |
| 195 | // serial bump. via names the door for the audit row's own vocabulary |
| 196 | // (pipelineAuditDetail), exactly as handleAPIPipelinePut's inline tail did |
| 197 | // before this was extracted for both callers to share. Box-consumed |
| 198 | // (carlos-deploy §3): boxes read pipeline.json on the same fast pass channel |
| 199 | // pointers and config already ride, which is what the serial bump is for. |
| 200 | func (s *Server) writePipeline(ctx context.Context, acct *Account, app string, channels []release.PipelineChannel, actor, via string) (*release.Pipeline, error) { |
| 201 | set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channels, actor) |
| 202 | if err != nil { |
| 203 | return nil, err |
| 204 | } |
| 205 | s.ensureProductionForBakes(ctx, acct, app, set, actor) |
| 206 | s.audit(acct, app, actor, "pipeline", pipelineAuditDetail(set, via)) |
| 207 | s.bumpSerial(ctx) |
| 208 | return set, nil |
| 209 | } |
| 210 | |
| 211 | // ---- the settings page: GET -------------------------------------------- |
| 212 | |
| 213 | // handlePipelineSettings renders the current (or default) pipeline as an |
| 214 | // editable form. Read-only, so membership is the whole gate — same as the |
| 215 | // rest of the app's settings tab. |
| 216 | func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request) { |
| 217 | p, acct, ok := s.appPage(w, r, "settings") |
| 218 | if !ok { |
| 219 | return |
| 220 | } |
| 221 | s.handlePipelineSettingsPage(w, r, p, acct) |
| 222 | } |
| 223 | |
| 224 | func (s *Server) handlePipelineSettingsPage(w http.ResponseWriter, r *http.Request, p page, acct *Account) { |
| 225 | setAppSettingsSection(&p, "pipeline") |
| 226 | ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) |
| 227 | defer cancel() |
| 228 | pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, p.AppName) |
| 229 | channels := release.DefaultPipelineChannels() |
| 230 | if err != nil { |
| 231 | log.Printf("console: pipeline settings %s/%s: %v", acct.Sqid, p.AppName, err) |
| 232 | p.FlashErr = "Could not read the pipeline — showing the default. Try again to see the real one." |
| 233 | } else if pipe != nil { |
| 234 | channels = pipe.Channels |
| 235 | } |
| 236 | p.PipelineRows = pipelineRowsFromChannels(channels) |
| 237 | p.PipelineIsDefault = pipelineIsDefault(p.PipelineRows) |
| 238 | s.render(w, http.StatusOK, "pipeline_settings", p) |
| 239 | } |
| 240 | |
| 241 | // renderPipelineSettings re-renders the settings form on top of a page |
| 242 | // already built by appPage — used for the draft-only actions (add/remove |
| 243 | // row, load a template) and for a save that failed, so the human's |
| 244 | // in-progress edit is never silently thrown away. |
| 245 | func (s *Server) renderPipelineSettings(w http.ResponseWriter, p page, rows []PipelineFormRow, errMsg string) { |
| 246 | setAppSettingsSection(&p, "pipeline") |
| 247 | p.PipelineRows = rows |
| 248 | p.PipelineIsDefault = pipelineIsDefault(rows) |
| 249 | if errMsg != "" { |
| 250 | p.FlashErr = errMsg |
| 251 | } |
| 252 | s.render(w, http.StatusOK, "pipeline_settings", p) |
| 253 | } |
| 254 | |
| 255 | // ---- the settings page: POST --------------------------------------------- |
| 256 | |
| 257 | // handlePipelineSettingsSave is the settings form's one door: draft actions |
| 258 | // (add/remove a row, load a starter template) re-render the form with |
| 259 | // nothing written; "save" carries the edit through the same guarded path |
| 260 | // the API PUT takes (writePipeline directly when nothing is guarded, |
| 261 | // proposeThenExecute's passkey-backed shape when a touched channel demands |
| 262 | // a quorum), reusing guardedEdit's decision rather than a second copy of it. |
| 263 | // |
| 264 | // Gate order: session + membership (appPage → accountApp, byte-identical |
| 265 | // 404 for a non-member) → CSRF → [the cheap, non-revealing refusals: the |
| 266 | // draft actions, the signing-key check, the read-current-pipeline and |
| 267 | // member-ceiling checks below] → requireFreshAuth LAST, and only for the |
| 268 | // one branch that actually writes — a policy edit is account authority in |
| 269 | // the same register as the API PUT (apipipeline.go's own doc comment), and |
| 270 | // ANY member may reach it, matching the API door's human-any-role rule. |
| 271 | func (s *Server) handlePipelineSettingsSave(w http.ResponseWriter, r *http.Request) { |
| 272 | p, acct, ok := s.appPage(w, r, "settings") |
| 273 | if !ok { |
| 274 | return |
| 275 | } |
| 276 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(p.CSRF)) != 1 { |
| 277 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 278 | return |
| 279 | } |
| 280 | addr, raw, app := p.Address, p.CSRF, p.AppName |
| 281 | rows := parsePipelineRows(r) |
| 282 | |
| 283 | switch action := r.FormValue("action"); { |
| 284 | case action == "add": |
| 285 | rows = append(rows, PipelineFormRow{}) |
| 286 | s.renderPipelineSettings(w, p, rows, "") |
| 287 | return |
| 288 | case strings.HasPrefix(action, "remove-"): |
| 289 | if idx, err := strconv.Atoi(strings.TrimPrefix(action, "remove-")); err == nil && idx >= 0 && idx < len(rows) { |
| 290 | rows = append(rows[:idx], rows[idx+1:]...) |
| 291 | } |
| 292 | s.renderPipelineSettings(w, p, rows, "") |
| 293 | return |
| 294 | case action == "template-edge-production": |
| 295 | s.renderPipelineSettings(w, p, edgeProductionTemplate(), "") |
| 296 | return |
| 297 | case action == "template-full-ladder": |
| 298 | s.renderPipelineSettings(w, p, fullLadderTemplate(), "") |
| 299 | return |
| 300 | } |
| 301 | |
| 302 | if s.cfg.Key == nil { |
| 303 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Pipeline edits disabled", |
| 304 | "No signing key on this host — the console is running read-only.") |
| 305 | return |
| 306 | } |
| 307 | channels := pipelineChannelsFromRows(rows) |
| 308 | ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) |
| 309 | defer cancel() |
| 310 | cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 311 | if err != nil { |
| 312 | log.Printf("console: pipeline settings save %s/%s: read current: %v", acct.Sqid, app, err) |
| 313 | s.renderPipelineSettings(w, p, rows, "Could not read the current pipeline — nothing was changed. Try again.") |
| 314 | return |
| 315 | } |
| 316 | members, err := s.store.Members(acct.ID) |
| 317 | if err != nil { |
| 318 | log.Printf("console: pipeline settings save %s/%s: members: %v", acct.Sqid, app, err) |
| 319 | s.renderPipelineSettings(w, p, rows, "Could not check the account's members — nothing was changed. Try again.") |
| 320 | return |
| 321 | } |
| 322 | proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: channels} |
| 323 | if n := proposed.MaxApprovals(); n > len(members) { |
| 324 | s.renderPipelineSettings(w, p, rows, fmt.Sprintf( |
| 325 | "Approvals of %d exceed the account's %d member(s) — add members first.", n, len(members))) |
| 326 | return |
| 327 | } |
| 328 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 329 | return |
| 330 | } |
| 331 | dest := appSettingsURL(acct.Sqid, app, "pipeline") |
| 332 | if _, n := guardedEdit(cur, channels); n > 0 { |
| 333 | kind, msg := s.proposeThenExecute(ctx, acct, app, kindPolicy, policyProposal(cur, channels), n, addr) |
| 334 | s.setFlash(w, kind, msg) |
| 335 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 336 | return |
| 337 | } |
| 338 | if _, err := s.writePipeline(ctx, acct, app, channels, addr, "console"); err != nil { |
| 339 | status, msg := classifySetPipelineError(err) |
| 340 | if status != http.StatusUnprocessableEntity { |
| 341 | log.Printf("console: pipeline settings save %s/%s: set: %v", acct.Sqid, app, err) |
| 342 | } |
| 343 | s.renderPipelineSettings(w, p, rows, msg) |
| 344 | return |
| 345 | } |
| 346 | s.setFlash(w, "ok", "Pipeline updated.") |
| 347 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 348 | } |
| 349 | |