carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
16package console
17
18import (
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.
37type 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).
59func (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.
77type 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.
87func 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.
103func 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.
124func 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.
134func 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.
145func 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".
158func 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.
182func 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.
200func (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.
216func (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
224func (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.
245func (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.
271func (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