carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The NARROWED restart door (discussion #16): `carlos restart --host` and
2// `carlos restart --environment`, plus the dry run that shows an operator the
3// blast radius before anything stops.
4//
5// It is a SEPARATE PATH from handleAPIRestart, and that is a safety property
6// rather than tidiness. The old door reads no body at all, so a narrowed
7// request sent to an old console would be answered by cycling every instance
8// the app owns — the exact accident this feature exists to prevent, committed
9// by the server instead of the box. A distinct path cannot be misread: a
10// console that predates this answers 404, the CLI says so and nothing is
11// restarted. The rule is the same one internal/release/restartscope.go states
12// for boxes — a reader that cannot understand a narrowing must do NOTHING,
13// never something wider.
14//
15// The doors, in order, are handleAPIRestart's with two additions:
16//
17// token — cliUser's one 401, before any name is resolved.
18// account+app 404 — apiAccountApp, so probing teaches a stranger nothing.
19// body shape — 400 for a field this door does not declare. A renamed
20// or misspelled key that was merely IGNORED would leave a
21// body with no selector in it, and no selector means the
22// whole app.
23// selector shape — 400 for a malformed host or environment, before the
24// registry is opened.
25// registry — 503 when this console serves none. A narrowed request
26// cannot be resolved OR reported without one, and the one
27// thing this door must never do is answer "requested" for
28// a selector it did not check.
29// resolution — 400 when the selector names nothing, LISTING what the
30// app actually has. A typo in --environment that fell
31// through to an app-wide restart would be a worse version
32// of the bug being fixed.
33// narrowing — 400 for a WRITE with no selector at all. The app-wide
34// case is answered here only as a --dry-run, which is how
35// somebody sees the blast radius before typing the real
36// verb; performing it belongs to the app-wide door, whose
37// object every box in the fleet already reads.
38// rate limit — the shared `write` budget, keyed on the address, and
39// only for a request that WRITES. A dry run is a read of
40// this box's own registry, the same read `carlos routes`
41// makes through a door with no budget at all.
42// write + bump — the request object, then the announcement, in that
43// order.
44//
45// SCOPE: like handleAPIRoutes and the config door's restart notice, this
46// console reads its OWN box's registry, so in a multi-box fleet the other
47// boxes' routes are not represented in what it resolves or reports. Once a
48// request is accepted it does reach them — it is a bucket object every box
49// converges on — and each box matches it against its own routes, which is what
50// makes `--environment relay` mean "everything reading that bundle"
51// fleet-wide.
52//
53// The limit that cuts the other way, and is worth stating plainly because the
54// resolution gate above is what causes it: a selector NO route on this box
55// matches is refused, so a --host or an --environment that only another box in
56// the fleet knows about cannot be requested here at all. That direction is
57// safe — a refusal, never a wider restart — but it is a real limit, not just a
58// reporting one, and docs/site/cli.md says so in the same words.
59package console
60
61import (
62 "context"
63 "fmt"
64 "log"
65 "net/http"
66 "sort"
67 "strings"
68 "time"
69
70 "github.com/carlosframework/platform/internal/provision"
71 "github.com/carlosframework/platform/internal/registry"
72 "github.com/carlosframework/platform/internal/release"
73)
74
75// restartScopeMaxBytes bounds the request body. Two short strings and a bool;
76// anything larger is a client that has misunderstood the door.
77const restartScopeMaxBytes = 4 << 10
78
79// restartScopeNamesShown caps how many hosts or environments a refusal lists.
80// A refusal exists to let someone spot their typo, and nineteen names do that
81// where two hundred would not — the count that follows carries the rest.
82const restartScopeNamesShown = 20
83
84// apiRestartRoute is one addressed route in the answer. Environment and Unit
85// are the two columns an operator reads to check the set is the one they
86// meant; Hibernate is the one that says a named route will NOT cycle now, and
87// why that is correct rather than a failure.
88type apiRestartRoute struct {
89 Host string `json:"host"`
90 Environment string `json:"environment,omitempty"`
91 Unit string `json:"unit,omitempty"`
92 Hibernate bool `json:"hibernate"`
93}
94
95// handleAPIRestartScope answers `carlos restart --host/--environment` and any
96// `--dry-run`.
97func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) {
98 addr, acct, app, ok := s.apiAccountApp(w, r)
99 if !ok {
100 return
101 }
102 var req struct {
103 Host string `json:"host"`
104 Environment string `json:"environment"`
105 DryRun bool `json:"dry_run"`
106 Message string `json:"message"`
107 }
108 // STRICT, and that is a safety property rather than pedantry: a key this
109 // door does not declare — `"hosts"` for `"host"`, a field a future client
110 // renamed — would otherwise be dropped, leaving a body with no selector in
111 // it, which resolves to the whole app. A door that exists because the other
112 // door widens must not widen on a typo of its own.
113 if !readJSONStrictLimit(w, r, &req, restartScopeMaxBytes) {
114 return
115 }
116 message, ok := checkedActivityMessage(w, req.Message)
117 if !ok {
118 return
119 }
120 host := strings.TrimSpace(req.Host)
121 envName := strings.TrimSpace(req.Environment)
122 if host != "" && !provision.ValidHostShape(host) {
123 jsonError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a usable hostname", host))
124 return
125 }
126 // The config doors' own gate, reused rather than restated: one shape rule
127 // for one flag, including its refusal of the literal "default" (which
128 // names the base bundle no route is ever BOUND to — a restart of what
129 // reads that is a restart of the app).
130 if !validAPIEnvironment(w, envName) {
131 return
132 }
133 if s.cfg.Registry == "" {
134 // The app-wide door still works on a console like this, because it
135 // resolves nothing. This one cannot: it would have to answer
136 // "requested" for a selector it never checked and a set it cannot
137 // name, which is the whole thing the verb was narrowed to avoid.
138 // (Opening "" would also hand sqlite a throwaway temp DB and answer
139 // "no routes" having created the file it said that from —
140 // handleAPIRoutes' twin guard.)
141 jsonError(w, http.StatusServiceUnavailable,
142 "this console serves no route registry, so it cannot tell which routes a narrowed restart would address — "+
143 "use carlos restart --app to cycle the whole app, or run this against the console on the box")
144 return
145 }
146 reg, err := openRegistry(s.cfg.Registry)
147 if err != nil {
148 log.Printf("console: api restart scope: registry %s: %v", s.cfg.Registry, err)
149 jsonError(w, http.StatusBadGateway, "could not read the route registry — nothing was cycled; try again")
150 return
151 }
152 all, err := reg.List()
153 if err != nil {
154 log.Printf("console: api restart scope: registry list: %v", err)
155 jsonError(w, http.StatusBadGateway, "could not read the route registry — nothing was cycled; try again")
156 return
157 }
158 // The tenant boundary, and the only one: stamped to THIS app and resolving
159 // to THIS account, through release.RouteAccount rather than a bare column
160 // compare — a second private notion of "whose route is this" is how a
161 // listing ends up disagreeing with the reconciler about ownership.
162 // release.Adoptable on top, because that is the exact predicate the box's
163 // restart passes use: a route no pass would adopt is not one whose process
164 // this verb has any business claiming to cycle.
165 var mine []registry.Route
166 for _, rt := range all {
167 if rt.App != app || release.RouteAccount(rt) != acct.Sqid || !release.Adoptable(rt) {
168 continue
169 }
170 mine = append(mine, rt)
171 }
172 scope, addressed, refusal := resolveRestartScope(mine, host, envName)
173 if refusal != "" {
174 jsonError(w, http.StatusBadRequest, refusal)
175 return
176 }
177 // An app-wide request may be DESCRIBED here and never PERFORMED here. The
178 // dry run is the whole reason the app-wide case reaches this door at all —
179 // it is how somebody sees the blast radius before typing the real thing —
180 // but a write with no selector in it would cycle every route the app owns
181 // through the very door that exists because the other one widens. The
182 // app-wide restart has its own door, which every box in the fleet
183 // understands; this one only ever narrows.
184 if scope.Kind == release.RestartScopeApp && !req.DryRun {
185 jsonError(w, http.StatusBadRequest,
186 "this door only narrows a restart: name --host or --environment, or add --dry-run to see what a whole-app "+
187 "restart would cycle. To actually cycle the whole app, run: carlos restart --app "+app)
188 return
189 }
190 answer := map[string]any{
191 "account": acct.Sqid, "app": app,
192 "scope": map[string]string{"kind": scope.Kind, "value": scope.Value},
193 "dry_run": req.DryRun,
194 "requested": false,
195 "routes": restartRouteRows(addressed),
196 }
197 if req.DryRun {
198 // Nothing written, nothing bumped, nothing audited: this is a read of
199 // the registry with a selector applied, and the honest answer to
200 // "what would this cycle" must not itself cycle anything.
201 writeJSON(w, http.StatusOK, answer)
202 return
203 }
204 p, _ := principalFrom(r.Context())
205 if !s.allowWrite(p, time.Now()) {
206 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
207 return
208 }
209 if s.cfg.Releases == nil {
210 jsonError(w, http.StatusServiceUnavailable,
211 "no deployment bucket on this host — this console cannot request a restart")
212 return
213 }
214 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
215 defer cancel()
216 if err := s.writeRestartScope(ctx, acct.Sqid, app, scope); err != nil {
217 // Logged, not returned: a store error carries bucket names, endpoints
218 // and request ids. The caller gets the fixed sentence every other
219 // bucket failure here gets, and a retry is safe — one more request is
220 // one more cycle of the same routes.
221 log.Printf("console: api restart scope %s/%s (%s %s): %v", acct.Sqid, app, scope.Kind, scope.Value, err)
222 jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again")
223 return
224 }
225 targetType := "instance"
226 if scope.Kind == release.RestartScopeEnvironment {
227 targetType = "environment"
228 }
229 s.auditActivity(acct, app, addr, Activity{
230 Action: "restart",
231 TargetType: targetType,
232 Target: scope.Value,
233 Message: message,
234 Detail: restartAuditWords(scope),
235 })
236 // The request is in the bucket; this is what makes boxes LOOK at it — they
237 // read restart requests only when the serial moves.
238 s.bumpSerial(ctx)
239 answer["requested"] = true
240 writeJSON(w, http.StatusOK, answer)
241}
242
243// restartAuditWords is the audit row's sentence. It names the SELECTOR, not
244// the resolved hosts: the row is a record of what somebody asked for, and the
245// set it resolved to is a property of one box's registry at one moment, which
246// would read as a claim about the fleet the moment a route moved.
247//
248// The app-wide branch is kept although the handler no longer lets an app-wide
249// WRITE reach the audit at all: if a later edit ever restores one, the row it
250// writes must read as the app-wide door's own row rather than as
251// "requested a restart of app (cli)".
252func restartAuditWords(scope release.RestartScope) string {
253 if scope.Kind == release.RestartScopeApp {
254 return "requested a restart (cli)" // the app-wide door's own wording
255 }
256 return fmt.Sprintf("requested a restart of %s %s (cli)", scope.Kind, scope.Value)
257}
258
259// writeRestartScope puts the request where the boxes will find it: the log
260// only new boxes read, which is what keeps an old box from widening a request
261// it cannot understand into a whole-app restart.
262//
263// An app-wide scope is REFUSED here rather than forwarded to the old object,
264// which is belt to the handler's braces. The handler has already turned that
265// body away, and this is the check that keeps the refusal true if a later edit
266// moves or loses it: an app-wide restart is the other door's write, and a
267// second way to reach it through the narrowing door is exactly the accident
268// this file was built to prevent. The caller answers a store error with "could
269// not request the restart — nothing was cycled", which is the honest outcome.
270func (s *Server) writeRestartScope(ctx context.Context, acct, app string, scope release.RestartScope) error {
271 if scope.Kind != release.RestartScopeHost && scope.Kind != release.RestartScopeEnvironment {
272 return fmt.Errorf("restart scope: %q is not a narrowing — an app-wide restart belongs to the app-wide door", scope.Kind)
273 }
274 _, err := release.AppendRestartScope(ctx, s.cfg.Releases, acct, app, scope)
275 return err
276}
277
278// resolveRestartScope turns the two flags into ONE selector and the routes it
279// addresses, or into the sentence that refuses them.
280//
281// PRECEDENCE, and the reason it is this and not "narrowest silently wins":
282//
283// - --host alone selects that one route.
284// - --environment alone selects every route bound to that bundle — what
285// "restart what reads this" means, and the shape that pairs with
286// `carlos secrets set --environment`.
287// - both: --host wins, but only after the named host is CHECKED against the
288// named environment. Quietly ignoring the wider flag would hide the case
289// where one of the two is a typo, and this verb's whole job is telling an
290// operator what they are about to cycle.
291// - neither: the app, exactly as before.
292//
293// A selector that names nothing is refused with what the app actually has —
294// never widened. An --environment typo that fell through to an app-wide
295// restart is the nineteen-route accident this door was built to prevent.
296func resolveRestartScope(routes []registry.Route, host, envName string) (release.RestartScope, []registry.Route, string) {
297 switch {
298 case host != "":
299 rt, ok := routeWithHost(routes, host)
300 if !ok {
301 return release.RestartScope{}, nil, fmt.Sprintf(
302 "no route %s on this app — this console's box serves %s",
303 host, namesOrNone(hostNames(routes), "no routes for it"))
304 }
305 if envName != "" && rt.Environment != envName {
306 if rt.Environment == "" {
307 return release.RestartScope{}, nil, fmt.Sprintf(
308 "%s is bound to no environment (it reads the app's default bundle), not %s — "+
309 "one of --host and --environment is a typo, so nothing was cycled", host, envName)
310 }
311 return release.RestartScope{}, nil, fmt.Sprintf(
312 "%s reads environment %s, not %s — one of --host and --environment is a typo, so nothing was cycled",
313 host, rt.Environment, envName)
314 }
315 return release.RestartScope{Kind: release.RestartScopeHost, Value: host}, []registry.Route{rt}, ""
316 case envName != "":
317 var hit []registry.Route
318 for _, rt := range routes {
319 if rt.Environment == envName {
320 hit = append(hit, rt)
321 }
322 }
323 if len(hit) == 0 {
324 return release.RestartScope{}, nil, fmt.Sprintf(
325 "no route of this app reads environment %s — this console's box has %s",
326 envName, namesOrNone(environmentNames(routes), "no routes for it at all"))
327 }
328 return release.RestartScope{Kind: release.RestartScopeEnvironment, Value: envName}, hit, ""
329 }
330 return release.RestartScope{Kind: release.RestartScopeApp, Value: ""}, routes, ""
331}
332
333func routeWithHost(routes []registry.Route, host string) (registry.Route, bool) {
334 for _, rt := range routes {
335 if rt.Host == host {
336 return rt, true
337 }
338 }
339 return registry.Route{}, false
340}
341
342// hostNames is every host this app has here, sorted — the "what exists" half
343// of a --host refusal.
344func hostNames(routes []registry.Route) []string {
345 out := make([]string, 0, len(routes))
346 for _, rt := range routes {
347 out = append(out, rt.Host)
348 }
349 sort.Strings(out)
350 return out
351}
352
353// environmentNames is the distinct environments this app's routes are bound
354// to, sorted, with the unbound ones named as what they are. A refusal that
355// listed only the named bundles would leave someone whose route reads the
356// default one unable to tell why their spelling "failed" — the answer being
357// that there is nothing to spell.
358func environmentNames(routes []registry.Route) []string {
359 seen := map[string]bool{}
360 var out []string
361 for _, rt := range routes {
362 name := rt.Environment
363 if name == "" {
364 // Named as what it is rather than printed as an empty string: a
365 // route with no binding reads the app's default bundle, which is
366 // an answer, not missing data. It is also not a value --environment
367 // will accept, and the parentheses are what say so.
368 name = "(routes on the default bundle, which --environment cannot name)"
369 }
370 if seen[name] {
371 continue
372 }
373 seen[name] = true
374 out = append(out, name)
375 }
376 sort.Strings(out)
377 return out
378}
379
380// namesOrNone renders a bounded list for a refusal. The cap keeps a
381// nineteen-route app readable and a two-hundred-route one from burying the
382// point; the remainder is counted rather than dropped silently.
383func namesOrNone(names []string, empty string) string {
384 if len(names) == 0 {
385 return empty
386 }
387 if len(names) <= restartScopeNamesShown {
388 return strings.Join(names, ", ")
389 }
390 return fmt.Sprintf("%s and %d more", strings.Join(names[:restartScopeNamesShown], ", "), len(names)-restartScopeNamesShown)
391}
392
393// restartRouteRows renders the addressed set, sorted by host so a caller's
394// output — and any test pinning it — is stable across passes, where registry
395// order is not.
396func restartRouteRows(routes []registry.Route) []apiRestartRoute {
397 out := make([]apiRestartRoute, 0, len(routes))
398 for _, rt := range routes {
399 out = append(out, apiRestartRoute{
400 Host: rt.Host, Environment: rt.Environment, Unit: rt.Unit, Hibernate: rt.Hibernate,
401 })
402 }
403 sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
404 return out
405}
406