console: a door to change which channel a route follows
The second half of the hello.bdn incident. A route's channel is
record-owned and no door could change it: carlos route -channel is reverted
by reconcile within seconds, PATCH /instances/{host} carries only addrs and
probe, the page is read-only. Repairing one took a raw S3 write.
Its own door, not a field on the PATCH: that door is deliberately ungated —
upstreams and probes are operational — and channel is the one record column
with release authority behind it.
The gate is both ends of the move, each at its strictest, and the reason is
a bypass that guarding only the destination misses entirely: edge has no
rules, production takes 2 approvals, so instead of promoting v5 onto
production you move the production route onto edge and its URL serves v5
with nobody's approval. Guarding only the source has the mirror hole. A
channel outside the app's declared pipeline keeps its legacy rung, so
declaring a pipeline that omits stable buys no free movement off it.
The zero svcrole Set: a credential the app holds must not walk its own
route onto a channel whose rules it never met.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+511
−2
internal/console/authcli.go+6 −0internal/console/channelmove.go+222 −0internal/console/channelmove_test.go+249 −0internal/console/pendinghandlers.go+15 −0internal/console/svcroutes_test.go+9 −2internal/release/pipeline.go+10 −0
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 292d58812..318e80a6e 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -567,6 +567,12 @@ func (s *Server) registerCLIRoutes() { |
| // door, the ANSWER a credential gets here moves from 401 to 403. |
| s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceList) |
| + // Moving a route between channels (channelmove.go). The zero Set, for |
| + // the manifest door's reason: this decides what a route SERVES, and a |
| + // credential the app itself holds must not be able to walk its own route |
| + // onto a channel whose rules it never met. Widening it later is easy; |
| + // narrowing an authority already granted is not. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel) |
| // The logs read (`carlos logs`, apilogs.go) — same gate, same 404 |
| // discipline, and an app token may read its own app's logs. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs) |
diff --git a/internal/console/channelmove.go b/internal/console/channelmove.go| new file mode 100644 |
| index 000000000..e3fa90d60 |
| --- /dev/null |
| +++ b/internal/console/channelmove.go |
| @@ -0,0 +1,222 @@ |
| +// Changing which channel one route follows — the gate, and the door. |
| +// |
| +// A route's channel belongs to its instance record: the provisioning |
| +// reconciler converges it from apps/<sqid>/<app>/instances/<host>.json on |
| +// every pass, which is what makes it durable across registry rebuilds and |
| +// second boxes. The consequence was that nothing could change it. `carlos |
| +// route -channel` on a box is reverted within seconds, PATCH /instances/ |
| +// {host} carries only addrs and probe, and the console page is read-only — |
| +// so a route that landed on the wrong channel had a URL that served nothing |
| +// and no way back. Repairing one took a raw S3 write and a hand-bumped |
| +// serial (hello.bdn, 2026-08-22). |
| +// |
| +// This is its own door rather than a field on the PATCH. That door is |
| +// deliberately ungated — upstreams and probes are operational fields any |
| +// member may move — and channel is the one record column with release |
| +// authority behind it. Widening PATCH would put a gated field behind an |
| +// ungated handler and make that door's authority story depend on which key |
| +// the body happened to carry. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// moveGate is what a channel move must clear: the rules of BOTH ends, each |
| +// at its strictest. |
| +// |
| +// A move is not a promotion, but it can achieve one, and that is the whole |
| +// reason this is not simply "the destination's rules". Given `edge` (no |
| +// rules) and `production` (promote_approvals 2), promoting v5 onto |
| +// production takes two approvals — or the production route is moved onto |
| +// edge instead, and production's URL serves v5 with nobody's approval. |
| +// Guarding only the destination misses that entirely, because the |
| +// destination is the unguarded channel; guarding only the source lets a |
| +// route be walked ONTO a channel whose policy the mover never met. |
| +// |
| +// promote_approvals counts here even though nothing is being promoted: it is |
| +// the number that expresses "this channel's URL only serves what N people |
| +// agreed to", and a move is a way to change what that URL serves. |
| +// |
| +// A channel the pipeline does not name falls back to its LEGACY rung, so an |
| +// app cannot buy free movement off stable by declaring a pipeline that omits |
| +// it. A canary/* end is reserved platform namespace, outside every pipeline: |
| +// ungated at that end, and it never launders the other end's rules. |
| +func moveGate(p *release.Pipeline, from, to string) (passkey bool, approvals int) { |
| + for _, ch := range []string{from, to} { |
| + if rung, ok := release.Rung(ch); ok && rung == release.RungCanary { |
| + continue |
| + } |
| + if p != nil { |
| + if c, named := p.ChannelNamed(ch); named { |
| + if c.Rules.Passkey { |
| + passkey = true |
| + } |
| + if c.Rules.PromoteApprovals > approvals { |
| + approvals = c.Rules.PromoteApprovals |
| + } |
| + if c.Rules.ChangeApprovals > approvals { |
| + approvals = c.Rules.ChangeApprovals |
| + } |
| + continue |
| + } |
| + } |
| + // No pipeline, or a channel outside the one this app declared: the |
| + // legacy ladder governs, which means the console's standing |
| + // unconditional passkey on the stable rung — in both directions, |
| + // since moving OFF stable changes what stable's URL serves just as |
| + // surely as moving onto it. |
| + if rung, ok := release.Rung(ch); ok && rung == release.RungStable { |
| + passkey = true |
| + } |
| + } |
| + return passkey, approvals |
| +} |
| + |
| +// channelMovePayload is what a gated move pins for its confirmers. The etag |
| +// is the pin proper — execution refuses if the record moved underneath the |
| +// proposal — and From is what the summary and the audit line read back, so |
| +// what a confirmer approves is stated in the terms they were shown. |
| +type channelMovePayload struct { |
| + Host string `json:"host"` |
| + From string `json:"from"` |
| + To string `json:"to"` |
| + ETag string `json:"etag"` |
| +} |
| + |
| +// handleAPIInstanceChannel is POST /instances/{host}/channel: move one route |
| +// onto a different release channel. Refusal order mirrors the family's — |
| +// gate, record, shape, no-op, pipeline membership, rules, write. |
| +func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + host := r.PathValue("host") |
| + var req struct { |
| + Channel string `json:"channel"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + to := strings.TrimSpace(req.Channel) |
| + if !release.ValidChannel(to) { |
| + jsonError(w, http.StatusBadRequest, "invalid channel name") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + // The family's byte-identical 404: a record's existence is not a |
| + // thing to probe for here any more than it is on GET. |
| + http.NotFound(w, r) |
| + return |
| + case err != nil: |
| + log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return |
| + } |
| + from := rec.Channel |
| + if from == to { |
| + // Nothing changed: no write, no serial bump, no audit line. A serial |
| + // that names no change sends every box in the fleet converging for |
| + // nothing, and buries the tenant's audit log under no-ops. |
| + writeJSON(w, http.StatusOK, rec) |
| + return |
| + } |
| + pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api instance channel %s/%s: read pipeline: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the pipeline") |
| + return |
| + } |
| + // A pipelined app may only reach a channel its pipeline names. An |
| + // app-defined pipeline is a reason to govern its own channels, never a |
| + // way to reach one it left out — the promote door's own ruling, at the |
| + // same 409. |
| + if pipe != nil && pipe.Index(to) < 0 && !isCanaryChannel(to) { |
| + jsonError(w, http.StatusConflict, |
| + "this app's pipeline has no channel called "+to+" — it has "+strings.Join(pipe.Names(), ", ")) |
| + return |
| + } |
| + switch passkey, approvals := moveGate(pipe, from, to); { |
| + case approvals >= 1: |
| + s.proposeOverAPI(w, acct, app, kindRouteChannel, |
| + channelMovePayload{Host: host, From: from, To: to, ETag: etag}, approvals, principal) |
| + return |
| + case passkey: |
| + // A passkey rule with nothing to propose alongside it: one person's |
| + // presence IS the whole gate, so this door refuses and names the one |
| + // that can ask for it. (An approvals rule above subsumes this — |
| + // every confirmation counted toward a quorum is passkey-backed.) |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": "moving " + host + " between " + from + " and " + to + " needs a passkey — finish in the console", |
| + "console_url": s.appConsoleURL(acct, app), |
| + }) |
| + return |
| + } |
| + rec.Channel = to |
| + if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + jsonError(w, http.StatusConflict, "this instance changed while the request was in flight — try again") |
| + return |
| + } |
| + log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not change the channel — try again") |
| + return |
| + } |
| + s.audit(acct, app, principal, "instance-channel", "moved "+host+" from "+from+" to "+to+" (cli)") |
| + // A box-consumed write: the reconcile pass converges the route's Channel |
| + // column from this record, so announcing it is what makes the move take |
| + // seconds instead of an hour. |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, rec) |
| +} |
| + |
| +// isCanaryChannel reports whether a name is in the reserved canary |
| +// namespace, which sits outside every pipeline by design. |
| +func isCanaryChannel(channel string) bool { |
| + rung, ok := release.Rung(channel) |
| + return ok && rung == release.RungCanary |
| +} |
| + |
| +// executePendingRouteChannel carries out a confirmed move. The record is |
| +// re-read and the proposal's etag is the pin: a record that moved underneath |
| +// the proposal is a decision made about bytes that no longer exist, which is |
| +// exactly what staleProposal says. |
| +func (s *Server) executePendingRouteChannel(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| + var p channelMovePayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "", errors.New("this proposal's payload cannot be read — reject it and propose again") |
| + } |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, p.Host) |
| + if err != nil { |
| + log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err) |
| + return "", errors.New("could not read the instance") |
| + } |
| + if etag != p.ETag || rec.Channel != p.From { |
| + return "", staleProposal("the instance is on " + rec.Channel + " now") |
| + } |
| + rec.Channel = p.To |
| + if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| + log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err) |
| + return "", errors.New("could not change the channel — try again") |
| + } |
| + s.audit(acct, app, actor, "instance-channel", fmt.Sprintf( |
| + "moved %s from %s to %s (proposed by %s, %d confirmations)", |
| + p.Host, p.From, p.To, pc.Proposer, len(pc.Confirms))) |
| + s.bumpSerial(ctx) |
| + return "moved " + p.Host + " to " + p.To, nil |
| +} |
diff --git a/internal/console/channelmove_test.go b/internal/console/channelmove_test.go| index fccdcc7eb..f3165c061 100644 |
| --- a/internal/console/channelmove_test.go |
| +++ b/internal/console/channelmove_test.go |
| @@ -1,8 +1,14 @@ |
| package console |
| import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -92,3 +98,246 @@ func TestMoveGatePipelinedNameIsNotALegacyRung(t *testing.T) { |
| t.Fatalf("a pipelined app governs its OWN channels: got %v/%d, want free", passkey, approvals) |
| } |
| } |
| + |
| +// A route sitting on a channel the app's pipeline does NOT name — a legacy |
| +// route on stable, on an app that has since declared a pipeline without it — |
| +// keeps the legacy rung's protection. Anything else would make "declare a |
| +// pipeline that omits stable" a way to move production routes for free. |
| +func TestMoveGateChannelOutsideThePipelineKeepsItsLegacyRung(t *testing.T) { |
| + p := movePipe(release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production"}) |
| + if passkey, _ := moveGate(p, "stable", "edge"); !passkey { |
| + t.Fatal("a route on stable, outside the pipeline, must still need a passkey to move off it") |
| + } |
| +} |
| + |
| +// channelURL is the move door for one host. |
| +func channelURL(fx *consoleFixture, acct, app, host string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances/" + host + "/channel" |
| +} |
| + |
| +// recordOn puts one instance record in the bucket, on the named channel — |
| +// fixture state standing in for whatever created the route. |
| +func recordOn(t *testing.T, fx *consoleFixture, app, host, channel string) { |
| + t.Helper() |
| + rec := provision.Record{ |
| + Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: app, |
| + Account: release.OpsAccount, Channel: channel, CreatedAt: time.Now().UTC(), |
| + } |
| + if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func recordChannel(t *testing.T, fx *consoleFixture, app, host string) string { |
| + t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec.Channel |
| +} |
| + |
| +// A legacy app, neither end on stable: the move just lands, and the fleet is |
| +// told once. |
| +func TestAPIInstanceChannelLegacyMoveLands(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-move") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "edge") |
| + before := serial(t, fx) |
| + |
| + status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"beta"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("move = %d %s, want 200", status, out) |
| + } |
| + if got := recordChannel(t, fx, "titogo", host); got != "beta" { |
| + t.Fatalf("record channel = %q, want beta", got) |
| + } |
| + if got := serial(t, fx); got != before+1 { |
| + t.Fatalf("serial = %d, want exactly one bump from %d", got, before) |
| + } |
| +} |
| + |
| +// Either end on the stable rung is the console's standing passkey refusal, |
| +// and it names where to finish. |
| +func TestAPIInstanceChannelLegacyStableNeedsPasskey(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-stable") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "edge") |
| + |
| + status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"stable"}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("move onto stable = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "console_url") { |
| + t.Fatalf("403 body = %s, want it to name where to finish", out) |
| + } |
| + if got := recordChannel(t, fx, "titogo", host); got != "edge" { |
| + t.Fatalf("record moved anyway: %q", got) |
| + } |
| + |
| + // And OFF stable, which is the direction a naive gate forgets. |
| + recordOn(t, fx, "titogo", "other.go.tito.io", "stable") |
| + if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "other.go.tito.io"), |
| + member, `{"channel":"edge"}`); status != http.StatusForbidden { |
| + t.Fatalf("move OFF stable = %d %s, want 403", status, out) |
| + } |
| +} |
| + |
| +// The bypass, at the door: moving OFF a guarded channel onto an unguarded |
| +// one must meet the guarded channel's quorum, and must not write yet. |
| +func TestAPIInstanceChannelOffGuardedChannelProposes(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-guarded") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "production") |
| + setPipeline(t, fx, "titogo", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + |
| + status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("move off a guarded channel = %d %s, want 202 (proposed)", status, out) |
| + } |
| + if got := recordChannel(t, fx, "titogo", host); got != "production" { |
| + t.Fatalf("record channel = %q — a proposal must not have written", got) |
| + } |
| + if !strings.Contains(out, `"needed":2`) || !strings.Contains(out, `"result":"proposed"`) { |
| + t.Fatalf("proposal body = %s, want needed:2 and result:proposed", out) |
| + } |
| +} |
| + |
| +// A pipelined app may not reach a channel its pipeline left out, and the |
| +// refusal lists what it does have. |
| +func TestAPIInstanceChannelUnknownChannelNamesTheApps(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-unknown") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "edge") |
| + setPipeline(t, fx, "titogo", release.PipelineChannel{Name: "edge"}) |
| + |
| + status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"production"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("unknown channel = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "edge") { |
| + t.Fatalf("refusal = %s, want it to list the app's channels", out) |
| + } |
| +} |
| + |
| +// A no-op must not send the whole fleet converging for nothing. |
| +func TestAPIInstanceChannelNoOpDoesNotBumpTheSerial(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-noop") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "edge") |
| + before := serial(t, fx) |
| + |
| + if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), |
| + member, `{"channel":"edge"}`); status != http.StatusOK { |
| + t.Fatalf("no-op move = %d %s, want 200", status, out) |
| + } |
| + if got := serial(t, fx); got != before { |
| + t.Fatalf("serial = %d, want it unchanged at %d", got, before) |
| + } |
| +} |
| + |
| +// A host with no record is the family's 404, not an existence oracle. |
| +func TestAPIInstanceChannelUnknownHostIs404(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-404") |
| + if status, _, _ := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "nope.go.tito.io"), |
| + member, `{"channel":"edge"}`); status != http.StatusNotFound { |
| + t.Fatalf("unknown host = %d, want 404", status) |
| + } |
| +} |
| + |
| +// An app token is a credential the app itself holds: it must not be able to |
| +// walk its own route onto another channel. |
| +func TestAPIInstanceChannelRefusesAppToken(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-chan-apptok") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "edge") |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), appTok, `{"channel":"beta"}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("app token = %d %s, want 403 from the role gate", status, out) |
| + } |
| + if !strings.Contains(out, "needs a person") { |
| + t.Fatalf("app-token refusal = %s, want the role gate's own sentence", out) |
| + } |
| + if got := recordChannel(t, fx, "titogo", host); got != "edge" { |
| + t.Fatalf("record channel = %q, want edge", got) |
| + } |
| +} |
| + |
| +// The pin: a confirmed move writes, and a record that moved underneath the |
| +// proposal refuses instead. |
| +func TestExecutePendingRouteChannelWritesAndPins(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-chan-exec") |
| + const host = "web.go.tito.io" |
| + recordOn(t, fx, "titogo", host, "production") |
| + setPipeline(t, fx, "titogo", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}}) |
| + |
| + status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("propose = %d %s, want 202", status, out) |
| + } |
| + var body map[string]any |
| + if err := json.Unmarshal([]byte(out), &body); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, _ := body["pending_id"].(string) |
| + pc, err := fx.st.PendingChange(id, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + acct, err := fx.srv.store.AccountBySqid(release.OpsAccount) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The summary a confirmer reads names BOTH ends — what the URL serves |
| + // now matters as much as what it would serve after. |
| + if got := pendingSummary(*pc); got != "move "+host+" from production to edge" { |
| + t.Fatalf("summary = %q", got) |
| + } |
| + |
| + // Stale first: the record moves underneath the proposal. |
| + rec, etag, err := provision.GetRecordETag(context.Background(), fx.rel, release.OpsAccount, "titogo", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec.Channel = "beta" |
| + if err := provision.UpdateRecord(context.Background(), fx.rel, *rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc); err == nil || |
| + !strings.Contains(err.Error(), "re-propose") { |
| + t.Fatalf("stale execute err = %v, want the stale-proposal refusal", err) |
| + } |
| + if got := recordChannel(t, fx, "titogo", host); got != "beta" { |
| + t.Fatalf("a refused execution wrote anyway: %q", got) |
| + } |
| + |
| + // Now a proposal whose pin still holds. |
| + recordOn(t, fx, "titogo", "two.go.tito.io", "production") |
| + status, _, out = rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "two.go.tito.io"), member, `{"channel":"edge"}`) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("second propose = %d %s", status, out) |
| + } |
| + if err := json.Unmarshal([]byte(out), &body); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, _ = body["pending_id"].(string) |
| + pc2, err := fx.st.PendingChange(id, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc2); err != nil { |
| + t.Fatalf("execute: %v", err) |
| + } |
| + if got := recordChannel(t, fx, "titogo", "two.go.tito.io"); got != "edge" { |
| + t.Fatalf("record channel = %q, want edge", got) |
| + } |
| +} |
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go| index 7e783e49e..16f0bb5f7 100644 |
| --- a/internal/console/pendinghandlers.go |
| +++ b/internal/console/pendinghandlers.go |
| @@ -46,6 +46,11 @@ const ( |
| kindPromote = "promote" |
| kindPolicy = "policy" |
| kindFastTrack = "fasttrack" |
| + // kindRouteChannel moves one ROUTE between channels — not a release |
| + // anywhere, which is why it is its own kind rather than a promote with a |
| + // different payload: nothing is published, and what changes is which |
| + // pointer one host follows. |
| + kindRouteChannel = "route-channel" |
| ) |
| // pendingTimeout bounds the store round trips an execution makes — the same |
| @@ -168,6 +173,14 @@ func pendingSummary(pc PendingChange) string { |
| return "skip the safety delay on " + pc.App |
| } |
| return "skip the safety delay for " + p.Version + " on " + p.Channel |
| + case kindRouteChannel: |
| + var p channelMovePayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "move a route of " + pc.App |
| + } |
| + // Both ends, because a confirmer judging this needs to know what the |
| + // URL serves NOW as much as what it would serve after. |
| + return "move " + p.Host + " from " + p.From + " to " + p.To |
| } |
| return pc.Kind + " on " + pc.App |
| } |
| @@ -304,6 +317,8 @@ func (s *Server) executePending(ctx context.Context, acct *Account, app, actor s |
| msg, err = s.executePendingPolicy(ctx, acct, app, actor, pc) |
| case kindFastTrack: |
| msg, err = s.executePendingFastTrack(ctx, acct, app, actor, pc) |
| + case kindRouteChannel: |
| + msg, err = s.executePendingRouteChannel(ctx, acct, app, actor, pc) |
| default: |
| return "", fmt.Errorf("this console does not know how to carry out a %q change", pc.Kind) |
| } |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 3bcf125d1..b8b548fbf 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -124,6 +124,12 @@ var closedDoors = []string{ |
| // It also raises the daily cap in the same request, which is an |
| // authority grant rather than a runtime toggle. |
| "POST /api/cli/apps/{account}/{app}/email/pause", |
| + // Moving a route between channels decides what that route SERVES, which |
| + // is release authority wearing a config-shaped hat: a credential the app |
| + // itself holds must not be able to walk its own route onto a channel |
| + // whose rules it never met. Shut for the same reason the manifest door |
| + // is — an app may not rewrite the bounds it runs inside. |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/channel", |
| } |
| // TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| @@ -617,6 +623,7 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| "{fleet}", "edge", |
| "{label}", "box1", |
| "{name}", "hub", |
| + "{host}", "web.go.tito.io", |
| ).Replace(path) |
| if strings.Contains(u, "{") { |
| t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern) |
| @@ -634,8 +641,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 16 { |
| - t.Errorf("walked %d closed doors, want all %d (and 16 today: instance-token, the routes door, instances-manifest, six fleets, four services, two email credentials, email pause)", |
| + if seen != len(closedDoors) || seen != 17 { |
| + t.Errorf("walked %d closed doors, want all %d (and 17 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/release/pipeline.go b/internal/release/pipeline.go| index cb7534373..d01983f27 100644 |
| --- a/internal/release/pipeline.go |
| +++ b/internal/release/pipeline.go |
| @@ -97,6 +97,16 @@ func (p *Pipeline) ChannelNamed(name string) (PipelineChannel, bool) { |
| return PipelineChannel{}, false |
| } |
| +// Names is the pipeline's channel names in promotion order — what a refusal |
| +// lists when it has to say which channels an app actually has. |
| +func (p *Pipeline) Names() []string { |
| + out := make([]string, len(p.Channels)) |
| + for i, c := range p.Channels { |
| + out[i] = c.Name |
| + } |
| + return out |
| +} |
| + |
| // MaxApprovals is the largest quorum any channel demands — the console |
| // refuses to store a pipeline whose MaxApprovals exceeds the account's |
| // member count (spec §4). |