carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
19package console
20
21import (
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.
55func 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.
97type 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.
107func (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.
197func 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.
206func (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