deploy: resolve the pipeline entry channel; channels API carries the pipeline envelope
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+472
−43
apimode.go+40 −7apimode_test.go+65 −1cli.go+2 −1deploy.go+68 −14deploy_test.go+164 −15deploy_wizard_test.go+4 −4internal/console/apicli.go+35 −1internal/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. |