| 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. |
| 59 | package console |
| 60 | |
| 61 | import ( |
| 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. |
| 77 | const 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. |
| 82 | const 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. |
| 88 | type 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`. |
| 97 | func (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)". |
| 252 | func 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. |
| 270 | func (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. |
| 296 | func 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 | |
| 333 | func 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. |
| 344 | func 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. |
| 358 | func 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. |
| 383 | func 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. |
| 396 | func 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 | |