// 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///instances/.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. // // An EMPTY channel reaches here and is free, which is right rather // than an oversight: the provisioning reconciler refuses to mint a // route from a record with no channel at all (reconcile.go's // "skipping record: empty channel"), so such a record serves no // traffic to protect — and naming one is precisely the repair this // door exists for. 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, recordView(*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.auditActivity(acct, app, principal, Activity{Action: "instance-channel", TargetType: "instance", Target: host, Detail: "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, recordView(*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.auditActivity(acct, app, actor, Activity{Action: "instance-channel", TargetType: "instance", Target: p.Host, Detail: 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 }