deploy: flatten the channels-door pipeline envelope to pipeline_entry/pipeline_channels
Review fix round 1 (Task 11): the /channels door's "pipeline" key
collided with the /pipeline door's own "pipeline" key naming an
incompatible shape (a flat {entry, channels: [strings]} summary vs the
full signed release.Pipeline object) — same JSON path, object on one
door, string-bearing summary on the other. Flattened to two additive
top-level keys, pipeline_entry/pipeline_channels, mirroring the client's
apiChannelsResult fields exactly. Both keys are now omitted together for
BOTH a legacy app and an unreadable pipeline — the null-vs-omitted
distinction the door used to carry is dropped by design; the /pipeline
door remains the sole authority on that distinction, untouched.
Updated: internal/console/apicli.go (handleAPIChannels), apimode.go
(channels decode), and the covering tests in apicli_test.go/apimode_test.go.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+110
−91
apimode.go+19 −17apimode_test.go+18 −17internal/console/apicli.go+32 −24internal/console/apicli_test.go+41 −33
diff --git a/apimode.go b/apimode.go| index 08f3090b4..8ae180e29 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -327,8 +327,8 @@ type apiChannel struct { |
| // 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 |
| +// edge/beta/stable ladder, OR a console whose pipeline read failed |
| +// (handleAPIChannels omits both pipeline_entry/pipeline_channels 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). |
| @@ -1080,31 +1080,33 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiCh |
| 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"` |
| + // PipelineEntry/PipelineChannels are two flat, top-level keys — NOT |
| + // nested under a "pipeline" object — deliberately distinct from the |
| + // full release.Pipeline object `carlos pipeline` reads under that |
| + // name (review fix round 1: the same JSON key meaning an object on |
| + // one door and a string on another was a trap for any future |
| + // consumer). Both are absent together for BOTH "no pipeline key at |
| + // all" (an older console) and "this app has no pipeline" (a |
| + // confirmed-legacy app) AND a pipeline read failure server-side — |
| + // this client has no need to tell those apart; see |
| + // apiChannelsResult's doc comment. |
| + PipelineEntry string `json:"pipeline_entry"` |
| + PipelineChannels []string `json:"pipeline_channels"` |
| } |
| if err := json.Unmarshal(body, &res); err != nil { |
| return apiChannelsResult{}, fmt.Errorf("channels: unreadable response: %w", err) |
| } |
| - out := apiChannelsResult{Channels: make([]apiChannel, len(res.Channels))} |
| + out := apiChannelsResult{ |
| + Channels: make([]apiChannel, len(res.Channels)), |
| + PipelineEntry: res.PipelineEntry, |
| + PipelineChannels: res.PipelineChannels, |
| + } |
| for i, ch := range res.Channels { |
| 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 1368be30b..ee9d985e4 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -608,29 +608,30 @@ 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). |
| + // Neither "pipeline_entry" nor "pipeline_channels" present at all (an |
| + // older console, a legacy app, or handleAPIChannels' own |
| + // degrade-on-read-failure branch — this client has no need to tell |
| + // those apart, apiChannelsResult's doc comment): both decode to their |
| + // zero value. |
| if res.PipelineEntry != "" || len(res.PipelineChannels) != 0 { |
| - t.Fatalf("no pipeline key = %+v, want a zero-value pipeline", res) |
| + t.Fatalf("no pipeline keys = %+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. |
| +// additive "pipeline_entry"/"pipeline_channels" flat top-level keys (release |
| +// pipelines v2, Task 11, review fix round 1 — NOT nested under a "pipeline" |
| +// key, since that JSON path already names the full release.Pipeline object |
| +// on the `/pipeline` door): PipelineEntry/PipelineChannels decode from the |
| +// two flat fields when present, and — both keys absent, the shape a legacy |
| +// app OR an unreadable-pipeline app both get, this client has no need to |
| +// tell them apart — decode to the same zero value. |
| 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"}, |
| - }, |
| + "channels": []map[string]any{}, |
| + "pipeline_entry": "edge", |
| + "pipeline_channels": []string{"edge", "production"}, |
| }) |
| })) |
| defer ts.Close() |
| @@ -648,7 +649,7 @@ func TestAPISessionChannelsPipelineEnvelope(t *testing.T) { |
| } |
| 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}) |
| + json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{}}) |
| })) |
| defer ts2.Close() |
| s2 := newTestSession(ts2.URL) |
| @@ -657,7 +658,7 @@ func TestAPISessionChannelsPipelineEnvelope(t *testing.T) { |
| t.Fatalf("channels: %v", err) |
| } |
| if res2.PipelineEntry != "" || len(res2.PipelineChannels) != 0 { |
| - t.Fatalf("explicit null pipeline = %+v, want the zero value", res2) |
| + t.Fatalf("both keys absent = %+v, want the zero value", res2) |
| } |
| } |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 7ccc50ba1..8c9de35cc 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -512,8 +512,9 @@ func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) { |
| // 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. |
| +// The response also carries two ADDITIVE "pipeline_entry"/"pipeline_channels" |
| +// keys (release pipelines v2, Task 11) — see the comment at their assignment |
| +// below. |
| func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { |
| _, acct, app, ok := s.apiAccountApp(w, r) |
| if !ok { |
| @@ -548,35 +549,42 @@ func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { |
| }) |
| } |
| 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. |
| + // pipeline_entry/pipeline_channels: two ADDITIVE top-level keys (release |
| + // pipelines v2, Task 11 — review fix round 1) — old CLIs decode |
| + // "channels" and ignore the rest. They are a REDUCED view of the |
| + // pipeline — the entry channel and the full order, nothing about |
| + // bake/rules — not the full signed release.Pipeline object `carlos |
| + // pipeline` GETs (Task 6). Deliberately NOT nested under a "pipeline" |
| + // key: the `/pipeline` door already uses that JSON path name for the |
| + // full object, and reusing it here for a differently-shaped value (an |
| + // object there, these two flat fields here) would be the same key |
| + // meaning two incompatible things depending on which door answered it — |
| + // a trap for any future consumer, and one that freezes the moment a |
| + // released CLI starts decoding it. Two new key NAMES sidestep the |
| + // collision entirely. The LOAD 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". |
| + // Both keys are OMITTED — together, never one without the other — for |
| + // BOTH a legacy app (no pipeline.json) AND a pipeline read failure. |
| + // This channels door is a convenience summary for deploy resolution, |
| + // not the authority on why a pipeline is absent: a caller that needs to |
| + // tell "definitely no pipeline" apart from "the console could not read |
| + // it" has the dedicated `/pipeline` door for that (its null-vs-502 |
| + // distinction, Task 6). Collapsing the two here costs nothing a caller |
| + // of THIS door needs: deployTargets' entry-channel fallback treats an |
| + // absent PipelineEntry as "no fallback" either way, which is the |
| + // correct conservative behaviour for a read failure as much as for a |
| + // genuinely legacy app. |
| 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 { |
| + } else if p != nil { |
| 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} |
| + resp["pipeline_entry"] = p.Entry() |
| + resp["pipeline_channels"] = names |
| } |
| writeJSON(w, http.StatusOK, resp) |
| } |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index cc2e64fb8..18a1fe3b2 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -261,13 +261,23 @@ 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. |
| +// TestAPIChannelsPipelineEnvelope pins the additive "pipeline_entry"/ |
| +// "pipeline_channels" envelope keys (release pipelines v2, Task 11, review |
| +// fix round 1): a pipelined app's channels response carries the REDUCED view |
| +// as two flat top-level keys — "pipeline_entry" and "pipeline_channels": |
| +// [names in order] — sharing release.GetPipeline's read with |
| +// handleAPIPipelineGet (Task 6) so the two doors can never disagree. They |
| +// are deliberately NOT nested under a "pipeline" key: that JSON path already |
| +// names the full release.Pipeline object on the `/pipeline` door, and |
| +// reusing it here for a differently-shaped value would mean the same key |
| +// meaning two incompatible things depending on which door answered. |
| +// |
| +// A legacy app (no pipeline.json) gets BOTH keys OMITTED, never present as |
| +// null — this door does not attempt to distinguish "no pipeline" from "the |
| +// console could not read one" (see TestAPIChannelsPipelineUnreadableOmitsKey); |
| +// that distinction belongs to the dedicated `/pipeline` door alone. 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") |
| @@ -289,16 +299,12 @@ func TestAPIChannelsPipelineEnvelope(t *testing.T) { |
| 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"]) |
| + if str(t, out, "pipeline_entry") != "edge" { |
| + t.Fatalf("pipeline_entry = %v, want edge", out["pipeline_entry"]) |
| } |
| - names, _ := p["channels"].([]any) |
| + names, _ := out["pipeline_channels"].([]any) |
| if len(names) != 2 || names[0] != "edge" || names[1] != "production" { |
| - t.Fatalf("pipeline channels = %v, want [edge production]", names) |
| + t.Fatalf("pipeline_channels = %v, want [edge production]", names) |
| } |
| // The channel-rows key is untouched: a fresh app with no promotions has |
| // none yet. |
| @@ -308,10 +314,8 @@ func TestAPIChannelsPipelineEnvelope(t *testing.T) { |
| } |
| // 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). |
| + // default-pipeline write — no pipeline.json exists at all, so BOTH keys |
| + // must be absent, together, exactly as the unreadable case below. |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "legacyapp", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| @@ -319,23 +323,24 @@ func TestAPIChannelsPipelineEnvelope(t *testing.T) { |
| 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 _, present := out["pipeline_entry"]; present { |
| + t.Fatalf("channels (legacy) pipeline_entry = %v, want the key OMITTED", out["pipeline_entry"]) |
| } |
| - if pval != nil { |
| - t.Fatalf("channels (legacy) pipeline = %v, want null", pval) |
| + if _, present := out["pipeline_channels"]; present { |
| + t.Fatalf("channels (legacy) pipeline_channels = %v, want the key OMITTED", out["pipeline_channels"]) |
| } |
| } |
| // 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. |
| +// exists but fails verification gets the SAME wire shape as a legacy app — |
| +// both pipeline_entry/pipeline_channels OMITTED. Unlike the dedicated |
| +// `/pipeline` door (which answers a genuinely-missing pipeline with 200/null |
| +// and an unreadable one with 502, Task 6), this door does not attempt that |
| +// distinction at all (review fix round 1) — it is a convenience summary for |
| +// deploy resolution, and an absent entry channel is the correct conservative |
| +// answer whether the pipeline is missing or merely unreadable. 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 { |
| @@ -350,8 +355,11 @@ func TestAPIChannelsPipelineUnreadableOmitsKey(t *testing.T) { |
| 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) |
| + if _, present := out["pipeline_entry"]; present { |
| + t.Fatalf("channels (unreadable pipeline) pipeline_entry = %v, want the key OMITTED, not present (even as null)", out) |
| + } |
| + if _, present := out["pipeline_channels"]; present { |
| + t.Fatalf("channels (unreadable pipeline) pipeline_channels = %v, want the key OMITTED, not present (even as null)", out) |
| } |
| } |