| 1 | // Changing which channel one route follows — the gate, and the door. |
| 2 | // |
| 3 | // A route's channel belongs to its instance record: the provisioning |
| 4 | // reconciler converges it from apps/<sqid>/<app>/instances/<host>.json on |
| 5 | // every pass, which is what makes it durable across registry rebuilds and |
| 6 | // second boxes. The consequence was that nothing could change it. `carlos |
| 7 | // route -channel` on a box is reverted within seconds, PATCH /instances/ |
| 8 | // {host} carries only addrs and probe, and the console page is read-only — |
| 9 | // so a route that landed on the wrong channel had a URL that served nothing |
| 10 | // and no way back. Repairing one took a raw S3 write and a hand-bumped |
| 11 | // serial (hello.bdn, 2026-08-22). |
| 12 | // |
| 13 | // This is its own door rather than a field on the PATCH. That door is |
| 14 | // deliberately ungated — upstreams and probes are operational fields any |
| 15 | // member may move — and channel is the one record column with release |
| 16 | // authority behind it. Widening PATCH would put a gated field behind an |
| 17 | // ungated handler and make that door's authority story depend on which key |
| 18 | // the body happened to carry. |
| 19 | package console |
| 20 | |
| 21 | import ( |
| 22 | "context" |
| 23 | "encoding/json" |
| 24 | "errors" |
| 25 | "fmt" |
| 26 | "log" |
| 27 | "net/http" |
| 28 | "os" |
| 29 | "strings" |
| 30 | |
| 31 | "github.com/carlosframework/platform/internal/provision" |
| 32 | "github.com/carlosframework/platform/internal/release" |
| 33 | ) |
| 34 | |
| 35 | // moveGate is what a channel move must clear: the rules of BOTH ends, each |
| 36 | // at its strictest. |
| 37 | // |
| 38 | // A move is not a promotion, but it can achieve one, and that is the whole |
| 39 | // reason this is not simply "the destination's rules". Given `edge` (no |
| 40 | // rules) and `production` (promote_approvals 2), promoting v5 onto |
| 41 | // production takes two approvals — or the production route is moved onto |
| 42 | // edge instead, and production's URL serves v5 with nobody's approval. |
| 43 | // Guarding only the destination misses that entirely, because the |
| 44 | // destination is the unguarded channel; guarding only the source lets a |
| 45 | // route be walked ONTO a channel whose policy the mover never met. |
| 46 | // |
| 47 | // promote_approvals counts here even though nothing is being promoted: it is |
| 48 | // the number that expresses "this channel's URL only serves what N people |
| 49 | // agreed to", and a move is a way to change what that URL serves. |
| 50 | // |
| 51 | // A channel the pipeline does not name falls back to its LEGACY rung, so an |
| 52 | // app cannot buy free movement off stable by declaring a pipeline that omits |
| 53 | // it. A canary/* end is reserved platform namespace, outside every pipeline: |
| 54 | // ungated at that end, and it never launders the other end's rules. |
| 55 | func moveGate(p *release.Pipeline, from, to string) (passkey bool, approvals int) { |
| 56 | for _, ch := range []string{from, to} { |
| 57 | if rung, ok := release.Rung(ch); ok && rung == release.RungCanary { |
| 58 | continue |
| 59 | } |
| 60 | if p != nil { |
| 61 | if c, named := p.ChannelNamed(ch); named { |
| 62 | if c.Rules.Passkey { |
| 63 | passkey = true |
| 64 | } |
| 65 | if c.Rules.PromoteApprovals > approvals { |
| 66 | approvals = c.Rules.PromoteApprovals |
| 67 | } |
| 68 | if c.Rules.ChangeApprovals > approvals { |
| 69 | approvals = c.Rules.ChangeApprovals |
| 70 | } |
| 71 | continue |
| 72 | } |
| 73 | } |
| 74 | // No pipeline, or a channel outside the one this app declared: the |
| 75 | // legacy ladder governs, which means the console's standing |
| 76 | // unconditional passkey on the stable rung — in both directions, |
| 77 | // since moving OFF stable changes what stable's URL serves just as |
| 78 | // surely as moving onto it. |
| 79 | // |
| 80 | // An EMPTY channel reaches here and is free, which is right rather |
| 81 | // than an oversight: the provisioning reconciler refuses to mint a |
| 82 | // route from a record with no channel at all (reconcile.go's |
| 83 | // "skipping record: empty channel"), so such a record serves no |
| 84 | // traffic to protect — and naming one is precisely the repair this |
| 85 | // door exists for. |
| 86 | if rung, ok := release.Rung(ch); ok && rung == release.RungStable { |
| 87 | passkey = true |
| 88 | } |
| 89 | } |
| 90 | return passkey, approvals |
| 91 | } |
| 92 | |
| 93 | // channelMovePayload is what a gated move pins for its confirmers. The etag |
| 94 | // is the pin proper — execution refuses if the record moved underneath the |
| 95 | // proposal — and From is what the summary and the audit line read back, so |
| 96 | // what a confirmer approves is stated in the terms they were shown. |
| 97 | type channelMovePayload struct { |
| 98 | Host string `json:"host"` |
| 99 | From string `json:"from"` |
| 100 | To string `json:"to"` |
| 101 | ETag string `json:"etag"` |
| 102 | } |
| 103 | |
| 104 | // handleAPIInstanceChannel is POST /instances/{host}/channel: move one route |
| 105 | // onto a different release channel. Refusal order mirrors the family's — |
| 106 | // gate, record, shape, no-op, pipeline membership, rules, write. |
| 107 | func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request) { |
| 108 | principal, acct, app, ok := s.instanceGate(w, r) |
| 109 | if !ok { |
| 110 | return |
| 111 | } |
| 112 | host := r.PathValue("host") |
| 113 | var req struct { |
| 114 | Channel string `json:"channel"` |
| 115 | } |
| 116 | if !readJSON(w, r, &req) { |
| 117 | return |
| 118 | } |
| 119 | to := strings.TrimSpace(req.Channel) |
| 120 | if !release.ValidChannel(to) { |
| 121 | jsonError(w, http.StatusBadRequest, "invalid channel name") |
| 122 | return |
| 123 | } |
| 124 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 125 | defer cancel() |
| 126 | rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| 127 | switch { |
| 128 | case errors.Is(err, os.ErrNotExist): |
| 129 | // The family's byte-identical 404: a record's existence is not a |
| 130 | // thing to probe for here any more than it is on GET. |
| 131 | http.NotFound(w, r) |
| 132 | return |
| 133 | case err != nil: |
| 134 | log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 135 | jsonError(w, http.StatusBadGateway, "could not read the instance") |
| 136 | return |
| 137 | } |
| 138 | from := rec.Channel |
| 139 | if from == to { |
| 140 | // Nothing changed: no write, no serial bump, no audit line. A serial |
| 141 | // that names no change sends every box in the fleet converging for |
| 142 | // nothing, and buries the tenant's audit log under no-ops. |
| 143 | writeJSON(w, http.StatusOK, recordView(*rec)) |
| 144 | return |
| 145 | } |
| 146 | pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 147 | if err != nil { |
| 148 | log.Printf("console: api instance channel %s/%s: read pipeline: %v", acct.Sqid, app, err) |
| 149 | jsonError(w, http.StatusBadGateway, "could not read the pipeline") |
| 150 | return |
| 151 | } |
| 152 | // A pipelined app may only reach a channel its pipeline names. An |
| 153 | // app-defined pipeline is a reason to govern its own channels, never a |
| 154 | // way to reach one it left out — the promote door's own ruling, at the |
| 155 | // same 409. |
| 156 | if pipe != nil && pipe.Index(to) < 0 && !isCanaryChannel(to) { |
| 157 | jsonError(w, http.StatusConflict, |
| 158 | "this app's pipeline has no channel called "+to+" — it has "+strings.Join(pipe.Names(), ", ")) |
| 159 | return |
| 160 | } |
| 161 | switch passkey, approvals := moveGate(pipe, from, to); { |
| 162 | case approvals >= 1: |
| 163 | s.proposeOverAPI(w, acct, app, kindRouteChannel, |
| 164 | channelMovePayload{Host: host, From: from, To: to, ETag: etag}, approvals, principal) |
| 165 | return |
| 166 | case passkey: |
| 167 | // A passkey rule with nothing to propose alongside it: one person's |
| 168 | // presence IS the whole gate, so this door refuses and names the one |
| 169 | // that can ask for it. (An approvals rule above subsumes this — |
| 170 | // every confirmation counted toward a quorum is passkey-backed.) |
| 171 | writeJSON(w, http.StatusForbidden, map[string]string{ |
| 172 | "error": "moving " + host + " between " + from + " and " + to + " needs a passkey — finish in the console", |
| 173 | "console_url": s.appConsoleURL(acct, app), |
| 174 | }) |
| 175 | return |
| 176 | } |
| 177 | rec.Channel = to |
| 178 | if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| 179 | if errors.Is(err, release.ErrCASConflict) { |
| 180 | jsonError(w, http.StatusConflict, "this instance changed while the request was in flight — try again") |
| 181 | return |
| 182 | } |
| 183 | log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 184 | jsonError(w, http.StatusBadGateway, "could not change the channel — try again") |
| 185 | return |
| 186 | } |
| 187 | s.auditActivity(acct, app, principal, Activity{Action: "instance-channel", TargetType: "instance", Target: host, Detail: "moved " + host + " from " + from + " to " + to + " (cli)"}) |
| 188 | // A box-consumed write: the reconcile pass converges the route's Channel |
| 189 | // column from this record, so announcing it is what makes the move take |
| 190 | // seconds instead of an hour. |
| 191 | s.bumpSerial(ctx) |
| 192 | writeJSON(w, http.StatusOK, recordView(*rec)) |
| 193 | } |
| 194 | |
| 195 | // isCanaryChannel reports whether a name is in the reserved canary |
| 196 | // namespace, which sits outside every pipeline by design. |
| 197 | func isCanaryChannel(channel string) bool { |
| 198 | rung, ok := release.Rung(channel) |
| 199 | return ok && rung == release.RungCanary |
| 200 | } |
| 201 | |
| 202 | // executePendingRouteChannel carries out a confirmed move. The record is |
| 203 | // re-read and the proposal's etag is the pin: a record that moved underneath |
| 204 | // the proposal is a decision made about bytes that no longer exist, which is |
| 205 | // exactly what staleProposal says. |
| 206 | func (s *Server) executePendingRouteChannel(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| 207 | var p channelMovePayload |
| 208 | if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| 209 | return "", errors.New("this proposal's payload cannot be read — reject it and propose again") |
| 210 | } |
| 211 | rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, p.Host) |
| 212 | if err != nil { |
| 213 | log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err) |
| 214 | return "", errors.New("could not read the instance") |
| 215 | } |
| 216 | if etag != p.ETag || rec.Channel != p.From { |
| 217 | return "", staleProposal("the instance is on " + rec.Channel + " now") |
| 218 | } |
| 219 | rec.Channel = p.To |
| 220 | if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| 221 | log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err) |
| 222 | return "", errors.New("could not change the channel — try again") |
| 223 | } |
| 224 | s.auditActivity(acct, app, actor, Activity{Action: "instance-channel", TargetType: "instance", Target: p.Host, Detail: fmt.Sprintf( |
| 225 | "moved %s from %s to %s (proposed by %s, %d confirmations)", |
| 226 | p.Host, p.From, p.To, pc.Proposer, len(pc.Confirms))}) |
| 227 | s.bumpSerial(ctx) |
| 228 | return "moved " + p.Host + " to " + p.To, nil |
| 229 | } |
| 230 | |