carlos / platform Public

deploy: resolve the pipeline entry channel; channels API carries the pipeline envelope

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3f2e3407372ecc0ac11510bf269221c21f822ae5 parent bc52489
8 files changed, +472 −43
  • apimode.go +40 −7
  • apimode_test.go +65 −1
  • cli.go +2 −1
  • deploy.go +68 −14
  • deploy_test.go +164 −15
  • deploy_wizard_test.go +4 −4
  • internal/console/apicli.go +35 −1
  • internal/console/apicli_test.go +94 −0
diff --git a/apimode.go b/apimode.go
index cd39491ff..08f3090b4 100644
--- a/apimode.go
+++ b/apimode.go
@@ -320,6 +320,24 @@ type apiChannel struct {
Err string
}
+// apiChannelsResult is what `channels` returns: the channel rows plus the
+// app's declared pipeline (release pipelines v2, Task 11), folded down to
+// the two facts a deploy-side caller actually needs — where a release
+// enters and the full order — rather than re-exposing release.Pipeline's
+// whole shape over the wire a second time.
+//
+// PipelineEntry == "" means "no pipeline" — a legacy app on the implicit
+// edge/beta/stable ladder, OR a console the pipeline read failed against
+// (handleAPIChannels degrades a read failure to omitting the key rather
+// than failing the listing; this client-side type does not need to tell
+// the two apart, since either way there is no entry channel to fall back
+// to and the caller's existing no-pipeline behaviour is exactly right).
+type apiChannelsResult struct {
+ Channels []apiChannel
+ PipelineEntry string
+ PipelineChannels []string
+}
+
// apiPromoteResult and apiShipResult carry the one fact Task 5 needs to
// print a parity message: whether the console did nothing because the
// request already matched what's live (a promote of a version already on
@@ -1044,14 +1062,15 @@ func decodeRetention(status int, body []byte, err error) (apiRetentionResult, er
return res, nil
}
-// channels reads one app's channel ladder through the console.
-func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]apiChannel, error) {
+// channels reads one app's channel ladder — and its pipeline envelope, when
+// the console carries one — through the console.
+func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiChannelsResult, error) {
status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels"))
if err != nil {
- return nil, err
+ return apiChannelsResult{}, err
}
if status != http.StatusOK {
- return nil, apiErrorFromBody(status, body)
+ return apiChannelsResult{}, apiErrorFromBody(status, body)
}
var res struct {
Channels []struct {
@@ -1061,17 +1080,31 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]api
Rung string `json:"rung"`
Error string `json:"error"`
} `json:"channels"`
+ // Pipeline is nil for both "no pipeline key at all" (an older
+ // console) and an explicit JSON null (a confirmed-legacy app) —
+ // this client has no need to tell those two apart; see
+ // apiChannelsResult's doc comment. Its shape is the REDUCED view
+ // handleAPIChannels sends (apicli.go), not the full release.Pipeline
+ // object `carlos pipeline` reads.
+ Pipeline *struct {
+ Entry string `json:"entry"`
+ Channels []string `json:"channels"`
+ } `json:"pipeline"`
}
if err := json.Unmarshal(body, &res); err != nil {
- return nil, fmt.Errorf("channels: unreadable response: %w", err)
+ return apiChannelsResult{}, fmt.Errorf("channels: unreadable response: %w", err)
}
- out := make([]apiChannel, len(res.Channels))
+ out := apiChannelsResult{Channels: make([]apiChannel, len(res.Channels))}
for i, ch := range res.Channels {
- out[i] = apiChannel{
+ out.Channels[i] = apiChannel{
Channel: ch.Channel, Version: ch.Version,
PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error,
}
}
+ if res.Pipeline != nil {
+ out.PipelineEntry = res.Pipeline.Entry
+ out.PipelineChannels = res.Pipeline.Channels
+ }
return out, nil
}
diff --git a/apimode_test.go b/apimode_test.go
index 3c6fb07c8..1368be30b 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -594,10 +594,11 @@ func TestAPISessionChannels(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- chans, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
+ res, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
if err != nil {
t.Fatalf("channels: %v", err)
}
+ chans := res.Channels
if len(chans) != 2 {
t.Fatalf("channels = %+v, want 2 rows", chans)
}
@@ -607,6 +608,69 @@ func TestAPISessionChannels(t *testing.T) {
if chans[1].Channel != "broken" || chans[1].Err != "signature mismatch" {
t.Fatalf("row 1 = %+v", chans[1])
}
+ // No "pipeline" key at all (an older console, or handleAPIChannels'
+ // own degrade-on-read-failure branch): PipelineEntry stays "" — the
+ // same zero value a confirmed-legacy null decodes to, since this
+ // client has no need to tell the two apart (apiChannelsResult's doc
+ // comment).
+ if res.PipelineEntry != "" || len(res.PipelineChannels) != 0 {
+ t.Fatalf("no pipeline key = %+v, want a zero-value pipeline", res)
+ }
+}
+
+// TestAPISessionChannelsPipelineEnvelope pins the client-side decode of the
+// additive "pipeline" envelope key (release pipelines v2, Task 11):
+// PipelineEntry is the pipeline's first channel, PipelineChannels is every
+// channel in order — and an explicit JSON null (the confirmed-legacy shape
+// handleAPIChannels answers) decodes to the same zero value as the key
+// being absent altogether.
+func TestAPISessionChannelsPipelineEnvelope(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "channels": []map[string]any{},
+ "pipeline": map[string]any{
+ "entry": "edge", "channels": []string{"edge", "production"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ res, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
+ if err != nil {
+ t.Fatalf("channels: %v", err)
+ }
+ if res.PipelineEntry != "edge" {
+ t.Fatalf("PipelineEntry = %q, want edge", res.PipelineEntry)
+ }
+ if want := []string{"edge", "production"}; !equalStrings(res.PipelineChannels, want) {
+ t.Fatalf("PipelineChannels = %v, want %v", res.PipelineChannels, want)
+ }
+
+ ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{}, "pipeline": nil})
+ }))
+ defer ts2.Close()
+ s2 := newTestSession(ts2.URL)
+ res2, err := s2.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
+ if err != nil {
+ t.Fatalf("channels: %v", err)
+ }
+ if res2.PipelineEntry != "" || len(res2.PipelineChannels) != 0 {
+ t.Fatalf("explicit null pipeline = %+v, want the zero value", res2)
+ }
+}
+
+func equalStrings(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
}
// TestInstanceListSendsFilters pins two things at once (Task 10): instanceList's
diff --git a/cli.go b/cli.go
index 0d683e46d..b6691d3f2 100644
--- a/cli.go
+++ b/cli.go
@@ -2144,10 +2144,11 @@ func runChannels(args []string) {
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, usage)
- chans, err := sess.channels(context.Background(), acct, app2)
+ chansRes, err := sess.channels(context.Background(), acct, app2)
if err != nil {
log.Fatal(err)
}
+ chans := chansRes.Channels
// A separate read rather than a new field on the channels response:
// the channels endpoint is what the box-facing world already parses,
// and a display join belongs on the display side. A tag read that
diff --git a/deploy.go b/deploy.go
index 7df356e3b..eb597b75d 100644
--- a/deploy.go
+++ b/deploy.go
@@ -177,7 +177,7 @@ func runDeploy(args []string) {
log.Fatalf("deploy: list instances: %v", err)
}
channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag,
- func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) })
+ func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) })
if errors.Is(err, errAmbiguousChannel) && *channelFlag == "" && interactiveTerminal() {
// The refusal's own advice is "name one with -channel" — at a
// terminal, ask instead of quoting the flag back. Typed, never
@@ -185,7 +185,7 @@ func runDeploy(args []string) {
// answered this way (a no-instances refusal must stay a refusal).
if picked, ok := chooseDeployChannel(ctx, sess, acct, *app); ok {
channel, hosts, staticDeploy, err = deployTargets(recs, *kind, picked, *hostFlag,
- func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) })
+ func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) })
}
}
if err != nil {
@@ -207,6 +207,24 @@ func runDeploy(args []string) {
// order, so this is a plain false, never a refusal.
res, err := sess.promote(ctx, acct, *app, *ver, channel, false)
if err != nil {
+ // A guarded channel turns promote into a 202 proposal (release
+ // pipelines v2 §4) rather than a landed write — *apiProposedError,
+ // not a refusal (Task 10 controller ruling). The ship above already
+ // succeeded (and printed its own "shipped" confirmation) and its
+ // bytes are real; only the promote is pending, so this exits
+ // CLEANLY (return, not log.Fatal) with its own message naming what
+ // happened and what's still needed — never runPromote's bare
+ // "proposed" line, which on its own would not say the ship had
+ // already landed, and never a second "shipped" line repeating what
+ // was just printed. The watch loop below must NOT run: nothing
+ // converges anywhere until the proposal is confirmed, so there is
+ // nothing yet to watch for.
+ var prop *apiProposedError
+ if errors.As(err, &prop) {
+ fmt.Fprintf(stdout, "the ship succeeded; promoting %s to %s is proposed and needs %d confirmation(s) at %s\n",
+ *ver, channel, prop.Needed, prop.ConsoleURL)
+ return
+ }
log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err
}
fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, *ver, channel)
@@ -431,12 +449,12 @@ func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noA
// to the original refusal — the chooser must never invent an option the
// flag could not have named.
func chooseDeployChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, bool) {
- chans, err := sess.channels(ctx, acct, app)
+ res, err := sess.channels(ctx, acct, app)
if err != nil {
return "", false
}
- names := make([]string, 0, len(chans))
- for _, ch := range chans {
+ names := make([]string, 0, len(res.Channels))
+ for _, ch := range res.Channels {
if ch.Err == "" {
names = append(names, ch.Channel)
}
@@ -467,8 +485,12 @@ func (s *apiSession) accountName(acct apiAcct) string {
// deployTargets decides what this deploy promotes to and what it then watches,
// and reports whether it took the static path. It is a function, not a stretch
// of runDeploy, so the decision can be tested without a console: `channels` is
-// called only where the static path actually needs it (a lazy closure over
-// apiSession.channels in the verb; a stub in tests).
+// called only where a resolution actually needs it (a lazy closure over
+// apiSession.channels in the verb; a stub in tests) — the static path's own
+// channel listing, as before, plus (release pipelines v2, Task 11) the
+// no-instances case on EITHER path, to ask whether the app has declared a
+// pipeline whose entry channel can stand in for a resolution that would
+// otherwise fail.
//
// Zero instance records mean different things per kind, and the split matters:
// - static: there are no records BY DESIGN — the edge serves the route off
@@ -488,7 +510,7 @@ func (s *apiSession) accountName(acct apiAcct) string {
// targets; only a real instance/service record pulls a -kind static deploy
// onto the instance path.
func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
- channels func() ([]apiChannel, error)) (channel string, hosts []string, static bool, err error) {
+ channels func() (apiChannelsResult, error)) (channel string, hosts []string, static bool, err error) {
if kind == "static" && staticOnlyRecords(recs) {
hosts, err := staticDeployHosts(hostFlag, recs)
if err != nil {
@@ -505,11 +527,21 @@ func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
if ch := staticRecordChannel(recs, hostFlag); ch != "" {
return ch, hosts, true, nil
}
- chans, err := channels()
+ res, err := channels()
if err != nil {
return "", nil, true, fmt.Errorf("list channels: %w", err)
}
- channel, err := staticDeployChannel(chans)
+ // No channels at all AND a declared pipeline: this is spec §2's
+ // entry channel doing its job — a brand-new one-channel app's
+ // FIRST deploy just lands on it, rather than being refused for a
+ // channel that (being pipelined) was never going to be created any
+ // other way. A pipeline whose channels have ALREADY been promoted
+ // onto is not this case — staticDeployChannel below resolves it
+ // exactly as before, unambiguous or not.
+ if len(res.Channels) == 0 && res.PipelineEntry != "" {
+ return res.PipelineEntry, hosts, true, nil
+ }
+ channel, err := staticDeployChannel(res.Channels)
if err != nil {
return "", nil, true, err
}
@@ -529,7 +561,19 @@ func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
}
return channelFlag, hosts, false, nil
}
- channel, hosts, err = resolveDeployChannel(recs, hostFlag)
+ // The no-instances case is the only one a pipeline can rescue: every
+ // other refusal resolveDeployChannel can return (several channels among
+ // existing instances, an unknown -host) is a genuine ambiguity or typo a
+ // pipeline's entry channel has no business papering over. So the
+ // channels round trip only happens here, once, and only when there is
+ // nothing else to resolve from.
+ pipelineEntry := ""
+ if len(recs) == 0 {
+ if res, cerr := channels(); cerr == nil {
+ pipelineEntry = res.PipelineEntry
+ }
+ }
+ channel, hosts, err = resolveDeployChannel(recs, hostFlag, pipelineEntry)
return channel, hosts, false, err
}
@@ -920,10 +964,20 @@ func gitShortHead() string {
// left to resolve. With no host, every instance must already agree on one
// channel — several distinct channels is treated as ambiguity for a human to
// break with -channel or -host, not something this verb should guess at.
-// runDeploy no longer reaches this with zero records (that is the
-// no-instance/static path above), but the check stays as its own guard.
-func resolveDeployChannel(recs []apiInstanceRecord, hostFlag string) (channel string, hosts []string, err error) {
+//
+// pipelineEntry is deployTargets' pipeline-entry fallback (release pipelines
+// v2, Task 11): the zero-records case used to be an unconditional refusal —
+// it still is for a legacy app (pipelineEntry == "") — but a pipelined app's
+// entry channel is exactly where spec §2 says a fresh deploy lands, so a
+// brand-new one-channel app's first `carlos deploy` just works, no
+// -channel, no `carlos instances create` first. hosts stays nil either way:
+// there is nothing to watch until something is adopted onto the entry
+// channel.
+func resolveDeployChannel(recs []apiInstanceRecord, hostFlag, pipelineEntry string) (channel string, hosts []string, err error) {
if len(recs) == 0 {
+ if pipelineEntry != "" {
+ return pipelineEntry, nil, nil
+ }
return "", nil, errors.New("no instances — create one: carlos instances create -app <a> -host <h> -channel <ch>")
}
if hostFlag != "" {
diff --git a/deploy_test.go b/deploy_test.go
index 992aedc0c..7c018ad60 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -23,20 +23,27 @@ func TestResolveDeployChannel(t *testing.T) {
{Host: "a.test", Channel: "canary/dev"},
{Host: "b.test", Channel: "canary/dev"},
}
- ch, hosts, err := resolveDeployChannel(recs, "")
+ ch, hosts, err := resolveDeployChannel(recs, "", "")
if err != nil || ch != "canary/dev" || len(hosts) != 2 {
t.Fatalf("%q %v %v", ch, hosts, err)
}
recs[1].Channel = "edge"
- if _, _, err := resolveDeployChannel(recs, ""); err == nil {
+ if _, _, err := resolveDeployChannel(recs, "", ""); err == nil {
t.Fatal("two channels with no -channel/-host must error")
}
- ch, hosts, err = resolveDeployChannel(recs, "b.test")
+ ch, hosts, err = resolveDeployChannel(recs, "b.test", "")
if err != nil || ch != "edge" || len(hosts) != 1 || hosts[0] != "b.test" {
t.Fatalf("host-scoped: %q %v %v", ch, hosts, err)
}
- if _, _, err := resolveDeployChannel(nil, ""); err == nil {
- t.Fatal("no instances must error (hint: carlos instances create)")
+ if _, _, err := resolveDeployChannel(nil, "", ""); err == nil {
+ t.Fatal("no instances, no pipeline entry must error (hint: carlos instances create)")
+ }
+ // A pipelined app (release pipelines v2, Task 11): zero records resolves
+ // to the pipeline's entry channel instead of erroring, with nothing to
+ // watch yet.
+ ch, hosts, err = resolveDeployChannel(nil, "", "edge")
+ if err != nil || ch != "edge" || len(hosts) != 0 {
+ t.Fatalf("no instances, pipeline entry edge: %q %v %v, want it resolved with no error", ch, hosts, err)
}
}
@@ -163,13 +170,19 @@ func TestDefaultDeployTimeoutOutlastsAStaticRecheck(t *testing.T) {
// "promoted, nothing to watch" path, which must not grow a -host demand
// or a static-flavoured watch line.
func TestDeployTargetsStaticVsBinary(t *testing.T) {
- noChannels := func() ([]apiChannel, error) {
+ noChannels := func() (apiChannelsResult, error) {
t.Helper()
t.Error("the channels API was consulted on a path that must not need it")
- return nil, nil
+ return apiChannelsResult{}, nil
+ }
+ oneChannel := func() (apiChannelsResult, error) {
+ return apiChannelsResult{Channels: []apiChannel{{Channel: "canary/site"}}}, nil
}
- oneChannel := func() ([]apiChannel, error) {
- return []apiChannel{{Channel: "canary/site"}}, nil
+ // legacyNoPipeline is consulted (unlike noChannels above), but answers
+ // with no channels and no pipeline — the "no instances + NO pipeline"
+ // case, whose refusal must stay byte-identical to before Task 11.
+ legacyNoPipeline := func() (apiChannelsResult, error) {
+ return apiChannelsResult{}, nil
}
// Static, no instances: channel resolved from the app's one channel.
@@ -197,9 +210,12 @@ func TestDeployTargetsStaticVsBinary(t *testing.T) {
if ch != "canary/dev" || len(hosts) != 0 || static {
t.Fatalf("binary/no-instances/-channel: %q %v static=%v", ch, hosts, static)
}
- // BINARY, no instances, no -channel: still the old "create one" refusal,
- // never the static one.
- _, _, static, err = deployTargets(nil, "binary", "", "", noChannels)
+ // BINARY, no instances, no -channel, NO pipeline: still the old "create
+ // one" refusal, never the static one — the channels API IS now
+ // consulted (release pipelines v2, Task 11 — see
+ // TestDeployTargetsNoInstancesPipelineEntry for the pipelined case), but
+ // an app with no declared pipeline gets today's error text unchanged.
+ _, _, static, err = deployTargets(nil, "binary", "", "", legacyNoPipeline)
if err == nil || !strings.Contains(err.Error(), "carlos instances create") {
t.Fatalf("binary/no-instances must keep its own refusal: %v", err)
}
@@ -211,7 +227,7 @@ func TestDeployTargetsStaticVsBinary(t *testing.T) {
}
// With records, kind is irrelevant: the instance path is byte-identical
- // for both kinds.
+ // for both kinds, and the channels API is not needed either way.
recs := []apiInstanceRecord{{Host: "a.test", Channel: "canary/dev"}}
for _, kind := range []string{"binary", "static"} {
ch, hosts, static, err = deployTargets(recs, kind, "", "", noChannels)
@@ -221,6 +237,45 @@ func TestDeployTargetsStaticVsBinary(t *testing.T) {
}
}
+// TestDeployTargetsNoInstancesPipelineEntry pins the pipeline-entry fallback
+// itself (release pipelines v2, Task 11, spec §2's "the entry channel is
+// where deploy lands"): a brand-new app with no instance records and no
+// resolvable channel — no explicit -channel, no existing channel rows —
+// resolves straight to the pipeline's entry channel instead of erroring, on
+// BOTH the static and the non-static (binary/instance) path. The ambiguity
+// error and -channel override behaviour are otherwise untouched (pinned by
+// TestDeployTargetsStaticVsBinary/TestStaticDeployChannel/
+// TestResolveDeployChannel).
+func TestDeployTargetsNoInstancesPipelineEntry(t *testing.T) {
+ pipelined := func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge", PipelineChannels: []string{"edge", "production"}}, nil
+ }
+
+ // Non-static (binary/instance) path: no records, no -channel, a
+ // pipelined app — resolves to the entry channel, no error, and (like
+ // the pre-existing no-instances shape) nothing to watch yet.
+ ch, hosts, static, err := deployTargets(nil, "binary", "", "", pipelined)
+ if err != nil {
+ t.Fatalf("binary/no-instances/pipelined must not error: %v", err)
+ }
+ if ch != "edge" || len(hosts) != 0 || static {
+ t.Fatalf("binary/no-instances/pipelined = %q %v static=%v, want the entry channel edge", ch, hosts, static)
+ }
+
+ // Static path: no records, -host given (required on the static path
+ // regardless), no -channel, a pipelined app whose channels listing is
+ // empty (nothing has ever been promoted onto it yet) — resolves to the
+ // entry channel rather than staticDeployChannel's "no channels yet"
+ // refusal.
+ ch, hosts, static, err = deployTargets(nil, "static", "", "site.test", pipelined)
+ if err != nil {
+ t.Fatalf("static/no-instances/pipelined must not error: %v", err)
+ }
+ if ch != "edge" || len(hosts) != 1 || hosts[0] != "site.test" || !static {
+ t.Fatalf("static/no-instances/pipelined = %q %v static=%v, want the entry channel edge, host watched", ch, hosts, static)
+ }
+}
+
func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) {
var mu sync.Mutex
ver := "old1234"
@@ -621,6 +676,10 @@ type deployFakeConsole struct {
// as a first route, the review#1 case attachUnclaimedHost must read off
// the door's own answer rather than assume.
attachRouteOverride string
+ // promoteProposed, when true, makes POST .../promote answer 202 (a
+ // guarded channel's proposal shape, release pipelines v2 §4) instead of
+ // the normal 200 — the RIDER case, Task 11.
+ promoteProposed bool
}
func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
@@ -683,6 +742,17 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
})
mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) {
fc.record(r)
+ fc.mu.Lock()
+ proposed := fc.promoteProposed
+ fc.mu.Unlock()
+ if proposed {
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "proposed", "pending_id": "pend-1", "needed": 2, "confirmed": 0,
+ "console_url": "https://console.example.test/a/ops/apps/sitely/pending/pend-1",
+ })
+ return
+ }
json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false})
})
fc.ts = httptest.NewServer(mux)
@@ -724,6 +794,12 @@ func (fc *deployFakeConsole) setAttachRouteOverride(route string) {
fc.attachRouteOverride = route
}
+func (fc *deployFakeConsole) setPromoteProposed(proposed bool) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.promoteProposed = proposed
+}
+
func (fc *deployFakeConsole) record(r *http.Request) {
fc.mu.Lock()
defer fc.mu.Unlock()
@@ -1219,9 +1295,9 @@ func TestDeployAttachRefusalStopsBeforeShip(t *testing.T) {
func TestDeployTargetsStaticKindAware(t *testing.T) {
staticRec := apiInstanceRecord{Host: "site.example.test", Channel: "canary/site", Kind: "static"}
channelsCalled := false
- channels := func() ([]apiChannel, error) {
+ channels := func() (apiChannelsResult, error) {
channelsCalled = true
- return []apiChannel{{Channel: "canary/other"}}, nil
+ return apiChannelsResult{Channels: []apiChannel{{Channel: "canary/other"}}}, nil
}
// Redeploy: -host names the record — static path, channel straight from
@@ -1401,3 +1477,76 @@ func TestDeploySecondStaticHostAttaches(t *testing.T) {
}
checkNoRegistry()
}
+
+// TestDeployPromoteProposedExitsCleanly pins the RIDER from Task 10's
+// review: when the ship succeeds but the promote a pipelined `carlos
+// deploy` attempts lands on a GUARDED channel, the console answers 202 —
+// *apiProposedError, not a refusal — and `carlos deploy` must exit CLEANLY
+// (status 0), print a message that says BOTH halves of the truth (the ship
+// already landed; the promotion needs confirming), and never reach the
+// watch loop: nothing converges anywhere until the proposal is confirmed,
+// so there is nothing yet to watch for. Before this fix, deploy.go's
+// promote error handling was a bare log.Fatalf that would have reported
+// the ship as failed outright (exit 1, a misleading "deploy: promote:
+// ..." line) — the CLI-lies-about-its-outcome bug apiProposedError exists
+// to close everywhere else it is checked (runPromote, runRollback,
+// applyPipelinePut).
+func TestDeployPromoteProposedExitsCleanly(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const app = "widgetco"
+ fc := newDeployFakeConsole(t, "sha0009")
+ // An instance record with hosts to watch, so a regression that reaches
+ // the watch loop anyway has something to hang against — proof by
+ // consequence that the fix's early return is what's keeping it away,
+ // not an accidental "nothing to watch" shortcut.
+ fc.setInstances([]apiInstanceRecord{{Host: "widgetco-a.example.test", Channel: "canary/x"}})
+ fc.setPromoteProposed(true)
+
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ binDir := t.TempDir()
+ binPath := filepath.Join(binDir, "widgetco")
+ if err := os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "binary", "-version", "sha0009",
+ "-channel", "canary/x", "-timeout", "5s", binPath})
+ out := buf.String()
+
+ if promoteIdx := fc.callIndex("POST /api/cli/apps/ops/widgetco/promote"); promoteIdx < 0 {
+ t.Fatalf("promote was never called: %v", fc.calls)
+ }
+ for _, want := range []string{"shipped widgetco sha0009", "ship succeeded", "sha0009", "canary/x", "proposed",
+ "2 confirmation", "console.example.test/a/ops/apps/sitely/pending/pend-1"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ }
+ if strings.Contains(out, "deploy: promote:") {
+ t.Fatalf("stdout = %q, must not print the generic promote-failure prefix — this is not a failure", out)
+ }
+ // The proof that the watch loop never ran: watchVersion's own success
+ // line ("live https://<host>", the exact text
+ // TestDeploySecondStaticHostAttaches pins) never appears — if deploy had
+ // fallen through to the watch loop against a host
+ // (widgetco-a.example.test) nothing serves, this test would instead
+ // have blocked for close to -timeout before a log.Fatal, not returned
+ // promptly with a clean message.
+ if strings.Contains(out, "live https://") {
+ t.Fatalf("stdout = %q, must never reach the watch loop (nothing converges until the proposal is confirmed)", out)
+ }
+ checkNoRegistry()
+}
diff --git a/deploy_wizard_test.go b/deploy_wizard_test.go
index 9fe54ed9d..a39be0bfe 100644
--- a/deploy_wizard_test.go
+++ b/deploy_wizard_test.go
@@ -151,21 +151,21 @@ func TestDeployTargetsAmbiguityIsTyped(t *testing.T) {
// No instances at all: a hard refusal that merely MENTIONS -channel —
// the chooser must never answer it (it once converted this refusal
// into a ship+promote).
- _, _, _, err := deployTargets(nil, "binary", "", "", func() ([]apiChannel, error) { return nil, nil })
+ _, _, _, err := deployTargets(nil, "binary", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil })
if err == nil || errors.Is(err, errAmbiguousChannel) {
t.Fatalf("no-instances = %v, want a NON-ambiguity refusal", err)
}
// Instances split across channels: genuine ambiguity, typed.
recs := []apiInstanceRecord{{Host: "a.x", Channel: "canary/a"}, {Host: "b.x", Channel: "canary/b"}}
- _, _, _, err = deployTargets(recs, "binary", "", "", func() ([]apiChannel, error) { return nil, nil })
+ _, _, _, err = deployTargets(recs, "binary", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil })
if !errors.Is(err, errAmbiguousChannel) {
t.Fatalf("split channels = %v, want errAmbiguousChannel", err)
}
// Static app, no instances, several channels: ambiguity, typed.
- chans := []apiChannel{{Channel: "canary/site"}, {Channel: "stable"}}
- _, _, _, err = deployTargets(nil, "static", "", "h.example", func() ([]apiChannel, error) { return chans, nil })
+ chans := apiChannelsResult{Channels: []apiChannel{{Channel: "canary/site"}, {Channel: "stable"}}}
+ _, _, _, err = deployTargets(nil, "static", "", "h.example", func() (apiChannelsResult, error) { return chans, nil })
if !errors.Is(err, errAmbiguousChannel) {
t.Fatalf("static several channels = %v, want errAmbiguousChannel", err)
}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index a6c7bd4b3..7ccc50ba1 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -511,6 +511,9 @@ func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) {
// response — mirroring runChannels' per-row ERROR behaviour (cli.go), since
// one bad pointer must not hide every other channel's answer from the
// caller.
+//
+// The response also carries an ADDITIVE "pipeline" envelope key (release
+// pipelines v2, Task 11) — see the comment at its assignment below.
func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) {
_, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
@@ -544,7 +547,38 @@ func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) {
"rung": rung,
})
}
- writeJSON(w, http.StatusOK, map[string]any{"channels": out})
+ resp := map[string]any{"channels": out}
+ // pipeline: an ADDITIVE envelope key (release pipelines v2, Task 11) —
+ // old CLIs decode "channels" and ignore the rest. It is a REDUCED view
+ // of the pipeline — {"entry", "channels": [names in order]} — not the
+ // full signed release.Pipeline object `carlos pipeline` GETs (Task 6);
+ // this door answers "where does a deploy land and what's the order",
+ // nothing about bake/rules, so it need not carry the wider object over
+ // the wire a second time. The LOAD, though, is shared with
+ // handleAPIPipelineGet verbatim (release.GetPipeline) so the two doors
+ // can never disagree about what "this app's pipeline" is.
+ //
+ // A read failure here degrades to OMITTING the key rather than failing
+ // this otherwise-successful, read-only listing — the channel rows above
+ // already carry their own per-row errors, and `carlos pipeline` is one
+ // call away for a caller that needs to know why. Crucially this is not
+ // the same wire shape as "no pipeline.json": that confirmed-legacy case
+ // is `"pipeline": null` (p, err both nil, exactly as handleAPIPipelineGet
+ // answers it) — an errored read must never be presented as that, so it
+ // leaves the key out instead of forging a null a stale reader would take
+ // as "definitely legacy".
+ if p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app); err != nil {
+ log.Printf("console: api channels %s/%s: pipeline read: %v", acct.Sqid, app, err)
+ } else if p == nil {
+ resp["pipeline"] = nil
+ } else {
+ names := make([]string, len(p.Channels))
+ for i, c := range p.Channels {
+ names[i] = c.Name
+ }
+ resp["pipeline"] = map[string]any{"entry": p.Entry(), "channels": names}
+ }
+ writeJSON(w, http.StatusOK, resp)
}
// handleAPIPromote answers `carlos promote`: the CLI's write door onto the
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index a307fb541..cc2e64fb8 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -261,6 +261,100 @@ func TestAPIChannelsErrorRow(t *testing.T) {
}
}
+// TestAPIChannelsPipelineEnvelope pins the additive "pipeline" envelope key
+// (release pipelines v2, Task 11): a pipelined app's channels response
+// carries the REDUCED view {"entry": ..., "channels": [names in order]} —
+// sharing release.GetPipeline's read with handleAPIPipelineGet (Task 6) so
+// the two doors can never disagree — while a legacy app (no pipeline.json)
+// carries an explicit "pipeline": null. Old CLIs decode "channels" and
+// ignore the rest either way, so both shapes are additive.
+func TestAPIChannelsPipelineEnvelope(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-channels-pipeline")
+
+ // Pipelined app: created through the API door, which seeds the
+ // one-channel edge default (TestAPIPipelineRoundTripAndDefaults), then
+ // widened to two channels so entry != the app's only channel.
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "piped"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("create app = %d %v", status, out)
+ }
+ status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/pipeline",
+ map[string]any{"channels": []map[string]any{{"name": "edge"}, {"name": "production"}}}, token)
+ if status != http.StatusOK {
+ t.Fatalf("pipeline put = %d %v", status, out)
+ }
+
+ status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/channels", token)
+ if status != http.StatusOK {
+ t.Fatalf("channels (pipelined) = %d %v", status, out)
+ }
+ p, ok := out["pipeline"].(map[string]any)
+ if !ok {
+ t.Fatalf("channels (pipelined) pipeline = %v, want an object, not null/absent", out["pipeline"])
+ }
+ if str(t, p, "entry") != "edge" {
+ t.Fatalf("pipeline entry = %v, want edge", p["entry"])
+ }
+ names, _ := p["channels"].([]any)
+ if len(names) != 2 || names[0] != "edge" || names[1] != "production" {
+ t.Fatalf("pipeline channels = %v, want [edge production]", names)
+ }
+ // The channel-rows key is untouched: a fresh app with no promotions has
+ // none yet.
+ chans, _ := out["channels"].([]any)
+ if len(chans) != 0 {
+ t.Fatalf("channels (pipelined, unpromoted) = %v, want none yet", chans)
+ }
+
+ // Legacy app: created directly, bypassing the API create door's
+ // default-pipeline write — no pipeline.json exists at all, so the key
+ // must be an explicit null, never omitted (an omitted key is what an
+ // UNREADABLE pipeline gets, and this app's pipeline is genuinely
+ // absent, not unreadable).
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "legacyapp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/legacyapp/channels", token)
+ if status != http.StatusOK {
+ t.Fatalf("channels (legacy) = %d %v", status, out)
+ }
+ pval, present := out["pipeline"]
+ if !present {
+ t.Fatalf("channels (legacy) omitted the pipeline key entirely, want an explicit null")
+ }
+ if pval != nil {
+ t.Fatalf("channels (legacy) pipeline = %v, want null", pval)
+ }
+}
+
+// TestAPIChannelsPipelineUnreadableOmitsKey: an app whose pipeline.json
+// exists but fails verification must not be told apart from a legacy app —
+// GetPipeline's error is a genuinely different state (spec-mandated 502 on
+// the dedicated `carlos pipeline` door), and forging "pipeline": null here
+// would tell a caller "definitely no pipeline" when the truth is "the
+// console could not read it". The channels listing itself is unharmed: it
+// is read-only info this door must not fail over a pipeline it doesn't
+// strictly need.
+func TestAPIChannelsPipelineUnreadableOmitsKey(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(), release.PipelineKey("bab", "widget"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-channels-pipeline-unreadable")
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/channels", token)
+ if status != http.StatusOK {
+ t.Fatalf("channels (unreadable pipeline) = %d %v, want 200 — the listing itself is fine", status, out)
+ }
+ if _, present := out["pipeline"]; present {
+ t.Fatalf("channels (unreadable pipeline) = %v, want the pipeline key OMITTED, not present (even as null)", out)
+ }
+}
+
// TestAPIChannelsRefusalMatrix: an unknown account, and an account you belong
// to but an app it hasn't claimed, are the SAME 404 — byte-identical to
// http.NotFound's own output, so a token holder learns nothing by probing.