carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 27ec15afc60e8ab149617658fb5a85ca9ba844f5 parent 8f18645
6 files changed, +511 −2
  • internal/console/authcli.go +6 −0
  • internal/console/channelmove.go +222 −0
  • internal/console/channelmove_test.go +249 −0
  • internal/console/pendinghandlers.go +15 −0
  • internal/console/svcroutes_test.go +9 −2
  • internal/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).