// The NARROWED restart door (discussion #16): `carlos restart --host` and // `carlos restart --environment`, plus the dry run that shows an operator the // blast radius before anything stops. // // It is a SEPARATE PATH from handleAPIRestart, and that is a safety property // rather than tidiness. The old door reads no body at all, so a narrowed // request sent to an old console would be answered by cycling every instance // the app owns — the exact accident this feature exists to prevent, committed // by the server instead of the box. A distinct path cannot be misread: a // console that predates this answers 404, the CLI says so and nothing is // restarted. The rule is the same one internal/release/restartscope.go states // for boxes — a reader that cannot understand a narrowing must do NOTHING, // never something wider. // // The doors, in order, are handleAPIRestart's with two additions: // // token — cliUser's one 401, before any name is resolved. // account+app 404 — apiAccountApp, so probing teaches a stranger nothing. // body shape — 400 for a field this door does not declare. A renamed // or misspelled key that was merely IGNORED would leave a // body with no selector in it, and no selector means the // whole app. // selector shape — 400 for a malformed host or environment, before the // registry is opened. // registry — 503 when this console serves none. A narrowed request // cannot be resolved OR reported without one, and the one // thing this door must never do is answer "requested" for // a selector it did not check. // resolution — 400 when the selector names nothing, LISTING what the // app actually has. A typo in --environment that fell // through to an app-wide restart would be a worse version // of the bug being fixed. // narrowing — 400 for a WRITE with no selector at all. The app-wide // case is answered here only as a --dry-run, which is how // somebody sees the blast radius before typing the real // verb; performing it belongs to the app-wide door, whose // object every box in the fleet already reads. // rate limit — the shared `write` budget, keyed on the address, and // only for a request that WRITES. A dry run is a read of // this box's own registry, the same read `carlos routes` // makes through a door with no budget at all. // write + bump — the request object, then the announcement, in that // order. // // SCOPE: like handleAPIRoutes and the config door's restart notice, this // console reads its OWN box's registry, so in a multi-box fleet the other // boxes' routes are not represented in what it resolves or reports. Once a // request is accepted it does reach them — it is a bucket object every box // converges on — and each box matches it against its own routes, which is what // makes `--environment relay` mean "everything reading that bundle" // fleet-wide. // // The limit that cuts the other way, and is worth stating plainly because the // resolution gate above is what causes it: a selector NO route on this box // matches is refused, so a --host or an --environment that only another box in // the fleet knows about cannot be requested here at all. That direction is // safe — a refusal, never a wider restart — but it is a real limit, not just a // reporting one, and docs/site/cli.md says so in the same words. package console import ( "context" "fmt" "log" "net/http" "sort" "strings" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // restartScopeMaxBytes bounds the request body. Two short strings and a bool; // anything larger is a client that has misunderstood the door. const restartScopeMaxBytes = 4 << 10 // restartScopeNamesShown caps how many hosts or environments a refusal lists. // A refusal exists to let someone spot their typo, and nineteen names do that // where two hundred would not — the count that follows carries the rest. const restartScopeNamesShown = 20 // apiRestartRoute is one addressed route in the answer. Environment and Unit // are the two columns an operator reads to check the set is the one they // meant; Hibernate is the one that says a named route will NOT cycle now, and // why that is correct rather than a failure. type apiRestartRoute struct { Host string `json:"host"` Environment string `json:"environment,omitempty"` Unit string `json:"unit,omitempty"` Hibernate bool `json:"hibernate"` } // handleAPIRestartScope answers `carlos restart --host/--environment` and any // `--dry-run`. func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } var req struct { Host string `json:"host"` Environment string `json:"environment"` DryRun bool `json:"dry_run"` Message string `json:"message"` } // STRICT, and that is a safety property rather than pedantry: a key this // door does not declare — `"hosts"` for `"host"`, a field a future client // renamed — would otherwise be dropped, leaving a body with no selector in // it, which resolves to the whole app. A door that exists because the other // door widens must not widen on a typo of its own. if !readJSONStrictLimit(w, r, &req, restartScopeMaxBytes) { return } message, ok := checkedActivityMessage(w, req.Message) if !ok { return } host := strings.TrimSpace(req.Host) envName := strings.TrimSpace(req.Environment) if host != "" && !provision.ValidHostShape(host) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a usable hostname", host)) return } // The config doors' own gate, reused rather than restated: one shape rule // for one flag, including its refusal of the literal "default" (which // names the base bundle no route is ever BOUND to — a restart of what // reads that is a restart of the app). if !validAPIEnvironment(w, envName) { return } if s.cfg.Registry == "" { // The app-wide door still works on a console like this, because it // resolves nothing. This one cannot: it would have to answer // "requested" for a selector it never checked and a set it cannot // name, which is the whole thing the verb was narrowed to avoid. // (Opening "" would also hand sqlite a throwaway temp DB and answer // "no routes" having created the file it said that from — // handleAPIRoutes' twin guard.) jsonError(w, http.StatusServiceUnavailable, "this console serves no route registry, so it cannot tell which routes a narrowed restart would address — "+ "use carlos restart --app to cycle the whole app, or run this against the console on the box") return } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: api restart scope: registry %s: %v", s.cfg.Registry, err) jsonError(w, http.StatusBadGateway, "could not read the route registry — nothing was cycled; try again") return } all, err := reg.List() if err != nil { log.Printf("console: api restart scope: registry list: %v", err) jsonError(w, http.StatusBadGateway, "could not read the route registry — nothing was cycled; try again") return } // The tenant boundary, and the only one: stamped to THIS app and resolving // to THIS account, through release.RouteAccount rather than a bare column // compare — a second private notion of "whose route is this" is how a // listing ends up disagreeing with the reconciler about ownership. // release.Adoptable on top, because that is the exact predicate the box's // restart passes use: a route no pass would adopt is not one whose process // this verb has any business claiming to cycle. var mine []registry.Route for _, rt := range all { if rt.App != app || release.RouteAccount(rt) != acct.Sqid || !release.Adoptable(rt) { continue } mine = append(mine, rt) } scope, addressed, refusal := resolveRestartScope(mine, host, envName) if refusal != "" { jsonError(w, http.StatusBadRequest, refusal) return } // An app-wide request may be DESCRIBED here and never PERFORMED here. The // dry run is the whole reason the app-wide case reaches this door at all — // it is how somebody sees the blast radius before typing the real thing — // but a write with no selector in it would cycle every route the app owns // through the very door that exists because the other one widens. The // app-wide restart has its own door, which every box in the fleet // understands; this one only ever narrows. if scope.Kind == release.RestartScopeApp && !req.DryRun { jsonError(w, http.StatusBadRequest, "this door only narrows a restart: name --host or --environment, or add --dry-run to see what a whole-app "+ "restart would cycle. To actually cycle the whole app, run: carlos restart --app "+app) return } answer := map[string]any{ "account": acct.Sqid, "app": app, "scope": map[string]string{"kind": scope.Kind, "value": scope.Value}, "dry_run": req.DryRun, "requested": false, "routes": restartRouteRows(addressed), } if req.DryRun { // Nothing written, nothing bumped, nothing audited: this is a read of // the registry with a selector applied, and the honest answer to // "what would this cycle" must not itself cycle anything. writeJSON(w, http.StatusOK, answer) return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot request a restart") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() if err := s.writeRestartScope(ctx, acct.Sqid, app, scope); err != nil { // Logged, not returned: a store error carries bucket names, endpoints // and request ids. The caller gets the fixed sentence every other // bucket failure here gets, and a retry is safe — one more request is // one more cycle of the same routes. log.Printf("console: api restart scope %s/%s (%s %s): %v", acct.Sqid, app, scope.Kind, scope.Value, err) jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again") return } targetType := "instance" if scope.Kind == release.RestartScopeEnvironment { targetType = "environment" } s.auditActivity(acct, app, addr, Activity{ Action: "restart", TargetType: targetType, Target: scope.Value, Message: message, Detail: restartAuditWords(scope), }) // The request is in the bucket; this is what makes boxes LOOK at it — they // read restart requests only when the serial moves. s.bumpSerial(ctx) answer["requested"] = true writeJSON(w, http.StatusOK, answer) } // restartAuditWords is the audit row's sentence. It names the SELECTOR, not // the resolved hosts: the row is a record of what somebody asked for, and the // set it resolved to is a property of one box's registry at one moment, which // would read as a claim about the fleet the moment a route moved. // // The app-wide branch is kept although the handler no longer lets an app-wide // WRITE reach the audit at all: if a later edit ever restores one, the row it // writes must read as the app-wide door's own row rather than as // "requested a restart of app (cli)". func restartAuditWords(scope release.RestartScope) string { if scope.Kind == release.RestartScopeApp { return "requested a restart (cli)" // the app-wide door's own wording } return fmt.Sprintf("requested a restart of %s %s (cli)", scope.Kind, scope.Value) } // writeRestartScope puts the request where the boxes will find it: the log // only new boxes read, which is what keeps an old box from widening a request // it cannot understand into a whole-app restart. // // An app-wide scope is REFUSED here rather than forwarded to the old object, // which is belt to the handler's braces. The handler has already turned that // body away, and this is the check that keeps the refusal true if a later edit // moves or loses it: an app-wide restart is the other door's write, and a // second way to reach it through the narrowing door is exactly the accident // this file was built to prevent. The caller answers a store error with "could // not request the restart — nothing was cycled", which is the honest outcome. func (s *Server) writeRestartScope(ctx context.Context, acct, app string, scope release.RestartScope) error { if scope.Kind != release.RestartScopeHost && scope.Kind != release.RestartScopeEnvironment { return fmt.Errorf("restart scope: %q is not a narrowing — an app-wide restart belongs to the app-wide door", scope.Kind) } _, err := release.AppendRestartScope(ctx, s.cfg.Releases, acct, app, scope) return err } // resolveRestartScope turns the two flags into ONE selector and the routes it // addresses, or into the sentence that refuses them. // // PRECEDENCE, and the reason it is this and not "narrowest silently wins": // // - --host alone selects that one route. // - --environment alone selects every route bound to that bundle — what // "restart what reads this" means, and the shape that pairs with // `carlos secrets set --environment`. // - both: --host wins, but only after the named host is CHECKED against the // named environment. Quietly ignoring the wider flag would hide the case // where one of the two is a typo, and this verb's whole job is telling an // operator what they are about to cycle. // - neither: the app, exactly as before. // // A selector that names nothing is refused with what the app actually has — // never widened. An --environment typo that fell through to an app-wide // restart is the nineteen-route accident this door was built to prevent. func resolveRestartScope(routes []registry.Route, host, envName string) (release.RestartScope, []registry.Route, string) { switch { case host != "": rt, ok := routeWithHost(routes, host) if !ok { return release.RestartScope{}, nil, fmt.Sprintf( "no route %s on this app — this console's box serves %s", host, namesOrNone(hostNames(routes), "no routes for it")) } if envName != "" && rt.Environment != envName { if rt.Environment == "" { return release.RestartScope{}, nil, fmt.Sprintf( "%s is bound to no environment (it reads the app's default bundle), not %s — "+ "one of --host and --environment is a typo, so nothing was cycled", host, envName) } return release.RestartScope{}, nil, fmt.Sprintf( "%s reads environment %s, not %s — one of --host and --environment is a typo, so nothing was cycled", host, rt.Environment, envName) } return release.RestartScope{Kind: release.RestartScopeHost, Value: host}, []registry.Route{rt}, "" case envName != "": var hit []registry.Route for _, rt := range routes { if rt.Environment == envName { hit = append(hit, rt) } } if len(hit) == 0 { return release.RestartScope{}, nil, fmt.Sprintf( "no route of this app reads environment %s — this console's box has %s", envName, namesOrNone(environmentNames(routes), "no routes for it at all")) } return release.RestartScope{Kind: release.RestartScopeEnvironment, Value: envName}, hit, "" } return release.RestartScope{Kind: release.RestartScopeApp, Value: ""}, routes, "" } func routeWithHost(routes []registry.Route, host string) (registry.Route, bool) { for _, rt := range routes { if rt.Host == host { return rt, true } } return registry.Route{}, false } // hostNames is every host this app has here, sorted — the "what exists" half // of a --host refusal. func hostNames(routes []registry.Route) []string { out := make([]string, 0, len(routes)) for _, rt := range routes { out = append(out, rt.Host) } sort.Strings(out) return out } // environmentNames is the distinct environments this app's routes are bound // to, sorted, with the unbound ones named as what they are. A refusal that // listed only the named bundles would leave someone whose route reads the // default one unable to tell why their spelling "failed" — the answer being // that there is nothing to spell. func environmentNames(routes []registry.Route) []string { seen := map[string]bool{} var out []string for _, rt := range routes { name := rt.Environment if name == "" { // Named as what it is rather than printed as an empty string: a // route with no binding reads the app's default bundle, which is // an answer, not missing data. It is also not a value --environment // will accept, and the parentheses are what say so. name = "(routes on the default bundle, which --environment cannot name)" } if seen[name] { continue } seen[name] = true out = append(out, name) } sort.Strings(out) return out } // namesOrNone renders a bounded list for a refusal. The cap keeps a // nineteen-route app readable and a two-hundred-route one from burying the // point; the remainder is counted rather than dropped silently. func namesOrNone(names []string, empty string) string { if len(names) == 0 { return empty } if len(names) <= restartScopeNamesShown { return strings.Join(names, ", ") } return fmt.Sprintf("%s and %d more", strings.Join(names[:restartScopeNamesShown], ", "), len(names)-restartScopeNamesShown) } // restartRouteRows renders the addressed set, sorted by host so a caller's // output — and any test pinning it — is stable across passes, where registry // order is not. func restartRouteRows(routes []registry.Route) []apiRestartRoute { out := make([]apiRestartRoute, 0, len(routes)) for _, rt := range routes { out = append(out, apiRestartRoute{ Host: rt.Host, Environment: rt.Environment, Unit: rt.Unit, Hibernate: rt.Hibernate, }) } sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) return out }