carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's pipeline API: `carlos pipeline` reading and writing an app's
2// pipeline object through the console, the same door class as promote and
3// config — release pipelines v2
4// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md).
5//
6// GET is Publish-reachable like channels/releases: a pipeline is release
7// metadata, not a secret. PUT is admin-only for service credentials
8// (svcrole.Allow(svcrole.Admin) at registration) — it rewrites the policy
9// that governs every promotion into every channel of the app, including
10// whether one needs a passkey or a quorum of humans, which is account
11// authority in the same register as ledgers and object storage grants, not
12// day-to-day deploy work. A human member reaches it regardless of role: the
13// role gate in cliRoute only ever narrows a SERVICE credential.
14//
15// An edit that touches a channel whose CURRENTLY STORED rules already demand
16// change_approvals does not land from here: it becomes a PROPOSAL (202,
17// "finish in the console"), pinned to the pipeline it was composed against
18// and starting at zero confirmations, because a member typing a bearer-token
19// PUT must never be able to strip or reorder past a rule that exists
20// specifically to require more than one bearer token's say-so. Tightening
21// (raising a channel's own approvals, or adding a guard where there was none)
22// still goes straight through — only a channel the stored pipeline ALREADY
23// guards takes the quorum.
24package console
25
26import (
27 "context"
28 "errors"
29 "fmt"
30 "log"
31 "net/http"
32 "strings"
33 "time"
34
35 "github.com/carlosframework/platform/internal/release"
36)
37
38// pipelineStoreTimeout bounds every store round trip these handlers make —
39// the same budget handleAPIChannels and the config doors give the bucket.
40const pipelineStoreTimeout = 15 * time.Second
41
42// handleAPIPipelineGet answers `carlos pipeline`: an app's declared
43// pipeline, or null when it has none. Absence is not an error — it is spec
44// §2's "no pipeline.json" case, meaning the legacy edge/beta/stable ladder
45// applies untouched — so it is a 200 with a null field, not a 404.
46//
47// GetPipeline's error return is NEVER treated as absence: an unreadable or
48// unverifiable object is a 502, distinct from the (nil, nil) genuinely-missing
49// case. Collapsing the two would tell a caller "this app has no pipeline"
50// when the truth is "the console could not tell you" — the exact trap
51// Task 1's review caught for this same function.
52func (s *Server) handleAPIPipelineGet(w http.ResponseWriter, r *http.Request) {
53 _, acct, app, ok := s.apiAccountApp(w, r)
54 if !ok {
55 return
56 }
57 ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout)
58 defer cancel()
59 p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
60 if err != nil {
61 log.Printf("console: api pipeline get %s/%s: %v", acct.Sqid, app, err)
62 jsonError(w, http.StatusBadGateway, "could not read the pipeline")
63 return
64 }
65 writeJSON(w, http.StatusOK, map[string]any{"pipeline": p})
66}
67
68// handleAPIPipelinePut answers `carlos pipeline set` (and the console's own
69// pipeline editor, once it exists): replace an app's channel list.
70//
71// Doors, in order, and why:
72//
73// token + account/app 404 — apiAccountApp, same byte-identical refusal
74// every other door in this family gives.
75// write budget — allowWrite, the same picker every other
76// mutating CLI door here uses (promote,
77// rollback, config, restart): a person keyed on
78// their address, a service on its name.
79// signing key — SetPipeline signs root-only, like promote and
80// production; a read-only console (no
81// CARLOS_RELEASE_KEY) refuses with 503 before
82// ever reading a body, the same shape
83// handleAPIPromote gives.
84// body + current read — decoded and the CURRENT pipeline read (needed
85// for the guarded-edit check below) before any
86// refusal that depends on either.
87// member-count ceiling — spec §4: no channel's approvals may exceed
88// the account's own member count, checked
89// against the PROPOSED shape.
90// guarded-edit refusal — the interim gate this door's doc comment
91// describes; Task 8 replaces it with the real
92// quorum flow.
93// SetPipeline — validates shape internally (canary reserved,
94// duplicate names, bad bake strings, negative
95// approvals) and signs. Its error is CLASSIFIED
96// (classifySetPipelineError, below) rather than
97// echoed wholesale: a validation refusal is 422,
98// the caller's to fix; a lost CAS race is 409,
99// re-ask; a store read/write failure is 502 and
100// never puts a bucket key path on the wire.
101// production side-write — spec §3's old-binary fallback, below.
102// audit + bump — after the write lands, never before or in
103// place of a gate above.
104func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) {
105 addr, acct, app, ok := s.apiAccountApp(w, r)
106 if !ok {
107 return
108 }
109 principal, _ := principalFrom(r.Context())
110 if !s.allowWrite(principal, time.Now()) {
111 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
112 return
113 }
114 if s.cfg.Key == nil {
115 jsonError(w, http.StatusServiceUnavailable,
116 "no signing key on this host — the console is running read-only")
117 return
118 }
119 var in struct {
120 Channels []release.PipelineChannel `json:"channels"`
121 }
122 if !readJSON(w, r, &in) {
123 return
124 }
125 ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout)
126 defer cancel()
127 cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
128 if err != nil {
129 log.Printf("console: api pipeline put %s/%s: read current: %v", acct.Sqid, app, err)
130 jsonError(w, http.StatusBadGateway, "cannot read the current pipeline")
131 return
132 }
133 proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: in.Channels}
134 members, err := s.store.Members(acct.ID)
135 if err != nil {
136 log.Printf("console: api pipeline put %s/%s: members: %v", acct.Sqid, app, err)
137 jsonError(w, http.StatusInternalServerError, "members lookup failed")
138 return
139 }
140 if n := proposed.MaxApprovals(); n > len(members) {
141 jsonError(w, http.StatusUnprocessableEntity, fmt.Sprintf(
142 "approvals of %d exceed the account's %d member(s) — add members first", n, len(members)))
143 return
144 }
145 if _, n := guardedEdit(cur, in.Channels); n > 0 {
146 // The guard is met by a QUORUM, not refused outright (release
147 // pipelines v2 §4): the edit becomes a proposal pinned to the
148 // pipeline it was composed against, at zero confirmations, and the
149 // members finish it in the console with their passkeys.
150 s.proposeOverAPI(w, acct, app, kindPolicy, policyProposal(cur, in.Channels), n, addr)
151 return
152 }
153 // writePipeline (pipelinepage.go) is the shared write tail: SetPipeline,
154 // the old-binary bake fallback, the audit row and the serial bump — the
155 // browser settings POST (Task 9) funnels through the same function
156 // rather than a second copy of it.
157 set, err := s.writePipeline(ctx, acct, app, in.Channels, addr, "cli")
158 if err != nil {
159 status, msg := classifySetPipelineError(err)
160 if status != http.StatusUnprocessableEntity {
161 // A validation refusal is the caller's own typo — the message
162 // already says so, on the wire, and logging every bad request a
163 // script sends would just be noise. A 409 or 502 here is a
164 // genuine backend condition (a race, an unreachable store) an
165 // operator may need the real detail for.
166 log.Printf("console: api pipeline put %s/%s: set: %v", acct.Sqid, app, err)
167 }
168 jsonError(w, status, msg)
169 return
170 }
171 writeJSON(w, http.StatusOK, map[string]any{"pipeline": set})
172}
173
174// guardedEdit is the confirmation gate on a policy write. It reports the
175// channel in cur whose CURRENTLY STORED rules demand the HIGHEST
176// change_approvals among those this edit touches, and that quorum — or
177// ("", 0) when nothing guarded was touched. The whole edit then proposes at
178// that quorum (handleAPIPipelinePut): an edit touching two guarded channels
179// must meet the stricter of them, or the looser one would be the way past the
180// stricter in the same request.
181//
182// cur is nil for an app that has never had a pipeline (GetPipeline's
183// (nil, nil) case): nothing is guarded yet, so nothing can be touched.
184//
185// "Preserve" means: still present in next, at the SAME index, with an
186// IDENTICAL PipelineChannel value — name, bake and every rule, compared with
187// Go's struct ==. Removing the channel, reordering it, or changing anything
188// about it (including loosening or tightening its own approvals) all count
189// as touching it, matching spec §2's "removing the channel or moving its
190// position in the list counts as an edit to its entry — otherwise deletion
191// is the loophole around every rule it carries." Only a channel cur did NOT
192// already guard is free to be added, removed or reordered without
193// confirmation.
194//
195// One boundary this DELIBERATELY leaves open, named here so it reads as
196// chosen rather than missed: swapping a guarded channel's PREDECESSOR changes
197// what may be promoted into it, and is not itself an edit to its entry. Spec
198// §2 scopes the guard to "this channel's entry in the pipeline", and the
199// predecessor's own entry is the thing being edited — so a predecessor swap
200// passes unless that predecessor is itself guarded. The promotion into the
201// guarded channel still meets every rule the guarded channel carries, which
202// is where its protection actually lives.
203func guardedEdit(cur *release.Pipeline, next []release.PipelineChannel) (string, int) {
204 if cur == nil {
205 return "", 0
206 }
207 name, quorum := "", 0
208 for i, c := range cur.Channels {
209 if c.Rules.ChangeApprovals < 1 || c.Rules.ChangeApprovals <= quorum {
210 continue
211 }
212 if i >= len(next) || next[i] != c {
213 name, quorum = c.Name, c.Rules.ChangeApprovals
214 }
215 }
216 return name, quorum
217}
218
219// ensureProductionForBakes is spec §3's old-binary mitigation: enabling a
220// pipeline with ANY nonzero bake also sets production: true, so a box that
221// has not yet been updated to read pipeline.json — and therefore ignores it
222// entirely — falls back to its OWN fail-closed legacy holds (HoldFor's 72h
223// for a name it doesn't recognise) instead of adopting a held release
224// immediately.
225//
226// It only ever flips TOWARD true. A pipeline whose every channel bakes for
227// 0s is not a reason to turn a flag OFF — production may be true for a
228// reason that has nothing to do with this pipeline (spec's deletion
229// protection) — and an UNREADABLE flag is left alone rather than
230// re-declared: GetProduction's error already fails every reader toward
231// "treat as production" (adopt path, trash's delete gate), so writing over
232// it here would only risk losing whatever the existing object actually
233// says. Log-only on a READ failure: the pipeline write itself already
234// landed, and a box that never sees this flag flip is no worse off than a
235// pipeline-less app was yesterday. A successful FLIP, though, gets the same
236// audit row every other SetProduction call site writes (server.go's
237// enable_bake and production-toggle doors) — this flag gates fleet-wide
238// baking and delete protection, so a row that goes unaudited here while
239// every other path to it is audited would be the one silent way to turn it
240// on.
241func (s *Server) ensureProductionForBakes(ctx context.Context, acct *Account, app string, p *release.Pipeline, actor string) {
242 bakes := false
243 for _, c := range p.Channels {
244 if d, err := c.BakeWindow(); err == nil && d > 0 {
245 bakes = true
246 break
247 }
248 }
249 if !bakes {
250 return
251 }
252 // A temporary app is never flagged production (spec §4): the flag would
253 // take it out of automatic destruction's reach. Its pipeline bakes on
254 // boxes new enough to read the pipeline; the old-binary fallback is
255 // skipped, and said so in the log.
256 if life, err := s.store.AppLife(acct.ID, app); err != nil || (life != nil && life.Temporary()) {
257 log.Printf("console: pipeline %s/%s: not setting production for the bake fallback (temporary app, or unreadable row: %v)", acct.Sqid, app, err)
258 return
259 }
260 flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
261 if err != nil {
262 log.Printf("console: pipeline %s/%s: read production flag for bake fallback: %v", acct.Sqid, app, err)
263 return
264 }
265 if flag != nil && flag.Production {
266 return // already true — never re-declared, never downgraded
267 }
268 if err := s.claimAppMutation(ctx, acct, app, "protection", func(ctx context.Context) error {
269 return release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, actor)
270 }); err != nil {
271 log.Printf("console: pipeline %s/%s: set production for bake fallback: %v", acct.Sqid, app, err)
272 return
273 }
274 s.audit(acct, app, actor, "production",
275 "enabled the safety delay (production: on, at pipeline set — old-binary bake fallback)")
276}
277
278// classifySetPipelineError maps a release.SetPipeline error to the status
279// and caller-facing message this door answers with. SetPipeline's errors
280// come in three shapes, and only one of them is safe to echo verbatim:
281//
282// - release.ErrCASConflict (checked by errors.Is, since SetPipeline
283// returns it — via Store.Put — unwrapped): a concurrent write won the
284// race. 409, with a fixed retry sentence — not the caller's fault and
285// not a defect in what they sent.
286// - A store or trash-marker READ failure, wrapped with the exact prefixes
287// SetPipeline's own source uses ("cannot check the trash marker" /
288// "set pipeline: read "): the bucket, not the request, is the problem.
289// 502, with a fixed sentence — the real error (bucket names, request
290// ids) stays in the log, the same discipline every other store-backed
291// door here follows (e.g. handleAPITrashApp, apiledger.go).
292// - Everything else prefixed "set pipeline: " is validatePipeline's own
293// refusal (or one of the pre-checks that cannot fire from this door,
294// since apiAccountApp already validated the account/app names) —
295// genuinely the caller's malformed input, safe and useful to echo. 422.
296// - An error with NO "set pipeline: " prefix at all and not ErrCASConflict
297// is the one other shape SetPipeline returns as-is: a raw Store.Put
298// failure. Not the caller's fault either — 502, generic.
299func classifySetPipelineError(err error) (int, string) {
300 if errors.Is(err, release.ErrCASConflict) {
301 return http.StatusConflict, "concurrent change — re-read and retry"
302 }
303 msg := err.Error()
304 if strings.Contains(msg, "cannot check the trash marker") || strings.Contains(msg, "set pipeline: read ") {
305 return http.StatusBadGateway, "could not verify the current pipeline — try again"
306 }
307 if strings.HasPrefix(msg, "set pipeline: ") {
308 return http.StatusUnprocessableEntity, msg
309 }
310 return http.StatusBadGateway, "could not write the pipeline — try again"
311}
312
313// pipelineAuditDetail names what actually changed, in the house style the
314// neighbouring audit rows use (server.go's promote/production rows): a
315// bare "set the pipeline" would leave a reader of the audit trail unable to
316// tell a one-line typo fix from a whole new ladder without opening the
317// bucket object themselves — and this row documents a POLICY rewrite, not a
318// routine write like a config var.
319// via names the door the write came through — "cli" for a direct PUT, or
320// what a confirmed proposal was proposed by and how many members signed off.
321func pipelineAuditDetail(p *release.Pipeline, via string) string {
322 names := make([]string, len(p.Channels))
323 for i, c := range p.Channels {
324 names[i] = c.Name
325 }
326 suffix := "s"
327 if len(p.Channels) == 1 {
328 suffix = ""
329 }
330 return fmt.Sprintf("set the pipeline (%s; %d channel%s, %s)",
331 strings.Join(names, " → "), len(p.Channels), suffix, via)
332}
333
334// holdWindow is the pipeline-aware form of release.HoldFor (bake.go), for
335// later tasks (adoption, promote) that need one channel's bake window
336// without first checking whether the app has opted into a pipeline at all.
337// A pipelined app's channel carries its OWN window — possibly zero, and for
338// a name the legacy ladder has never heard of. An app with no pipeline, or a
339// channel that sits outside a declared one (canary/*, a -hotfix target),
340// falls back to the legacy default table exactly as it does today.
341func holdWindow(p *release.Pipeline, channel string) time.Duration {
342 if p != nil {
343 if c, ok := p.ChannelNamed(channel); ok {
344 if d, err := c.BakeWindow(); err == nil {
345 return d
346 }
347 }
348 }
349 return release.HoldFor(release.DefaultHolds, channel)
350}
351