carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The per-app pipeline object — the release pipelines v2 spec
2// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md, §2).
3//
4// A pipeline replaces the implicit edge/beta/stable ladder with an
5// account-declared, ordered list of channels: where a release enters
6// (Channels[0]), how it moves between the rest, and — per channel — how
7// long it bakes and how many approvals promoting into or changing it takes.
8// canary/* stays outside the pipeline entirely; it is the pre-pipeline
9// staging rung the ladder already gives every channel name (spec §2), never
10// itself a pipeline entry.
11//
12// Signing and verification mirror production.go exactly: the object is
13// root-signed in the pointer's own signedWrapper envelope and root-ONLY — a
14// wrapper carrying a key grant is refused before its signature is even
15// checked, because a scoped key that could redraw the pipeline could hand
16// itself a channel with no bake and no approvals.
17package release
18
19import (
20 "context"
21 "crypto/ed25519"
22 "encoding/base64"
23 "encoding/json"
24 "errors"
25 "fmt"
26 "os"
27 "strings"
28 "time"
29)
30
31// ChannelRules is a channel's opt-in policy. The zero value is the default:
32// no rule. Enforcement is the console's job (spec §4-§5); boxes only ever
33// read Bake — rules travel here so one signed object is the whole policy.
34type ChannelRules struct {
35 Passkey bool `json:"passkey,omitempty"`
36 PromoteApprovals int `json:"promote_approvals,omitempty"`
37 ChangeApprovals int `json:"change_approvals,omitempty"`
38}
39
40type PipelineChannel struct {
41 Name string `json:"name"`
42 Bake string `json:"bake,omitempty"` // Go duration string; "" means 0
43 Rules ChannelRules `json:"rules,omitzero"`
44}
45
46// BakeWindow parses Bake, treating "" as no bake at all. A negative
47// duration is rejected here rather than left to a caller that would
48// otherwise wait for a time that never comes.
49func (c PipelineChannel) BakeWindow() (time.Duration, error) {
50 if c.Bake == "" {
51 return 0, nil
52 }
53 d, err := time.ParseDuration(c.Bake)
54 if err != nil || d < 0 {
55 return 0, fmt.Errorf("channel %s: bake %q is not a non-negative duration", c.Name, c.Bake)
56 }
57 return d, nil
58}
59
60// Pipeline is the signed payload: one app's ordered channel list.
61type Pipeline struct {
62 Schema int `json:"schema"` // 1
63 Account string `json:"account"`
64 App string `json:"app"`
65 Channels []PipelineChannel `json:"channels"`
66 // Actor is who set it — a console member's address. Recorded for the
67 // same reason the pointer's audit trail exists: an invisible bypass is a
68 // broken gate.
69 Actor string `json:"actor,omitempty"`
70 SetAt time.Time `json:"set_at"`
71}
72
73// Entry is where a release lands first. Callers must hold a validated
74// pipeline (validatePipeline guarantees at least one channel) — there is no
75// defensive branch here for an empty list.
76func (p *Pipeline) Entry() string { return p.Channels[0].Name }
77
78// Index is a channel's position in the promotion order, -1 for anything
79// outside it — including every canary/* name, which is never a pipeline
80// channel (spec §2).
81func (p *Pipeline) Index(channel string) int {
82 if r, ok := Rung(channel); ok && r == RungCanary {
83 return -1
84 }
85 for i, c := range p.Channels {
86 if c.Name == channel {
87 return i
88 }
89 }
90 return -1
91}
92
93func (p *Pipeline) ChannelNamed(name string) (PipelineChannel, bool) {
94 if i := p.Index(name); i >= 0 {
95 return p.Channels[i], true
96 }
97 return PipelineChannel{}, false
98}
99
100// Names is the pipeline's channel names in promotion order — what a refusal
101// lists when it has to say which channels an app actually has.
102func (p *Pipeline) Names() []string {
103 out := make([]string, len(p.Channels))
104 for i, c := range p.Channels {
105 out[i] = c.Name
106 }
107 return out
108}
109
110// MaxApprovals is the largest quorum any channel demands — the console
111// refuses to store a pipeline whose MaxApprovals exceeds the account's
112// member count (spec §4).
113func (p *Pipeline) MaxApprovals() int {
114 m := 0
115 for _, c := range p.Channels {
116 if c.Rules.PromoteApprovals > m {
117 m = c.Rules.PromoteApprovals
118 }
119 if c.Rules.ChangeApprovals > m {
120 m = c.Rules.ChangeApprovals
121 }
122 }
123 return m
124}
125
126// PipelineKey is where an app's pipeline lives: beside production.json,
127// deliberately NEVER under ChannelsPrefix — those listings are parsed as
128// channel pointers by the console and the CLI.
129func PipelineKey(acct, app string) string { return appPrefix(acct, app) + "pipeline.json" }
130
131// DefaultPipelineChannels is what a fresh app gets before anyone declares a
132// pipeline of their own: a single entry channel, matching the ladder's
133// current edge-only default.
134func DefaultPipelineChannels() []PipelineChannel { return []PipelineChannel{{Name: "edge"}} }
135
136// SetPipeline writes the object, root key only, CAS over whatever is there
137// (a concurrent write surfaces as ErrCASConflict — re-ask, never overwrite
138// blind). SetAt is stamped here, strictly after whatever this key's own
139// last signed write recorded.
140func claimRawSetPipeline(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, channels []PipelineChannel, actor string) (*Pipeline, error) {
141 if len(priv) != ed25519.PrivateKeySize {
142 return nil, fmt.Errorf("set pipeline: no usable release signing key")
143 }
144 if !ValidAccount(acct) {
145 return nil, fmt.Errorf("set pipeline: invalid account %q", acct)
146 }
147 if !ValidApp(app) {
148 return nil, fmt.Errorf("set pipeline: invalid app %q", app)
149 }
150 // A trashed app cannot be given a pipeline — restore first. Unreadable
151 // refuses too: a caller about to write a pipeline must never act as
152 // though a marker it cannot verify simply isn't there.
153 tomb, err := ReadAppTombstone(ctx, s, acct, app)
154 if err != nil {
155 return nil, fmt.Errorf("set pipeline: cannot check the trash marker for %s/%s: %w", acct, app, err)
156 }
157 if tomb != nil {
158 return nil, fmt.Errorf("set pipeline: %s/%s is in the trash — restore it before changing its pipeline", acct, app)
159 }
160 key := PipelineKey(acct, app)
161 raw, etag, err := s.Get(ctx, key)
162 if errors.Is(err, os.ErrNotExist) {
163 etag = "" // first write: create-only
164 } else if err != nil {
165 return nil, fmt.Errorf("set pipeline: read %s: %w", key, err)
166 }
167 setAt := time.Now().UTC().Truncate(time.Second)
168 if err == nil {
169 // SetAt is truncated to the second; two writes inside one wall-clock
170 // second would otherwise share a value. Bump strictly past whatever
171 // this key's own last signed write recorded.
172 if existing, verr := verifyPipeline(priv.Public().(ed25519.PublicKey), raw, acct, app); verr == nil && !existing.SetAt.Before(setAt) {
173 setAt = existing.SetAt.Add(time.Second)
174 }
175 }
176 p := &Pipeline{
177 Schema: 1, Account: acct, App: app, Channels: channels,
178 Actor: actor, SetAt: setAt,
179 }
180 if err := validatePipeline(p, acct, app); err != nil {
181 return nil, fmt.Errorf("set pipeline: %w", err)
182 }
183 payload, err := json.MarshalIndent(p, "", " ")
184 if err != nil {
185 return nil, err
186 }
187 b64 := base64.StdEncoding.EncodeToString(payload)
188 wrapper, err := json.MarshalIndent(signedWrapper{
189 Payload: b64,
190 Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64))),
191 }, "", " ")
192 if err != nil {
193 return nil, err
194 }
195 if _, err := s.Put(ctx, key, wrapper, etag); err != nil {
196 return nil, err
197 }
198 return p, nil
199}
200
201// GetPipeline resolves an app's pipeline. A MISSING object is (nil, nil):
202// callers fall back to DefaultPipelineChannels() — but never let a nil
203// collapse the distinction: any OTHER failure (unreadable, unverifiable,
204// misnamed) is the error.
205func GetPipeline(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) (*Pipeline, error) {
206 raw, _, err := s.Get(ctx, PipelineKey(acct, app))
207 if errors.Is(err, os.ErrNotExist) {
208 return nil, nil
209 }
210 if err != nil {
211 return nil, err
212 }
213 return verifyPipeline(pub, raw, acct, app)
214}
215
216// verifyPipeline is verifyProductionFlag's discipline for the pipeline:
217// refuse a grant outright, check the signature against the ROOT key alone,
218// decode after, validate the payload names the account/app whose key it
219// sits at.
220func verifyPipeline(pub ed25519.PublicKey, wrapper []byte, wantAcct, wantApp string) (*Pipeline, error) {
221 var sw signedWrapper
222 if err := json.Unmarshal(wrapper, &sw); err != nil {
223 return nil, fmt.Errorf("pipeline wrapper: %v", err)
224 }
225 if sw.Grant != nil {
226 return nil, fmt.Errorf("pipeline %s/%s: signed by a scoped key — the pipeline takes the root key, because a delegate that could redraw it could hand itself a channel with no bake and no approvals", wantAcct, wantApp)
227 }
228 if len(pub) != ed25519.PublicKeySize {
229 return nil, fmt.Errorf("pipeline: no usable release public key")
230 }
231 sig, err := base64.StdEncoding.DecodeString(sw.Sig)
232 if err != nil {
233 return nil, fmt.Errorf("pipeline sig: %v", err)
234 }
235 if !ed25519.Verify(pub, []byte(sw.Payload), sig) {
236 return nil, fmt.Errorf("pipeline signature verification failed")
237 }
238 payload, err := base64.StdEncoding.DecodeString(sw.Payload)
239 if err != nil {
240 return nil, fmt.Errorf("pipeline payload: %v", err)
241 }
242 var p Pipeline
243 if err := json.Unmarshal(payload, &p); err != nil {
244 return nil, fmt.Errorf("pipeline payload: %v", err)
245 }
246 if err := validatePipeline(&p, wantAcct, wantApp); err != nil {
247 return nil, err
248 }
249 return &p, nil
250}
251
252// validatePipeline checks a candidate pipeline before it is ever signed or
253// trusted after verification: schema, the copied-object trap (account/app
254// must name the key it sits at), channel count, and per-channel shape.
255func validatePipeline(p *Pipeline, wantAcct, wantApp string) error {
256 if p.Schema != 1 {
257 return fmt.Errorf("unsupported pipeline schema %d", p.Schema)
258 }
259 if p.Account != wantAcct || p.App != wantApp {
260 return fmt.Errorf("pipeline names %s/%s, expected %s/%s", p.Account, p.App, wantAcct, wantApp)
261 }
262 if len(p.Channels) == 0 {
263 return fmt.Errorf("pipeline must have at least one channel")
264 }
265 if len(p.Channels) > 16 {
266 return fmt.Errorf("pipeline has %d channels, at most 16 allowed", len(p.Channels))
267 }
268 seen := make(map[string]bool, len(p.Channels))
269 for _, c := range p.Channels {
270 if !ValidChannel(c.Name) {
271 return fmt.Errorf("invalid channel name %q", c.Name)
272 }
273 if strings.Contains(c.Name, "/") {
274 return fmt.Errorf("channel names may not contain \"/\" — only canary nests")
275 }
276 if c.Name == RungCanary {
277 return fmt.Errorf("%q is reserved — canary/* lives outside the pipeline", RungCanary)
278 }
279 if seen[c.Name] {
280 return fmt.Errorf("duplicate channel %q", c.Name)
281 }
282 seen[c.Name] = true
283 if _, err := c.BakeWindow(); err != nil {
284 return err
285 }
286 if c.Rules.PromoteApprovals < 0 || c.Rules.ChangeApprovals < 0 {
287 return fmt.Errorf("channel %s: approvals must be >= 0", c.Name)
288 }
289 }
290 return nil
291}
292
293// VersionStage is VersionRung for a pipeline: the highest-Index channel
294// among p.Channels that currently points at version — "how far along this
295// app's own pipeline is this build", answered from the pointers themselves.
296// "" means the version is on none of them; it may still be shipped, sitting
297// on a channel outside the pipeline (a canary, a hotfix target).
298//
299// A channel whose pointer cannot be read or verified is skipped rather than
300// fatal, mirroring VersionRung: an unreadable pointer can only LOWER the
301// answer, which makes the order gate stricter, never looser.
302func VersionStage(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string, p *Pipeline) (string, error) {
303 // Verification needs a real key: ed25519.Verify PANICS on a wrong-sized
304 // one, and a read-only console runs with none.
305 if len(pub) != ed25519.PublicKeySize {
306 return "", fmt.Errorf("version stage: no usable release public key")
307 }
308 best := ""
309 bestIdx := -1
310 for _, c := range p.Channels {
311 if i := p.Index(c.Name); i > bestIdx {
312 ptr, err := Channel(ctx, s, pub, acct, app, c.Name)
313 if err != nil || ptr.Version != version {
314 continue
315 }
316 bestIdx = i
317 best = c.Name
318 }
319 }
320 return best, nil
321}
322
323// PipelinePromotionAllowed is PromotionAllowed for an app-defined pipeline:
324// order comes from the channel list, canary is unconditional, and every
325// refusal names -hotfix — which bypasses ORDER only, never a channel's
326// rules; those are the console's gates (spec §5).
327func PipelinePromotionAllowed(p *Pipeline, from, to string) error {
328 if r, ok := Rung(to); ok && r == RungCanary {
329 return nil
330 }
331 names := make([]string, len(p.Channels))
332 for i, c := range p.Channels {
333 names[i] = c.Name
334 }
335 i := p.Index(to)
336 if i < 0 {
337 if len(p.Channels) == 1 {
338 return fmt.Errorf("%q is not one of this app's channels — it has one channel (%s); `carlos pipeline` sets up more, or --hotfix points outside the pipeline",
339 to, p.Entry())
340 }
341 return fmt.Errorf("%q is not one of this app's channels (%s) — use --hotfix to point a channel outside the pipeline",
342 to, strings.Join(names, ", "))
343 }
344 if i == 0 {
345 if from == "" {
346 return nil // a fresh build entering the pipeline — deploys land here
347 }
348 return fmt.Errorf("nothing is promoted onto %s — deploys land there, and this build is already on %s; use --hotfix to override",
349 to, from)
350 }
351 if from == p.Channels[i-1].Name {
352 return nil
353 }
354 return fmt.Errorf("%s is entered from %s only, and this build is on %s — use --hotfix to bypass the order",
355 to, p.Channels[i-1].Name, describeRung(from))
356}
357func SetPipeline(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, channels []PipelineChannel, actor string) (claimResult0 *Pipeline, claimResult1 error) {
358 claimResult1 = WithAppMutation(ctx, s, acct, app, "release.SetPipeline", func(claimContext context.Context) error {
359 claimResult0, claimResult1 = claimRawSetPipeline(claimContext, s, priv, acct, app, channels, actor)
360 return claimResult1
361 })
362 return
363}
364