carlos / platform Public

console: pipeline API fixes — audit production side-write, classify SetPipeline errors

Review findings on the pipeline API (GET/PUT) commit:

- ensureProductionForBakes now audits its own SetProduction flip, matching
  every other SetProduction call site in this package. Widened to take
  *Account so it can call s.audit.
- SetPipeline errors are classified instead of echoed wholesale:
  ErrCASConflict -> 409 (re-read and retry), store/tombstone read
  failures -> 502 (never leaks bucket key paths), validatePipeline
  refusals -> 422 (the only case still safe to echo verbatim).
- Pipeline audit row now names substance (channel names + count) instead
  of a bare "set the pipeline".

New tests: production-side-write audit row + no-second-row on a
following all-zero-bake PUT, pipeline audit detail names channels, and a
409/502 classification pair (stale-etag CAS conflict via a store
wrapper, broken-store error-text non-leak).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8d37d611ae0dad9e89dd68f88e632dabfb344fed parent 621ae1b
2 files changed, +221 −12
  • internal/console/apipipeline.go +88 −12
  • internal/console/apipipeline_test.go +133 −0
diff --git a/internal/console/apipipeline.go b/internal/console/apipipeline.go
index 3cb1de236..2079084bd 100644
--- a/internal/console/apipipeline.go
+++ b/internal/console/apipipeline.go
@@ -26,10 +26,12 @@ package console
import (
"context"
+ "errors"
"fmt"
"log"
"net/http"
"net/url"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/release"
@@ -92,8 +94,12 @@ func (s *Server) handleAPIPipelineGet(w http.ResponseWriter, r *http.Request) {
// quorum flow.
// SetPipeline — validates shape internally (canary reserved,
// duplicate names, bad bake strings, negative
-// approvals) and signs; its error becomes 422,
-// the caller's to fix.
+// approvals) and signs. Its error is CLASSIFIED
+// (classifySetPipelineError, below) rather than
+// echoed wholesale: a validation refusal is 422,
+// the caller's to fix; a lost CAS race is 409,
+// re-ask; a store read/write failure is 502 and
+// never puts a bucket key path on the wire.
// production side-write — spec §3's old-binary fallback, below.
// audit + bump — after the write lands, never before or in
// place of a gate above.
@@ -148,11 +154,20 @@ func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) {
}
set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, in.Channels, addr)
if err != nil {
- jsonError(w, http.StatusUnprocessableEntity, err.Error())
+ status, msg := classifySetPipelineError(err)
+ if status != http.StatusUnprocessableEntity {
+ // A validation refusal is the caller's own typo — the message
+ // already says so, on the wire, and logging every bad request a
+ // script sends would just be noise. A 409 or 502 here is a
+ // genuine backend condition (a race, an unreachable store) an
+ // operator may need the real detail for.
+ log.Printf("console: api pipeline put %s/%s: set: %v", acct.Sqid, app, err)
+ }
+ jsonError(w, status, msg)
return
}
- s.ensureProductionForBakes(ctx, acct.Sqid, app, set, addr)
- s.audit(acct, app, addr, "pipeline", "set the pipeline (cli)")
+ s.ensureProductionForBakes(ctx, acct, app, set, addr)
+ s.audit(acct, app, addr, "pipeline", pipelineAuditDetail(set))
// Box-consumed (carlos-deploy §3): boxes read pipeline.json on the same
// fast pass channel pointers and config already ride.
s.bumpSerial(ctx)
@@ -206,10 +221,15 @@ func guardedEdit(cur *release.Pipeline, next []release.PipelineChannel) (string,
// re-declared: GetProduction's error already fails every reader toward
// "treat as production" (adopt path, trash's delete gate), so writing over
// it here would only risk losing whatever the existing object actually
-// says. Log-only on every failure: the pipeline write itself already
+// says. Log-only on a READ failure: the pipeline write itself already
// landed, and a box that never sees this flag flip is no worse off than a
-// pipeline-less app was yesterday.
-func (s *Server) ensureProductionForBakes(ctx context.Context, acct, app string, p *release.Pipeline, actor string) {
+// pipeline-less app was yesterday. A successful FLIP, though, gets the same
+// audit row every other SetProduction call site writes (server.go's
+// enable_bake and production-toggle doors) — this flag gates fleet-wide
+// baking and delete protection, so a row that goes unaudited here while
+// every other path to it is audited would be the one silent way to turn it
+// on.
+func (s *Server) ensureProductionForBakes(ctx context.Context, acct *Account, app string, p *release.Pipeline, actor string) {
bakes := false
for _, c := range p.Channels {
if d, err := c.BakeWindow(); err == nil && d > 0 {
@@ -220,17 +240,73 @@ func (s *Server) ensureProductionForBakes(ctx context.Context, acct, app string,
if !bakes {
return
}
- flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app)
+ flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
if err != nil {
- log.Printf("console: pipeline %s/%s: read production flag for bake fallback: %v", acct, app, err)
+ log.Printf("console: pipeline %s/%s: read production flag for bake fallback: %v", acct.Sqid, app, err)
return
}
if flag != nil && flag.Production {
return // already true — never re-declared, never downgraded
}
- if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct, app, true, actor); err != nil {
- log.Printf("console: pipeline %s/%s: set production for bake fallback: %v", acct, app, err)
+ if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, actor); err != nil {
+ log.Printf("console: pipeline %s/%s: set production for bake fallback: %v", acct.Sqid, app, err)
+ return
+ }
+ s.audit(acct, app, actor, "production",
+ "enabled the safety delay (production: on, at pipeline set — old-binary bake fallback)")
+}
+
+// classifySetPipelineError maps a release.SetPipeline error to the status
+// and caller-facing message this door answers with. SetPipeline's errors
+// come in three shapes, and only one of them is safe to echo verbatim:
+//
+// - release.ErrCASConflict (checked by errors.Is, since SetPipeline
+// returns it — via Store.Put — unwrapped): a concurrent write won the
+// race. 409, with a fixed retry sentence — not the caller's fault and
+// not a defect in what they sent.
+// - A store or trash-marker READ failure, wrapped with the exact prefixes
+// SetPipeline's own source uses ("cannot check the trash marker" /
+// "set pipeline: read "): the bucket, not the request, is the problem.
+// 502, with a fixed sentence — the real error (bucket names, request
+// ids) stays in the log, the same discipline every other store-backed
+// door here follows (e.g. handleAPITrashApp, apiledger.go).
+// - Everything else prefixed "set pipeline: " is validatePipeline's own
+// refusal (or one of the pre-checks that cannot fire from this door,
+// since apiAccountApp already validated the account/app names) —
+// genuinely the caller's malformed input, safe and useful to echo. 422.
+// - An error with NO "set pipeline: " prefix at all and not ErrCASConflict
+// is the one other shape SetPipeline returns as-is: a raw Store.Put
+// failure. Not the caller's fault either — 502, generic.
+func classifySetPipelineError(err error) (int, string) {
+ if errors.Is(err, release.ErrCASConflict) {
+ return http.StatusConflict, "concurrent change — re-read and retry"
+ }
+ msg := err.Error()
+ if strings.Contains(msg, "cannot check the trash marker") || strings.Contains(msg, "set pipeline: read ") {
+ return http.StatusBadGateway, "could not verify the current pipeline — try again"
+ }
+ if strings.HasPrefix(msg, "set pipeline: ") {
+ return http.StatusUnprocessableEntity, msg
+ }
+ return http.StatusBadGateway, "could not write the pipeline — try again"
+}
+
+// pipelineAuditDetail names what actually changed, in the house style the
+// neighbouring audit rows use (server.go's promote/production rows): a
+// bare "set the pipeline" would leave a reader of the audit trail unable to
+// tell a one-line typo fix from a whole new ladder without opening the
+// bucket object themselves — and this row documents a POLICY rewrite, not a
+// routine write like a config var.
+func pipelineAuditDetail(p *release.Pipeline) string {
+ names := make([]string, len(p.Channels))
+ for i, c := range p.Channels {
+ names[i] = c.Name
+ }
+ suffix := "s"
+ if len(p.Channels) == 1 {
+ suffix = ""
}
+ return fmt.Sprintf("set the pipeline (%s; %d channel%s, cli)", strings.Join(names, " → "), len(p.Channels), suffix)
}
// holdWindow is the pipeline-aware form of release.HoldFor (bake.go), for
diff --git a/internal/console/apipipeline_test.go b/internal/console/apipipeline_test.go
index be23f50df..186c4be4c 100644
--- a/internal/console/apipipeline_test.go
+++ b/internal/console/apipipeline_test.go
@@ -229,6 +229,139 @@ func TestAPIPipelinePutGuardedChannelInterimRefusal(t *testing.T) {
}
}
+// TestAPIPipelinePutAuditsSubstanceAndProductionSideWrite: two audit
+// findings from review. First, the pipeline write's own row must name
+// substance (channel names and count), not a bare "set the pipeline" that
+// forces a reader to open the bucket object to see what changed. Second,
+// the production-flag side-write (spec §3's old-binary fallback) is a real
+// SetProduction call — every OTHER call site in this codebase
+// (server.go's enable_bake and production-toggle doors) writes its own
+// audit row when it flips the flag, and this one must too: the flag gates
+// fleet-wide baking and delete protection, so a silent flip here would be
+// the one path to it nothing in the audit trail explains.
+func TestAPIPipelinePutAuditsSubstanceAndProductionSideWrite(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-audit")
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline",
+ map[string]any{"channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h"},
+ }}, token)
+ if status != http.StatusOK {
+ t.Fatalf("pipeline put = %d %v", status, out)
+ }
+
+ events, err := fx.st.Events(OpsAccountID, "widget", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var pipelineRow, productionRow *Event
+ for i := range events {
+ switch events[i].Action {
+ case "pipeline":
+ pipelineRow = &events[i]
+ case "production":
+ productionRow = &events[i]
+ }
+ }
+ if pipelineRow == nil {
+ t.Fatalf("events = %v, want a pipeline audit row", events)
+ }
+ if !strings.Contains(pipelineRow.Detail, "edge") || !strings.Contains(pipelineRow.Detail, "production") ||
+ !strings.Contains(pipelineRow.Detail, "2 channel") {
+ t.Fatalf("pipeline audit detail = %q, want it to name the channels and count", pipelineRow.Detail)
+ }
+ if productionRow == nil {
+ t.Fatalf("events = %v, want a production audit row for the side-write", events)
+ }
+ if !strings.Contains(productionRow.Detail, "on") {
+ t.Fatalf("production audit detail = %q, want it to say the flag went on", productionRow.Detail)
+ }
+
+ // A second PUT that declares no bake at all must not flip production
+ // again — and therefore must not write a second production audit row.
+ status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline",
+ map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token)
+ if status != http.StatusOK {
+ t.Fatalf("all-zero-bake pipeline put = %d %v", status, out)
+ }
+ events, err = fx.st.Events(OpsAccountID, "widget", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ productionRows := 0
+ for _, e := range events {
+ if e.Action == "production" {
+ productionRows++
+ }
+ }
+ if productionRows != 1 {
+ t.Fatalf("production audit rows after an all-zero-bake put = %d, want still 1 (never re-declared)", productionRows)
+ }
+}
+
+// pipelineCASStore forces SetPipeline's own conditional Put on pipeline.json
+// to lose the race — the same shape casStore (apiledger_test.go) uses for
+// ledger keys — simulating a concurrent pipeline write without needing two
+// real goroutines to land in the narrow window between SetPipeline's own
+// read and its write.
+type pipelineCASStore struct{ release.Store }
+
+func (c pipelineCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if strings.HasSuffix(key, "pipeline.json") {
+ return "", release.ErrCASConflict
+ }
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+// TestAPIPipelinePutStoreFailureStatuses: SetPipeline's non-validation
+// failures must not be echoed wholesale — a lost CAS race is retry-worthy
+// (409, a fixed sentence), and a broken store is not the caller's fault and
+// must never put the store's own error text (bucket names, request ids) on
+// the wire (502, generic).
+func TestAPIPipelinePutStoreFailureStatuses(t *testing.T) {
+ t.Run("concurrent write is 409", func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-cas")
+ fx.srv.cfg.Releases = pipelineCASStore{fx.rel}
+
+ status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline",
+ map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("pipeline put racing a concurrent write = %d %v, want 409", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "re-read and retry") {
+ t.Fatalf("cas conflict error = %v, want it to say re-read and retry", out["error"])
+ }
+ })
+
+ t.Run("broken store is 502 and never leaks its error text", func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-broken")
+ fx.srv.cfg.Releases = brokenStore{fx.rel}
+
+ status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline",
+ map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("pipeline put against a broken store = %d %v, want 502", status, out)
+ }
+ if strings.Contains(str(t, out, "error"), "AccessDenied") ||
+ strings.Contains(str(t, out, "error"), "carlos-prod-secret-bucket") {
+ t.Fatalf("broken-store error leaked the store's own text: %v", out["error"])
+ }
+ })
+}
+
// TestAPIPipelinePutNoServiceRoleReachesIt: svcrole.Allow() with NO roles is
// the fully-closed Set — Admin included (svcrole.TestEmptySetPermitsNothingIncludingAdmin)
// — so the door must be registered svcrole.Allow(svcrole.Admin), not