cli: carlos pipeline verb family; promote and rollback propose and carry -hotfix over the API
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+1288
−28
apimode.go+229 −11apimode_test.go+5 −5auth_e2e_test.go+1 −1cli.go+16 −10color.go+12 −0deploy.go+3 −1main.go+3 −0pipeline.go+470 −0pipeline_test.go+549 −0
diff --git a/apimode.go b/apimode.go| index fdec85bdb..cd39491ff 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -773,12 +773,17 @@ func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, [] |
| return s.sendJSON(ctx, http.MethodPost, path, in) |
| } |
| -// stableRefusal decodes handleAPIPromote/handleAPIRollback's 403 shape |
| -// ({"error", "console_url"}) and, when it matches, returns the error the |
| -// caller should see: the console's own refusal text plus where to finish |
| -// the job — a passkey step-up has no bearer-token equivalent, so stable |
| -// promotion/rollback always ends here, never on this machine. |
| -func stableRefusal(status int, body []byte) (error, bool) { |
| +// consoleRefusal decodes the generalized {"error", "console_url"} 403 shape |
| +// every guarded write door in this family answers with — handleAPIPromote |
| +// and handleAPIRollback's stable/passkey refusal (apicli.go) and |
| +// handleAPIPipelinePut's own passkey-rule refusal (release pipelines v2 |
| +// §5) alike — and, when it matches, returns the error the caller should |
| +// see: the console's own refusal text plus where to finish the job. A |
| +// passkey step-up has no bearer-token equivalent, so a channel that demands |
| +// one always ends here, never on this machine. Named for what it now covers |
| +// (not only stable) since Task 10 widened its call sites beyond promote and |
| +// rollback to `carlos pipeline`'s own writes. |
| +func consoleRefusal(status int, body []byte) (error, bool) { |
| if status != http.StatusForbidden { |
| return nil, false |
| } |
| @@ -792,17 +797,61 @@ func stableRefusal(status int, body []byte) (error, bool) { |
| return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true |
| } |
| +// apiProposedError is what a guarded write (promote, rollback, or a |
| +// `carlos pipeline` edit that touches an already-guarded channel) returns |
| +// instead of landing: the console answered 202, spec §4's bearer-token |
| +// shape — a token can never carry a passkey quorum, so the change is |
| +// recorded at zero confirmations and must be finished in the console. Every |
| +// caller here must check for this with errors.As and print it rather than |
| +// let it fall through to apiErrorFromBody, which would otherwise report the |
| +// misleading "unexpected response (status 202)" (the CLI-lies-about-its- |
| +// outcome bug this type exists to close). |
| +type apiProposedError struct { |
| + PendingID string |
| + Needed int |
| + Confirmed int |
| + ConsoleURL string |
| +} |
| + |
| +func (e *apiProposedError) Error() string { |
| + return fmt.Sprintf("proposed — needs %d confirmation(s); finish at %s", e.Needed, e.ConsoleURL) |
| +} |
| + |
| +// proposedFromBody decodes a 202's body — proposeOverAPI's |
| +// {"result","pending_id","needed","confirmed","console_url"} shape |
| +// (pendinghandlers.go) — into an *apiProposedError. The body is decoded |
| +// best-effort: even an unreadable one still yields a usable error, because |
| +// the status code alone already says "proposed", and dropping the whole |
| +// thing here would lose the console_url an operator needs to actually |
| +// finish the job. |
| +func proposedFromBody(body []byte) error { |
| + var res struct { |
| + PendingID string `json:"pending_id"` |
| + Needed int `json:"needed"` |
| + Confirmed int `json:"confirmed"` |
| + ConsoleURL string `json:"console_url"` |
| + } |
| + json.Unmarshal(body, &res) |
| + return &apiProposedError{PendingID: res.PendingID, Needed: res.Needed, Confirmed: res.Confirmed, ConsoleURL: res.ConsoleURL} |
| +} |
| + |
| // promote asks the console to promote version onto channel. The response's |
| // "result" is the one field read here — "noop" when the ladder already had |
| // this version on this channel — because everything else the response |
| // carries (RUNG names in From/To) is not what the caller prints: the CLI |
| // already knows the version/channel it asked for and prints those, never |
| // res.From/res.To. |
| -func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string) (apiPromoteResult, error) { |
| +// |
| +// hotfix rides along as a plain field (release pipelines v2, Task 10): the |
| +// console's promote door reads it strictly after its own rules decision |
| +// (handleAPIPromote's doc comment), so sending it is never itself a bypass |
| +// of anything this call site doesn't already ask for. |
| +func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix bool) (apiPromoteResult, error) { |
| in := struct { |
| Version string `json:"version"` |
| Channel string `json:"channel"` |
| - }{version, channel} |
| + Hotfix bool `json:"hotfix,omitempty"` |
| + }{version, channel, hotfix} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in) |
| if err != nil { |
| return apiPromoteResult{}, err |
| @@ -817,7 +866,10 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann |
| } |
| return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held}, nil |
| } |
| - if refErr, ok := stableRefusal(status, body); ok { |
| + if status == http.StatusAccepted { |
| + return apiPromoteResult{}, proposedFromBody(body) |
| + } |
| + if refErr, ok := consoleRefusal(status, body); ok { |
| return apiPromoteResult{}, refErr |
| } |
| return apiPromoteResult{}, apiErrorFromBody(status, body) |
| @@ -878,7 +930,12 @@ func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string |
| // rollback asks the console to roll channel back to its previous version. |
| // There is no noop case for a rollback — it either has a previous version to |
| -// land on (409 if not) or it doesn't — so this stays error-only. |
| +// land on (409 if not) or it doesn't. It DOES have a proposed case, exactly |
| +// as promote does: a pipelined channel that demands promote_approvals turns |
| +// this into a 202 (handleAPIRollback, apicli.go), which is why this can no |
| +// longer stay error-or-nil — *apiProposedError is a real, expected return |
| +// alongside a genuine failure, and runRollback tells them apart with |
| +// errors.As. |
| func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string) error { |
| in := struct { |
| Channel string `json:"channel"` |
| @@ -890,7 +947,10 @@ func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel strin |
| if status == http.StatusOK { |
| return nil |
| } |
| - if refErr, ok := stableRefusal(status, body); ok { |
| + if status == http.StatusAccepted { |
| + return proposedFromBody(body) |
| + } |
| + if refErr, ok := consoleRefusal(status, body); ok { |
| return refErr |
| } |
| return apiErrorFromBody(status, body) |
| @@ -1626,6 +1686,164 @@ func (s *apiSession) configSync(ctx context.Context, a apiAcct, app string) erro |
| return nil |
| } |
| +// ---- pipeline + pending (release pipelines v2, Task 10) ------------------- |
| + |
| +// apiPipelineChannel mirrors release.PipelineChannel/ChannelRules across the |
| +// wire, but FLAT rather than nested — Passkey/PromoteApprovals/ |
| +// ChangeApprovals sit alongside Name/Bake here, one struct a `carlos |
| +// pipeline` verb can build and mutate directly, rather than a Rules |
| +// sub-struct every call site would have to reach through. MarshalJSON/ |
| +// UnmarshalJSON below fold that flat shape back onto the server's nested |
| +// {"name","bake","rules":{"passkey","promote_approvals","change_approvals"}} |
| +// wire shape (internal/release/pipeline.go) exactly, field for field and |
| +// omitempty for omitempty — a template PUT (ruling: any template that sets a |
| +// rule also sets change_approvals) depends on this producing exactly the |
| +// same bytes handleAPIPipelinePut already accepts from the console's own |
| +// settings form. |
| +type apiPipelineChannel struct { |
| + Name string |
| + Bake string |
| + Passkey bool |
| + PromoteApprovals int |
| + ChangeApprovals int |
| +} |
| + |
| +func (c apiPipelineChannel) MarshalJSON() ([]byte, error) { |
| + type rules struct { |
| + Passkey bool `json:"passkey,omitempty"` |
| + PromoteApprovals int `json:"promote_approvals,omitempty"` |
| + ChangeApprovals int `json:"change_approvals,omitempty"` |
| + } |
| + type wire struct { |
| + Name string `json:"name"` |
| + Bake string `json:"bake,omitempty"` |
| + Rules *rules `json:"rules,omitempty"` |
| + } |
| + w := wire{Name: c.Name, Bake: c.Bake} |
| + if c.Passkey || c.PromoteApprovals != 0 || c.ChangeApprovals != 0 { |
| + w.Rules = &rules{Passkey: c.Passkey, PromoteApprovals: c.PromoteApprovals, ChangeApprovals: c.ChangeApprovals} |
| + } |
| + return json.Marshal(w) |
| +} |
| + |
| +func (c *apiPipelineChannel) UnmarshalJSON(b []byte) error { |
| + var w struct { |
| + Name string `json:"name"` |
| + Bake string `json:"bake"` |
| + Rules struct { |
| + Passkey bool `json:"passkey"` |
| + PromoteApprovals int `json:"promote_approvals"` |
| + ChangeApprovals int `json:"change_approvals"` |
| + } `json:"rules"` |
| + } |
| + if err := json.Unmarshal(b, &w); err != nil { |
| + return err |
| + } |
| + c.Name, c.Bake = w.Name, w.Bake |
| + c.Passkey, c.PromoteApprovals, c.ChangeApprovals = w.Rules.Passkey, w.Rules.PromoteApprovals, w.Rules.ChangeApprovals |
| + return nil |
| +} |
| + |
| +// apiPipeline is GET/PUT .../pipeline's whole-body shape: the ordered |
| +// channel list, nothing else — Account/App/Actor/SetAt (release.Pipeline's |
| +// own extra fields) are server bookkeeping this client has no use for and |
| +// never sends back. |
| +type apiPipeline struct { |
| + Channels []apiPipelineChannel `json:"channels"` |
| +} |
| + |
| +// pipelineGet reads an app's declared pipeline. A nil return (with a nil |
| +// error) is spec §2's genuine "no pipeline.json" case — GET answers 200 with |
| +// a null "pipeline" field for it (handleAPIPipelineGet) — never conflated |
| +// with a read failure, which comes back as a non-nil error instead. |
| +func (s *apiSession) pipelineGet(ctx context.Context, a apiAcct, app string) (*apiPipeline, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/pipeline")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Pipeline *apiPipeline `json:"pipeline"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("pipeline: unreadable response: %w", err) |
| + } |
| + return res.Pipeline, nil |
| +} |
| + |
| +// pipelinePut replaces an app's channel list. Three outcomes, and a |
| +// `carlos pipeline` verb must tell them apart: |
| +// |
| +// - 200: the edit landed. The returned *apiPipeline is the console's own |
| +// echo of what it stored (SetAt included server-side, though this |
| +// client only reads Channels back out of it). |
| +// - 202: the edit touched a channel the CURRENTLY STORED pipeline already |
| +// guards with change_approvals (guardedEdit, apipipeline.go) — it |
| +// becomes a proposal at zero confirmations rather than landing. Returned |
| +// as *apiProposedError, not folded into the generic error path, so the |
| +// caller can print the "proposed" line instead of failing outright. |
| +// - anything else: a genuine refusal — validation (422), a lost CAS race |
| +// (409), a passkey-only channel with no approvals to propose against |
| +// (403, via consoleRefusal), or a store failure (502). |
| +func (s *apiSession) pipelinePut(ctx context.Context, a apiAcct, app string, channels []apiPipelineChannel) (*apiPipeline, error) { |
| + in := struct { |
| + Channels []apiPipelineChannel `json:"channels"` |
| + }{channels} |
| + status, body, err := s.putJSON(ctx, apiPath(a, app, "/pipeline"), in) |
| + if err != nil { |
| + return nil, err |
| + } |
| + switch status { |
| + case http.StatusOK: |
| + var res struct { |
| + Pipeline apiPipeline `json:"pipeline"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("pipeline: unreadable response: %w", err) |
| + } |
| + return &res.Pipeline, nil |
| + case http.StatusAccepted: |
| + return nil, proposedFromBody(body) |
| + } |
| + if refErr, ok := consoleRefusal(status, body); ok { |
| + return nil, refErr |
| + } |
| + return nil, apiErrorFromBody(status, body) |
| +} |
| + |
| +// apiPending is one row of `carlos pipeline`'s pending-changes list — the |
| +// CLI's read-only window onto a quorum in progress, since the CLI itself can |
| +// never confirm one (spec §4: confirmation is passkey-backed, browser-only). |
| +type apiPending struct { |
| + ID string `json:"id"` |
| + Kind string `json:"kind"` |
| + Summary string `json:"summary"` |
| + Confirmed int `json:"confirmed"` |
| + Needed int `json:"needed"` |
| +} |
| + |
| +// pendingList reads what an app is waiting on. Always read-only and |
| +// Publish-reachable, like channels/releases (handleAPIPending's own doc |
| +// comment) — a pending change names no secret. |
| +func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([]apiPending, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/pending")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Pending []apiPending `json:"pending"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("pending: unreadable response: %w", err) |
| + } |
| + return res.Pending, nil |
| +} |
| + |
| // deleteJSON runs one DELETE and hands back the status and raw body without |
| // interpreting a non-200 as an error itself — getJSON's plumbing with the |
| // method swapped, for the instance-record retire door (issue #66), the one |
diff --git a/apimode_test.go b/apimode_test.go| index e0bfd941a..3c6fb07c8 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -443,7 +443,7 @@ func TestAPISessionStableRefusal(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary") |
| + _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary", false) |
| if err == nil { |
| t.Fatal("promote against a stable refusal must error") |
| } |
| @@ -490,7 +490,7 @@ func TestAPISessionPromoteSuccess(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a") |
| + res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false) |
| if err != nil { |
| t.Fatalf("promote: %v", err) |
| } |
| @@ -511,7 +511,7 @@ func TestAPISessionPromoteNoOp(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a") |
| + res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false) |
| if err != nil { |
| t.Fatalf("promote: %v", err) |
| } |
| @@ -535,7 +535,7 @@ func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a") |
| + _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a", false) |
| if err == nil || !strings.Contains(err.Error(), "not a shipped release") { |
| t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err) |
| } |
| @@ -555,7 +555,7 @@ func TestAPISessionPromoteAccountGateNotFound(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a") |
| + _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a", false) |
| if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { |
| t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err) |
| } |
diff --git a/auth_e2e_test.go b/auth_e2e_test.go| index b743d1e9b..01e79e472 100644 |
| --- a/auth_e2e_test.go |
| +++ b/auth_e2e_test.go |
| @@ -551,7 +551,7 @@ func TestE2ECLIThroughConsole(t *testing.T) { |
| // apiSession level instead: the exact call runPromote's API-mode branch |
| // makes, against the same real server, over the same credential. |
| sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr} |
| - if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable"); err == nil { |
| + if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable", false); err == nil { |
| t.Fatal("promote to stable must refuse") |
| } else { |
| if !strings.Contains(err.Error(), ts.URL) { |
diff --git a/cli.go b/cli.go| index 102ffb7df..0d683e46d 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2053,20 +2053,21 @@ func runPromote(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - if *hotfix { |
| - // A hotfix bypasses the signed ladder's ordering check entirely — |
| - // an operator act, recorded as such (spec §3). The console's own |
| - // promote door (handleAPIPromote) has no hotfix field at all, so |
| - // there is no way to express this through the API even in |
| - // principle; refuse before ever dialing out, rather than let the |
| - // flag silently do nothing. |
| - log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console") |
| - } |
| + // -hotfix now travels over the wire (release pipelines v2, Task 10): |
| + // handleAPIPromote reads it strictly after its own rules decision |
| + // (its doc comment), so this is never itself a way past a channel's |
| + // declared rules — only past the ladder's ORDER check, exactly as it |
| + // is in direct mode below. |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, usage) |
| - res, err := sess.promote(context.Background(), acct, app2, version, channel) |
| + res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix) |
| if err != nil { |
| + var prop *apiProposedError |
| + if errors.As(err, &prop) { |
| + printProposed(stdout, prop) |
| + return |
| + } |
| log.Fatal(err) |
| } |
| if res.NoOp { |
| @@ -2450,6 +2451,11 @@ func runRollback(args []string) { |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, usage) |
| if err := sess.rollback(context.Background(), acct, app2, fs.Arg(0)); err != nil { |
| + var prop *apiProposedError |
| + if errors.As(err, &prop) { |
| + printProposed(stdout, prop) |
| + return |
| + } |
| log.Fatal(err) |
| } |
| fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), app2, fs.Arg(0)) |
diff --git a/color.go b/color.go| index 703055f83..7bd96b738 100644 |
| --- a/color.go |
| +++ b/color.go |
| @@ -150,6 +150,18 @@ func printUsing(w io.Writer, sess *apiSession) { |
| fmt.Fprintln(w, c.muted(fmt.Sprintf("using %s as %s", sess.console, sess.address))) |
| } |
| +// printProposed is the one line every guarded write (promote, rollback, a |
| +// `carlos pipeline` edit) prints when the console answers 202 instead of |
| +// landing the change (release pipelines v2 §4, Task 10 controller ruling 1): |
| +// a bearer token can never carry a passkey quorum, so the change is |
| +// recorded rather than refused outright, and the operator's next step is the |
| +// console URL, not a retry of this same command. |
| +func printProposed(w io.Writer, e *apiProposedError) { |
| + c := colorFor(w) |
| + fmt.Fprintf(w, "%s — needs %d confirmation(s); finish at %s\n pending id %s\n", |
| + c.warn("proposed"), e.Needed, e.ConsoleURL, e.PendingID) |
| +} |
| + |
| // errorWriter paints whole lines with the "something failed" style. Every |
| // verb in this binary reports failure through log.Fatal, so wrapping the |
| // standard logger's output once (main) colours all of them without touching |
diff --git a/deploy.go b/deploy.go| index 19a872778..7df356e3b 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -203,7 +203,9 @@ func runDeploy(args []string) { |
| if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, "", "", capsSet, false); err != nil { |
| log.Fatalf("deploy: ship: %v", err) |
| } |
| - res, err := sess.promote(ctx, acct, *app, *ver, channel) |
| + // `carlos deploy` carries no -hotfix flag of its own — it always ships in |
| + // order, so this is a plain false, never a refusal. |
| + res, err := sess.promote(ctx, acct, *app, *ver, channel, false) |
| if err != nil { |
| log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err |
| } |
diff --git a/main.go b/main.go| index 0029f3a87..9b5df302a 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -73,6 +73,8 @@ func main() { |
| runLogs(args) |
| case "channels": |
| runChannels(args) |
| + case "pipeline": |
| + runPipeline(args) |
| case "releases": |
| // One sub-verb, `retention` — the `carlos version target` judgment |
| // call: flags-only verbs stay flat until a word genuinely names a |
| @@ -155,6 +157,7 @@ Your apps — ship and run software on a CARLOS deployment |
| store object storage for an app: create | status | grant | rotate |
| rollback point a channel back at an earlier version |
| channels show what each of an app's channels is serving |
| + pipeline show or shape an app's release channels |
| releases every version shipped, newest first, with labels and tags |
| version target the semver ship counts iterations under: print it, or set |
| it — "carlos version target -app <a> 0.5.0" |
diff --git a/pipeline.go b/pipeline.go| new file mode 100644 |
| index 000000000..d6cd07d7e |
| --- /dev/null |
| +++ b/pipeline.go |
| @@ -0,0 +1,470 @@ |
| +// `carlos pipeline` — release pipelines v2 |
| +// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md): show |
| +// or shape an app's declared channel list, and see what an edit is waiting |
| +// on. |
| +// |
| +// API mode only, deliberately, like fleets and logs: a pipeline is |
| +// console-tracked policy (spec §2/§4), with nothing in the bucket for a |
| +// direct-mode CLI to read or write in its place. Every subcommand composes |
| +// its whole write client-side — GET the current list, mutate it, PUT the |
| +// whole thing back — because the console is the validator and the gate |
| +// (guardedEdit, member-count ceiling, shape checks all live server-side); |
| +// this file never second-guesses any of that, it only builds the list a |
| +// human meant and lets the console answer. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "strings" |
| +) |
| + |
| +const pipelineUsage = `usage: carlos pipeline <command> |
| + |
| +commands: |
| + show show an app's channels and any pending changes |
| + (this is also what a bare "carlos pipeline" |
| + runs) |
| + init -template edge-production|full-ladder |
| + replace the pipeline with a starter template |
| + set -channel <name> [-bake <dur>] [-passkey[=false]] |
| + [-promote-approvals N] [-change-approvals N] |
| + edit one existing channel's bake window or |
| + rules — flags not given are left as they were |
| + add -channel <name> [-after <name>] [-bake <dur>] [-passkey] |
| + [-promote-approvals N] [-change-approvals N] |
| + insert a new channel (default: appended at the |
| + end of the list; -after inserts right after |
| + that existing channel) |
| + remove -channel <name> drop a channel from the pipeline |
| + |
| +Every command above also takes [-app <a>] [-account <sqid-or-name>] |
| +[-account-id <sqid>] [-console <origin>] — the same account/app resolution |
| +every other API-mode verb uses (defaults apply when a flag is omitted and |
| +only one candidate exists). |
| + |
| +carlos pipeline works through a logged-in console (carlos auth login) — |
| +there is no bucket-direct pipeline editor to fall back to; -bake, -passkey, |
| +-promote-approvals and -change-approvals set release.ChannelRules exactly as |
| +the console's own pipeline settings page does. promote and rollback are |
| +unaffected in direct mode. |
| + |
| +set/add/remove all read the CURRENT channel list first and PUT the whole |
| +list back — an edit that touches a channel the stored pipeline already |
| +guards with -change-approvals does not land from here: the console turns it |
| +into a proposal instead (0 confirmations; finish with the printed console |
| +URL), exactly as it does for a guarded promote or rollback. |
| +` |
| + |
| +// runPipeline dispatches `carlos pipeline`'s subcommands. Unlike RunEnv/ |
| +// RunFleets/RunLedger, an unrecognized first word is not itself an error — |
| +// it falls through to runPipelineShow's own flag parsing, which is where |
| +// "show" lives as the DEFAULT (bare `carlos pipeline`, or `carlos pipeline |
| +// -app foo` with no subcommand word at all) — the same shape runInstances |
| +// (cli.go) gives its own bare-listing default. |
| +func runPipeline(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "init": |
| + runPipelineInit(args[1:]) |
| + return |
| + case "set": |
| + runPipelineSet(args[1:]) |
| + return |
| + case "add": |
| + runPipelineAdd(args[1:]) |
| + return |
| + case "remove": |
| + runPipelineRemove(args[1:]) |
| + return |
| + case "show": |
| + runPipelineShow(args[1:]) |
| + return |
| + } |
| + } |
| + runPipelineShow(args) |
| +} |
| + |
| +// pipelineSignedInSession resolves API mode and fatals with a |
| +// pipeline-specific message when this shell is in direct bucket mode — |
| +// mirrors fleetsSignedInSession (fleets.go) exactly: a pipeline is |
| +// console-tracked policy with nothing in the bucket to fall back to, so |
| +// there is no second, bucket-direct implementation of any of this to keep |
| +// in sync. |
| +func pipelineSignedInSession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos pipeline works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A pipeline is console-tracked\n" + |
| + "policy with no bucket-direct editor: unset the store env and run\n" + |
| + "carlos auth login -console <url> instead. promote and rollback are unaffected.") |
| + } |
| + return sess |
| +} |
| + |
| +// pipelineFlagSet builds the -app/-account/-account-id/-console flags every |
| +// pipeline subcommand shares, with fs.Usage set to the shared doc block |
| +// (the "current style" this binary's more heavily-documented verbs use — |
| +// logs.go's runLogs is the model) so "-h" on any subcommand explains every |
| +// subcommand, not just the one typed. |
| +func pipelineFlagSet(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) { |
| + fs = flag.NewFlagSet(name, flag.ExitOnError) |
| + app = fs.String("app", "", "app name") |
| + // API mode never opens the local registry — the third return |
| + // (accountFlags' direct-mode resolver) has no use here and is dropped. |
| + acctRaw, acctIDRaw, _ = accountFlags(fs) |
| + consoleFlag = fs.String("console", "", "console origin to work through (default: the only logged-in one)") |
| + fs.Usage = func() { fmt.Fprint(os.Stderr, pipelineUsage) } |
| + return fs, app, acctRaw, acctIDRaw, consoleFlag |
| +} |
| + |
| +// pipelineOrDefault is GetPipeline's (nil, nil) "no pipeline.json" case |
| +// turned into the same one-channel starter release.DefaultPipelineChannels() |
| +// gives the console's own settings page (pipelinepage.go) — so a fresh app's |
| +// `carlos pipeline show` reads the same "edge" starter row the console would |
| +// draft for it, and `set`/`add`/`remove` compose on top of that same |
| +// starting point rather than an empty list. Always returns a fresh slice: |
| +// callers mutate what they get back, and must never alias a cached response. |
| +func pipelineOrDefault(p *apiPipeline) []apiPipelineChannel { |
| + if p == nil || len(p.Channels) == 0 { |
| + return []apiPipelineChannel{{Name: "edge"}} |
| + } |
| + out := make([]apiPipelineChannel, len(p.Channels)) |
| + copy(out, p.Channels) |
| + return out |
| +} |
| + |
| +// pipelineTemplate is the two starters spec §5 names, byte-for-byte what the |
| +// console's own settings page offers (edgeProductionTemplate/ |
| +// fullLadderTemplate, internal/console/pipelinepage.go) — CONTROLLER RULING: |
| +// any template that sets a rule on a channel also sets change_approvals, so |
| +// a passkey nobody can strip with one bearer-token PUT. |
| +func pipelineTemplate(name string) ([]apiPipelineChannel, bool) { |
| + switch name { |
| + case "edge-production": |
| + return []apiPipelineChannel{ |
| + {Name: "edge"}, |
| + {Name: "production", Bake: "24h", Passkey: true, ChangeApprovals: 1}, |
| + }, true |
| + case "full-ladder": |
| + return []apiPipelineChannel{ |
| + {Name: "edge"}, |
| + {Name: "beta", Bake: "24h"}, |
| + {Name: "stable", Bake: "72h", Passkey: true, ChangeApprovals: 1}, |
| + }, true |
| + default: |
| + return nil, false |
| + } |
| +} |
| + |
| +// applyPipelinePut is set/add/remove/init's shared tail: PUT the composed |
| +// list, and tell a landed write, a proposal, and a genuine refusal apart — |
| +// the same errors.As check runPromote/runRollback use, so a |
| +// `carlos pipeline` edit that the console proposes instead of landing prints |
| +// the SAME "proposed" line those verbs print rather than the misleading |
| +// "unexpected response (status 202)" apiErrorFromBody would otherwise give |
| +// it. |
| +func applyPipelinePut(sess *apiSession, acct apiAcct, app string, channels []apiPipelineChannel) { |
| + set, err := sess.pipelinePut(context.Background(), acct, app, channels) |
| + if err != nil { |
| + var prop *apiProposedError |
| + if errors.As(err, &prop) { |
| + printProposed(stdout, prop) |
| + return |
| + } |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "%s the pipeline for %s\n", colorFor(stdout).ok("set"), app) |
| + printPipelineChannels(stdout, set.Channels) |
| +} |
| + |
| +// printPipelineChannels is `show`'s multi-channel rendering, and every write |
| +// verb's confirmation echo: ORDER/BAKE/RULES, one row per channel, in |
| +// pipeline order — position IS the promotion order, which is why it is |
| +// spelled into the ORDER cell rather than left to be inferred from row |
| +// position alone (a table copied into a chat message still says "2 beta"). |
| +func printPipelineChannels(w io.Writer, channels []apiPipelineChannel) { |
| + t := newTable(w) |
| + fmt.Fprintln(t, "ORDER\tBAKE\tRULES") |
| + for i, c := range channels { |
| + fmt.Fprintf(t, "%d %s\t%s\t%s\n", i+1, c.Name, pipelineBakeCell(c.Bake), pipelineRulesCell(c)) |
| + } |
| + t.Flush() |
| +} |
| + |
| +func pipelineBakeCell(bake string) string { |
| + if bake == "" { |
| + return "-" |
| + } |
| + return bake |
| +} |
| + |
| +// pipelineRulesCell names a channel's ChannelRules in the same vocabulary |
| +// -promote-approvals/-change-approvals/-passkey use, so what a human reads |
| +// here is what they'd type to reproduce it. |
| +func pipelineRulesCell(c apiPipelineChannel) string { |
| + var parts []string |
| + if c.Passkey { |
| + parts = append(parts, "passkey") |
| + } |
| + if c.PromoteApprovals > 0 { |
| + parts = append(parts, fmt.Sprintf("promote:%d", c.PromoteApprovals)) |
| + } |
| + if c.ChangeApprovals > 0 { |
| + parts = append(parts, fmt.Sprintf("change:%d", c.ChangeApprovals)) |
| + } |
| + if len(parts) == 0 { |
| + return "-" |
| + } |
| + return strings.Join(parts, ", ") |
| +} |
| + |
| +// printPipelinePending renders `show`'s pending-changes section — nothing at |
| +// all when there is nothing pending, matching the console app page's own |
| +// card, which likewise only appears when PendingChangesFor returns rows. |
| +func printPipelinePending(w io.Writer, pending []apiPending) { |
| + if len(pending) == 0 { |
| + return |
| + } |
| + fmt.Fprintln(w) |
| + fmt.Fprintln(w, colorFor(w).head("pending:")) |
| + for _, pc := range pending { |
| + fmt.Fprintf(w, " %s — %d/%d confirmed (id %s)\n", pc.Summary, pc.Confirmed, pc.Needed, pc.ID) |
| + } |
| +} |
| + |
| +// runPipelineShow is `carlos pipeline` and `carlos pipeline show` alike — |
| +// see runPipeline's doc comment for why an unrecognized subcommand word also |
| +// lands here rather than being refused outright. |
| +func runPipelineShow(args []string) { |
| + fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline show") |
| + fs.Parse(args) |
| + if fs.NArg() != 0 { |
| + log.Fatal(pipelineUsage) |
| + } |
| + |
| + sess := pipelineSignedInSession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, pipelineUsage) |
| + |
| + p, err := sess.pipelineGet(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + channels := pipelineOrDefault(p) |
| + // A single-channel pipeline (the "no pipeline.json" default, or a real |
| + // one-channel pipeline someone composed down to that) is not shown as a |
| + // one-row ladder table — there is no order or rule to tabulate yet, and |
| + // a table with one row reads as ceremony around nothing (Task 10 |
| + // controller ruling 5). The hint names the one command that grows it. |
| + if len(channels) == 1 { |
| + fmt.Fprintf(stdout, "%s — one channel, no pipeline set (carlos pipeline init -template edge-production|full-ladder adds more)\n", |
| + channels[0].Name) |
| + } else { |
| + printPipelineChannels(stdout, channels) |
| + } |
| + |
| + pending, err := sess.pendingList(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + printPipelinePending(stdout, pending) |
| +} |
| + |
| +// runPipelineInit replaces the whole pipeline with one of the two starter |
| +// templates spec §5 names — the console-side equivalent of clicking a |
| +// template button on the settings page, over the API. |
| +func runPipelineInit(args []string) { |
| + fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline init") |
| + template := fs.String("template", "", "starter pipeline: edge-production | full-ladder") |
| + fs.Parse(args) |
| + |
| + channels, ok := pipelineTemplate(*template) |
| + if !ok { |
| + log.Fatal("usage: carlos pipeline init -template edge-production|full-ladder [-app <a>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + |
| + sess := pipelineSignedInSession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, pipelineUsage) |
| + |
| + applyPipelinePut(sess, acct, app2, channels) |
| +} |
| + |
| +// pipelinePolicyFlags are the four rule-shaping flags set and add share. |
| +// Registered once so their help text and names can never drift between the |
| +// two subcommands. |
| +func pipelinePolicyFlags(fs *flag.FlagSet) (bake *string, passkey *bool, promoteApprovals, changeApprovals *int) { |
| + bake = fs.String("bake", "", "bake window before this channel is eligible to adopt (Go duration, e.g. 24h; \"\" clears it)") |
| + passkey = fs.Bool("passkey", false, "require a console passkey to promote into or edit this channel; -passkey=false clears it") |
| + promoteApprovals = fs.Int("promote-approvals", 0, "promotions into this channel need this many console confirmations (0 clears the rule)") |
| + changeApprovals = fs.Int("change-approvals", 0, "pipeline edits that touch this channel need this many console confirmations (0 clears the rule)") |
| + return bake, passkey, promoteApprovals, changeApprovals |
| +} |
| + |
| +// runPipelineSet edits ONE existing channel's bake window or rules. It GETs |
| +// the full current list, applies only the flags actually named on the |
| +// command line (fs.Visit — the same tri-state pattern runRoute's -hibernate/ |
| +// -backing/-unit already use, cli.go) so an unmentioned flag never clobbers |
| +// a rule someone else set, and PUTs the whole list back. |
| +func runPipelineSet(args []string) { |
| + fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline set") |
| + channel := fs.String("channel", "", "channel to edit") |
| + bake, passkey, promoteApprovals, changeApprovals := pipelinePolicyFlags(fs) |
| + fs.Parse(args) |
| + usage := "usage: carlos pipeline set -channel <name> [-app <a>] [-bake <dur>] [-passkey[=false]] " + |
| + "[-promote-approvals N] [-change-approvals N] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if *channel == "" { |
| + log.Fatal(usage) |
| + } |
| + bakeSet, passkeySet, promoteSet, changeSet := false, false, false, false |
| + fs.Visit(func(f *flag.Flag) { |
| + switch f.Name { |
| + case "bake": |
| + bakeSet = true |
| + case "passkey": |
| + passkeySet = true |
| + case "promote-approvals": |
| + promoteSet = true |
| + case "change-approvals": |
| + changeSet = true |
| + } |
| + }) |
| + |
| + sess := pipelineSignedInSession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + |
| + p, err := sess.pipelineGet(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + channels := pipelineOrDefault(p) |
| + idx := -1 |
| + for i, c := range channels { |
| + if c.Name == *channel { |
| + idx = i |
| + break |
| + } |
| + } |
| + if idx == -1 { |
| + log.Fatalf("pipeline set: %q is not a channel of this pipeline — carlos pipeline add -channel %s to create it", *channel, *channel) |
| + } |
| + if bakeSet { |
| + channels[idx].Bake = *bake |
| + } |
| + if passkeySet { |
| + channels[idx].Passkey = *passkey |
| + } |
| + if promoteSet { |
| + channels[idx].PromoteApprovals = *promoteApprovals |
| + } |
| + if changeSet { |
| + channels[idx].ChangeApprovals = *changeApprovals |
| + } |
| + applyPipelinePut(sess, acct, app2, channels) |
| +} |
| + |
| +// runPipelineAdd inserts a new channel. -after names an existing channel to |
| +// insert immediately behind; omitted, the new channel is appended at the |
| +// end (entering the pipeline as its newest, most-downstream rung — never |
| +// silently made the ENTRY channel, which -after edge would spell instead). |
| +func runPipelineAdd(args []string) { |
| + fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline add") |
| + channel := fs.String("channel", "", "channel to add") |
| + after := fs.String("after", "", "insert immediately after this existing channel (default: append at the end)") |
| + bake, passkey, promoteApprovals, changeApprovals := pipelinePolicyFlags(fs) |
| + fs.Parse(args) |
| + usage := "usage: carlos pipeline add -channel <name> [-after <name>] [-app <a>] [-bake <dur>] [-passkey] " + |
| + "[-promote-approvals N] [-change-approvals N] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if *channel == "" { |
| + log.Fatal(usage) |
| + } |
| + |
| + sess := pipelineSignedInSession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + |
| + p, err := sess.pipelineGet(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + channels := pipelineOrDefault(p) |
| + for _, c := range channels { |
| + if c.Name == *channel { |
| + log.Fatalf("pipeline add: %q already exists in this pipeline — carlos pipeline set to edit it", *channel) |
| + } |
| + } |
| + entry := apiPipelineChannel{ |
| + Name: *channel, Bake: *bake, Passkey: *passkey, |
| + PromoteApprovals: *promoteApprovals, ChangeApprovals: *changeApprovals, |
| + } |
| + pos := len(channels) |
| + if *after != "" { |
| + pos = -1 |
| + for i, c := range channels { |
| + if c.Name == *after { |
| + pos = i + 1 |
| + break |
| + } |
| + } |
| + if pos == -1 { |
| + log.Fatalf("pipeline add: -after %q does not name an existing channel of this pipeline", *after) |
| + } |
| + } |
| + next := make([]apiPipelineChannel, 0, len(channels)+1) |
| + next = append(next, channels[:pos]...) |
| + next = append(next, entry) |
| + next = append(next, channels[pos:]...) |
| + applyPipelinePut(sess, acct, app2, next) |
| +} |
| + |
| +// runPipelineRemove drops one channel from the pipeline. It never refuses on |
| +// emptying the list to zero channels itself — SetPipeline's own validation |
| +// (internal/release/pipeline.go) is where that shape check belongs, exactly |
| +// as it is the one place every other pipeline shape rule is enforced. |
| +func runPipelineRemove(args []string) { |
| + fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline remove") |
| + channel := fs.String("channel", "", "channel to remove") |
| + fs.Parse(args) |
| + usage := "usage: carlos pipeline remove -channel <name> [-app <a>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if *channel == "" { |
| + log.Fatal(usage) |
| + } |
| + |
| + sess := pipelineSignedInSession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, usage) |
| + |
| + p, err := sess.pipelineGet(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + channels := pipelineOrDefault(p) |
| + next := make([]apiPipelineChannel, 0, len(channels)) |
| + found := false |
| + for _, c := range channels { |
| + if c.Name == *channel { |
| + found = true |
| + continue |
| + } |
| + next = append(next, c) |
| + } |
| + if !found { |
| + log.Fatalf("pipeline remove: %q is not a channel of this pipeline", *channel) |
| + } |
| + applyPipelinePut(sess, acct, app2, next) |
| +} |
diff --git a/pipeline_test.go b/pipeline_test.go| new file mode 100644 |
| index 000000000..28948b6c5 |
| --- /dev/null |
| +++ b/pipeline_test.go |
| @@ -0,0 +1,549 @@ |
| +// Tests for `carlos pipeline` (Task 10, release pipelines v2) and the |
| +// promote/rollback proposed-response handling it shares with promote and |
| +// rollback. In-process, stub-console pattern (TestPromoteVerbAPIMode, |
| +// apimode_test.go) throughout: useTempCredentials/clearDeploymentEnv/ |
| +// requireNoRegistryTouch/loginTo/captureStdout, an httptest server standing |
| +// in for the console. |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "reflect" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| +) |
| + |
| +// TestPipelineShowVerbAPIMode pins `carlos pipeline show`'s read path: it |
| +// GETs the pipeline and the pending list, and renders a multi-channel |
| +// pipeline as a table naming each channel's order, bake and rules, with any |
| +// pending change listed beneath. |
| +func TestPipelineShowVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var gotPipelinePath, gotPendingPath string |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + gotPipelinePath = r.URL.Path |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ |
| + "channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, |
| + }, |
| + }}) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) { |
| + gotPendingPath = r.URL.Path |
| + json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{ |
| + {"id": "pc1", "kind": "policy", "summary": "change the pipeline to edge → production", "confirmed": 0, "needed": 1}, |
| + }}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if gotPipelinePath != "/api/cli/apps/"+apiTestAccount+"/titogo/pipeline" { |
| + t.Fatalf("pipeline GET path = %q", gotPipelinePath) |
| + } |
| + if gotPendingPath != "/api/cli/apps/"+apiTestAccount+"/titogo/pending" { |
| + t.Fatalf("pending GET path = %q", gotPendingPath) |
| + } |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + edgeAt := strings.Index(out, "edge") |
| + prodAt := strings.Index(out, "production") |
| + if edgeAt < 0 || prodAt < 0 || edgeAt > prodAt { |
| + t.Fatalf("stdout = %q, want edge listed before production", out) |
| + } |
| + if !strings.Contains(out, "24h") { |
| + t.Fatalf("stdout = %q, want the bake window", out) |
| + } |
| + if !strings.Contains(out, "passkey") || !strings.Contains(out, "change:1") { |
| + t.Fatalf("stdout = %q, want the rules rendered", out) |
| + } |
| + if !strings.Contains(out, "change the pipeline to edge → production") || !strings.Contains(out, "0/1") || !strings.Contains(out, "pc1") { |
| + t.Fatalf("stdout = %q, want the pending change listed with its progress and id", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineShowOneChannelNoLadderCeremony pins controller ruling 5: a |
| +// one-channel pipeline (here, the "no pipeline.json" default) prints a |
| +// single line, not a one-row table, with a hint that init grows it. |
| +func TestPipelineShowOneChannelNoLadderCeremony(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": nil}) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{}}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "edge") { |
| + t.Fatalf("stdout = %q, want the single channel named", out) |
| + } |
| + if !strings.Contains(out, "carlos pipeline init") { |
| + t.Fatalf("stdout = %q, want the init hint", out) |
| + } |
| + if strings.Contains(out, "ORDER") || strings.Contains(out, "RULES") { |
| + t.Fatalf("stdout = %q, must not render ladder-table ceremony for one channel", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineInitTemplateAPIMode pins the edge-production template's exact |
| +// wire shape (controller ruling 3): a template that sets a rule also sets |
| +// change_approvals, and promote_approvals (never set by either template) is |
| +// omitted entirely rather than sent as 0. |
| +func TestPipelineInitTemplateAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + var gotBody []byte |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + var buf [4096]byte |
| + n, _ := r.Body.Read(buf[:]) |
| + gotBody = buf[:n] |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ |
| + "channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, |
| + }, |
| + }}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPipeline([]string{"init", "-template", "edge-production", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d PUT requests, want 1", got) |
| + } |
| + |
| + var sent struct { |
| + Channels []map[string]any `json:"channels"` |
| + } |
| + if err := json.Unmarshal(gotBody, &sent); err != nil { |
| + t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) |
| + } |
| + want := []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}}, |
| + } |
| + if !reflect.DeepEqual(sent.Channels, want) { |
| + t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) |
| + } |
| + if !strings.Contains(buf.String(), "titogo") { |
| + t.Fatalf("stdout = %q, want it to name the app", buf.String()) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineFullLadderTemplateAPIMode pins the second template's exact |
| +// shape alongside the first, per controller ruling 3. |
| +func TestPipelineFullLadderTemplateAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var gotBody []byte |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + var buf [4096]byte |
| + n, _ := r.Body.Read(buf[:]) |
| + gotBody = buf[:n] |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + captureStdout(t) |
| + runPipeline([]string{"init", "-template", "full-ladder", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + var sent struct { |
| + Channels []map[string]any `json:"channels"` |
| + } |
| + if err := json.Unmarshal(gotBody, &sent); err != nil { |
| + t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) |
| + } |
| + want := []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "beta", "bake": "24h"}, |
| + {"name": "stable", "bake": "72h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}}, |
| + } |
| + if !reflect.DeepEqual(sent.Channels, want) { |
| + t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineSetComposesFullList pins set's compose-then-PUT contract: it |
| +// GETs the current list, mutates ONLY the named channel's ONE named flag |
| +// (-bake), and PUTs every channel back — including the untouched one |
| +// verbatim, and the edited one's OTHER rules (passkey, change_approvals) |
| +// preserved rather than clobbered by the flags this invocation didn't name. |
| +func TestPipelineSetComposesFullList(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var gotBody []byte |
| + var getCount, putCount int32 |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&getCount, 1) |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ |
| + "channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, |
| + }, |
| + }}) |
| + }) |
| + mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&putCount, 1) |
| + var buf [4096]byte |
| + n, _ := r.Body.Read(buf[:]) |
| + gotBody = buf[:n] |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + captureStdout(t) |
| + runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + if got := atomic.LoadInt32(&getCount); got != 1 { |
| + t.Fatalf("server got %d GET requests, want 1", got) |
| + } |
| + if got := atomic.LoadInt32(&putCount); got != 1 { |
| + t.Fatalf("server got %d PUT requests, want 1", got) |
| + } |
| + |
| + var sent struct { |
| + Channels []map[string]any `json:"channels"` |
| + } |
| + if err := json.Unmarshal(gotBody, &sent); err != nil { |
| + t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) |
| + } |
| + want := []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "48h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}}, |
| + } |
| + if !reflect.DeepEqual(sent.Channels, want) { |
| + t.Fatalf("PUT channels = %#v, want %#v (bake changed, everything else preserved)", sent.Channels, want) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineAddInsertsAfter pins add's -after insertion and its own |
| +// compose-then-PUT contract: it GETs the current list and PUTs it back with |
| +// the new channel spliced in immediately after the named one, the existing |
| +// channels otherwise untouched. |
| +func TestPipelineAddInsertsAfter(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var gotBody []byte |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ |
| + "channels": []map[string]any{{"name": "edge"}, {"name": "stable", "bake": "72h"}}, |
| + }}) |
| + }) |
| + mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + var buf [4096]byte |
| + n, _ := r.Body.Read(buf[:]) |
| + gotBody = buf[:n] |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + captureStdout(t) |
| + runPipeline([]string{"add", "-channel", "beta", "-after", "edge", "-bake", "24h", |
| + "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + var sent struct { |
| + Channels []map[string]any `json:"channels"` |
| + } |
| + if err := json.Unmarshal(gotBody, &sent); err != nil { |
| + t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) |
| + } |
| + want := []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "beta", "bake": "24h"}, |
| + {"name": "stable", "bake": "72h"}, |
| + } |
| + if !reflect.DeepEqual(sent.Channels, want) { |
| + t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineRemoveDropsChannel pins remove's compose-then-PUT contract: |
| +// it GETs the current list and PUTs it back without the named channel, the |
| +// rest untouched and in the same order. |
| +func TestPipelineRemoveDropsChannel(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var gotBody []byte |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ |
| + "channels": []map[string]any{{"name": "edge"}, {"name": "beta", "bake": "24h"}, {"name": "stable", "bake": "72h"}}, |
| + }}) |
| + }) |
| + mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + var buf [4096]byte |
| + n, _ := r.Body.Read(buf[:]) |
| + gotBody = buf[:n] |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + captureStdout(t) |
| + runPipeline([]string{"remove", "-channel", "beta", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + var sent struct { |
| + Channels []map[string]any `json:"channels"` |
| + } |
| + if err := json.Unmarshal(gotBody, &sent); err != nil { |
| + t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) |
| + } |
| + want := []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "stable", "bake": "72h"}, |
| + } |
| + if !reflect.DeepEqual(sent.Channels, want) { |
| + t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelinePutProposedPrintsConsoleURL pins a guarded pipeline PUT's own |
| +// 202 handling — the same proposed-response contract as promote/rollback, |
| +// exercised through `carlos pipeline set` this time. |
| +func TestPipelinePutProposedPrintsConsoleURL(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + consoleURL := "" |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ |
| + "channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, |
| + }, |
| + }}) |
| + }) |
| + mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusAccepted) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "proposed", "pending_id": "pc-99", "needed": 1, "confirmed": 0, |
| + "console_url": consoleURL, |
| + }) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo" |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "proposed") || !strings.Contains(out, consoleURL) || !strings.Contains(out, "pc-99") { |
| + t.Fatalf("stdout = %q, want proposed + console URL + pending id", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPromoteProposedPrintsConsoleURL pins runPromote's handling of a 202: a |
| +// guarded channel's PROMOTE_APPROVALS rule turns the request into a |
| +// proposal, and the CLI must say so — naming how many confirmations it |
| +// needs and where to finish — rather than reporting the generic "unexpected |
| +// response (status 202)" (the CLI-lies-about-its-outcome bug this task |
| +// exists to close). |
| +func TestPromoteProposedPrintsConsoleURL(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + consoleURL := "" |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusAccepted) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "proposed", "pending_id": "pc-42", "needed": 2, "confirmed": 0, |
| + "console_url": consoleURL, |
| + }) |
| + })) |
| + defer ts.Close() |
| + consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo" |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "production"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "proposed") { |
| + t.Fatalf("stdout = %q, want it to say proposed", out) |
| + } |
| + if !strings.Contains(out, "2") { |
| + t.Fatalf("stdout = %q, want the confirmations-needed count (2)", out) |
| + } |
| + if !strings.Contains(out, consoleURL) { |
| + t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestRollbackProposedPrintsConsoleURL is TestPromoteProposedPrintsConsoleURL's |
| +// twin for `carlos rollback` (controller ruling 1): Task 8 added the 202 on |
| +// the server side, but runRollback still reported the generic "unexpected |
| +// response (status 202)" until this task extended it exactly as promote was |
| +// extended. |
| +func TestRollbackProposedPrintsConsoleURL(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + consoleURL := "" |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusAccepted) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "proposed", "pending_id": "pc-43", "needed": 1, "confirmed": 0, |
| + "console_url": consoleURL, |
| + }) |
| + })) |
| + defer ts.Close() |
| + consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo" |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "production"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "proposed") { |
| + t.Fatalf("stdout = %q, want it to say proposed", out) |
| + } |
| + if !strings.Contains(out, "1") { |
| + t.Fatalf("stdout = %q, want the confirmations-needed count (1)", out) |
| + } |
| + if !strings.Contains(out, consoleURL) { |
| + t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL) |
| + } |
| + if !strings.Contains(out, "pc-43") { |
| + t.Fatalf("stdout = %q, want the pending id", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPromoteHotfixSentToAPI pins controller ruling 2: -hotfix is no longer |
| +// refused in API mode (cli.go used to log.Fatal before ever dialing out, |
| +// claiming the console "has no hotfix field at all" — now false), and the |
| +// field actually reaches the wire. |
| +func TestPromoteHotfixSentToAPI(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + var req struct { |
| + Version string `json:"version"` |
| + Channel string `json:"channel"` |
| + Hotfix bool `json:"hotfix"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + if !req.Hotfix { |
| + t.Errorf("hotfix = %v, want true", req.Hotfix) |
| + } |
| + if req.Version != "abc1234" || req.Channel != "stable" { |
| + t.Errorf("promote body = %+v", req) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "-hotfix", "abc1234", "stable"}) |
| + |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + if want := "promoted titogo abc1234 -> stable"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("stdout = %q, want %q", buf.String(), want) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPipelineDirectModeRefused pins the direct/bucket-mode refusal: there |
| +// is no bucket-side pipeline editor, so a `carlos pipeline` verb must name |
| +// the console requirement rather than silently doing nothing or panicking |
| +// on a nil session. Exercised at the resolveMode level, mirroring how |
| +// runPromote's stable refusal is pinned in auth_e2e_test.go — runPipeline |
| +// itself calls log.Fatal (os.Exit) on this path, which an in-process test |
| +// cannot survive. |
| +func TestPipelineDirectModeRefused(t *testing.T) { |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + |
| + sess, err := resolveMode("") |
| + if err != nil { |
| + t.Fatalf("resolveMode: %v", err) |
| + } |
| + if sess != nil { |
| + t.Fatal("want direct mode (nil session) with CARLOS_DEPLOYMENT_DIR set") |
| + } |
| +} |