Merge branch 'restart-selectors' into main
17 files changed,
+3018
−56
AGENTS.md+17 −0apimode.go+62 −0docs/site/cli.md+50 −1internal/console/apicli.go+9 −6internal/console/apirestartscope.go+390 −0internal/console/apirestartscope_test.go+422 −0internal/console/authcli.go+41 −1internal/host/agent.go+9 −7internal/host/restart.go+167 −21internal/host/restart_test.go+34 −13internal/host/restartscope.go+289 −0internal/host/restartscope_test.go+577 −0internal/release/restartscope.go+314 −0internal/release/restartscope_test.go+301 −0main.go+2 −1restart.go+152 −6restart_test.go+182 −0
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index 20a55f2b3..f50e5801a 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -278,3 +278,20 @@ anything. |
| PR #10). Hibernating instances are never woken. Static routes carry |
| `X-Carlos-Version` from the same roll (#112), so `carlos deploy -kind |
| static -host <site>` verifies static sites too. |
| +- **`carlos restart --host` / `--environment` / `--dry-run`** (discussion |
| + #16): the same verb narrowed to one route, or to everything reading one |
| + config bundle — the shape that follows `carlos secrets set --environment |
| + relay`, since a bundle is delivered to an ENVIRONMENT. `--host` wins over |
| + `--environment`, and the console refuses the pair when that host does not |
| + read that environment; a selector matching nothing is refused with what |
| + the app actually has, never widened. The narrowed request is a SECOND |
| + bucket object (`apps/<acct>/<app>/restart-scope.json`, an append log of |
| + `{id, requested_at, scope}`) reached through a SECOND console path |
| + (`POST .../restart/scope`), because both old readers act on the app-wide |
| + object alone: an old box never reads the log and an old console answers |
| + 404, so neither can turn a narrowed request into a whole-app restart. A |
| + box steps OVER a selector `kind` it does not know rather than falling |
| + back to the app — never widen on ignorance. Boxes remember the last |
| + request id they acted on (`scopes` in `restart-marks.json`, additive in |
| + both directions); a mark that has rolled off the log re-baselines and |
| + cycles nothing. |
diff --git a/apimode.go b/apimode.go| index 1c7aebe66..7cbb84f8c 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1044,6 +1044,68 @@ func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRes |
| return res, nil |
| } |
| +// apiRestartScopeRoute is one route a narrowed restart addressed — the |
| +// console's answer to "what exactly is about to stop". Hibernate is not |
| +// decoration: a hibernating route is named AND left asleep, and an operator |
| +// who sees it listed with no explanation would read that as a failure. |
| +type apiRestartScopeRoute struct { |
| + Host string `json:"host"` |
| + Environment string `json:"environment"` |
| + Unit string `json:"unit"` |
| + Hibernate bool `json:"hibernate"` |
| +} |
| + |
| +// apiRestartScopeResult is what the narrowed door answers with. Requested is |
| +// FALSE for a dry run and true otherwise, so the CLI never has to infer from |
| +// its own flags what the console did — the console says. |
| +type apiRestartScopeResult struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Scope struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value"` |
| + } `json:"scope"` |
| + DryRun bool `json:"dry_run"` |
| + Requested bool `json:"requested"` |
| + Routes []apiRestartScopeRoute `json:"routes"` |
| +} |
| + |
| +// restartScoped asks the console to cycle ONE route, or everything reading one |
| +// config environment — or, with dryRun, to say what that would be and touch |
| +// nothing (discussion #16). |
| +// |
| +// A separate path from restart() above, not a body on it, and the 404 message |
| +// is where that shows. The old door reads no body at all: a narrowed request |
| +// sent there would be answered by restarting every instance the app owns, |
| +// which is the accident this exists to prevent. So the CLI asks a path an old |
| +// console does not serve, gets net/http's byte-identical "404 page not found", |
| +// and says all three things it could mean — shortest fix first — the way |
| +// restart() widens its own. |
| +func (s *apiSession) restartScoped(ctx context.Context, a apiAcct, app, host, environment string, dryRun bool) (apiRestartScopeResult, error) { |
| + in := struct { |
| + Host string `json:"host"` |
| + Environment string `json:"environment"` |
| + DryRun bool `json:"dry_run"` |
| + }{host, environment, dryRun} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart/scope"), in) |
| + if err != nil { |
| + return apiRestartScopeResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiRestartScopeResult{}, fmt.Errorf("%w — or this console predates narrowed restarts "+ |
| + "(--host, --environment, --dry-run), in which case redeploy the console first; "+ |
| + "`carlos restart --app` still works on it, but cycles every instance the app has", notFoundErr()) |
| + } |
| + return apiRestartScopeResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiRestartScopeResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiRestartScopeResult{}, fmt.Errorf("restart: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // apiScheduleList mirrors handleAPIScheduleList's wire shape |
| // (internal/console/apischedule.go's scheduleAnswer) byte for byte — this |
| // binary never imports internal/console, so the shape is redeclared here |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 2fcf1f22e..3b017d0d6 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -234,7 +234,7 @@ percentage of production traffic on the app's own hostname. |
| ### carlos restart |
| -Cycles an app's processes with no new version and no config change. This is |
| +Cycles running processes with no new version and no config change. This is |
| the answer to a wedged instance. |
| ```sh |
| @@ -248,6 +248,55 @@ stop within seconds and respawn on their next request, which for an idle app |
| may be a while. Nothing is stuck: the instance comes back with the next |
| request that needs it. Hibernating instances are left asleep. |
| +#### Restarting less than the whole app |
| + |
| +An app is not always a small thing. One app can own a relay, a router, a |
| +tunnel and every customer instance — so "restart the app" can mean cycling all |
| +of production to pick up one credential that reached one route. Two flags |
| +narrow it: |
| + |
| +```sh |
| +carlos restart --app titogo --host bill.example.com |
| +carlos restart --app titogo --environment relay |
| +``` |
| + |
| +`--host` cycles exactly that route. `--environment` cycles every route reading |
| +that named config bundle, which is the one to reach for after a |
| +[carlos secrets set](/docs/cli#carlos-secrets) or |
| +[carlos env set](/docs/cli#carlos-env) on the same `--environment`: a bundle is |
| +delivered to an environment, so "restart what reads this" is the request that |
| +follows it. |
| + |
| +Both narrowed forms print the routes they addressed — host, environment, the |
| +unit that owns the process where there is one, and which of them were asleep |
| +and so left alone. `--app` on its own still prints the single line it always |
| +has; `--dry-run` is how you see its set. |
| + |
| +```sh |
| +carlos restart --app titogo --environment relay --dry-run |
| +``` |
| + |
| +A dry run resolves the selector, names every route it would cycle, and stops |
| +there. Nothing is written and nothing restarts. |
| + |
| +Precedence, when you give both: `--host` wins, but only after the console |
| +checks that the host really does read that environment. If it does not, the |
| +command refuses rather than picking one — one of the two flags is a typo, and |
| +this is not a verb to guess on. A selector that matches nothing is refused the |
| +same way, and the refusal lists the hosts or environments the app actually |
| +has. Nothing is ever widened back to the whole app. |
| + |
| +Two limits worth knowing. The console resolves the selector against its own |
| +box's registry, and it refuses a selector nothing there matches — so on a |
| +multi-box fleet you can only ask for a `--host` or an `--environment` that at |
| +least one route on the console's own box reads, and the printed set is what |
| +that box can see. A request it does accept is a bucket object every box |
| +converges on, and each box applies the same selector to its own routes; those |
| +other routes are cycled without appearing in the list. And a console or a box |
| +that predates these flags does not understand a narrowed request: the console |
| +answers that it has no such door, and a box that has not been rolled yet simply |
| +does nothing. Neither one turns it into a restart of everything. |
| + |
| ### carlos schedule |
| Gives an app a timetable. A schedule is a time and a path: at each fire the |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 3fad61330..e6e2613b5 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -1071,12 +1071,15 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| // path — there is no restart "options" — and a bearer request carries no |
| // cookie for a cross-site form to forge. |
| // |
| -// No per-host scoping. The generation is per APP, so every box serving it |
| -// cycles its own routes for that app; a -host flag would need a per-host |
| -// object and a way to say what happens to the hosts it doesn't name, and |
| -// "restart my app" is the request people actually have. Boxes skip their |
| -// hibernating routes on their own (internal/host/restart.go) — nothing here |
| -// wakes anything. |
| +// No per-host scoping HERE, and that is now a division of labour rather than |
| +// a limit. This door's generation is per APP: every box serving it cycles its |
| +// own routes for that app, which is what makes it the one form every box in |
| +// the fleet understands, including the ones that predate narrowing. Narrowed |
| +// requests (`--host`, `--environment`) live on their own path and their own |
| +// object — apirestartscope.go, discussion #16 — precisely so that a box or a |
| +// console that cannot understand a narrowing does NOTHING rather than falling |
| +// back to this. Boxes skip their hibernating routes on their own |
| +// (internal/host/restart.go) — nothing here wakes anything. |
| func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| addr, acct, app, ok := s.apiAccountApp(w, r) |
| if !ok { |
diff --git a/internal/console/apirestartscope.go b/internal/console/apirestartscope.go| new file mode 100644 |
| index 000000000..6ce6f3fb2 |
| --- /dev/null |
| +++ b/internal/console/apirestartscope.go |
| @@ -0,0 +1,390 @@ |
| +// 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"` |
| + } |
| + // 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 |
| + } |
| + 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 |
| + } |
| + s.audit(acct, app, addr, "restart", 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 |
| +} |
diff --git a/internal/console/apirestartscope_test.go b/internal/console/apirestartscope_test.go| new file mode 100644 |
| index 000000000..d6f31486e |
| --- /dev/null |
| +++ b/internal/console/apirestartscope_test.go |
| @@ -0,0 +1,422 @@ |
| +package console |
| + |
| +// The narrowed restart door (apirestartscope.go, discussion #16). What is |
| +// pinned here, in the order it would hurt if lost: a selector that names |
| +// nothing is REFUSED rather than widened into an app-wide restart; the request |
| +// that does land goes to the log old boxes never read, leaving the app-wide |
| +// object untouched; a dry run writes and announces nothing; and the answer |
| +// names the routes it addressed, which is the whole complaint the flags exist |
| +// to answer. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func scopeURL(fx *consoleFixture, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/bab/" + app + "/restart/scope" |
| +} |
| + |
| +// scopeFixture is a console whose registry holds a small titogo: two routes on |
| +// the relay bundle, one on router, one bound to nothing, one asleep, plus a |
| +// neighbour app and another account's row to prove the tenant boundary. |
| +func scopeFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, token := restartFixture(t, code) |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range []registry.Route{ |
| + {Host: "connect.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9001", Environment: "relay"}, |
| + {Host: "relay2.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9002", Environment: "relay", Unit: "relay2.service"}, |
| + {Host: "platform.ie.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9003", Environment: "router"}, |
| + {Host: "bill.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9004"}, |
| + {Host: "sleepy.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9005", Environment: "relay", Hibernate: true}, |
| + {Host: "neighbour.example.com", Kind: "instance", Status: "ready", App: "kass", |
| + Account: "bab", Addr: "127.0.0.1:9006", Environment: "relay"}, |
| + {Host: "foreign.example.com", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "zzzz", Addr: "127.0.0.1:9007", Environment: "relay"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return fx, token |
| +} |
| + |
| +// scopeLog reads the narrowed request log every NEW box watches. |
| +func scopeLog(t *testing.T, fx *consoleFixture, app string) *release.ScopedRestartGeneration { |
| + t.Helper() |
| + g, found, err := release.LoadRestartScopes(context.Background(), fx.rel, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatalf("read narrowed request log: %v", err) |
| + } |
| + if !found { |
| + return &release.ScopedRestartGeneration{} |
| + } |
| + return g |
| +} |
| + |
| +type scopeAnswer struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Scope struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value"` |
| + } `json:"scope"` |
| + DryRun bool `json:"dry_run"` |
| + Requested bool `json:"requested"` |
| + Routes []struct { |
| + Host string `json:"host"` |
| + Environment string `json:"environment"` |
| + Unit string `json:"unit"` |
| + Hibernate bool `json:"hibernate"` |
| + } `json:"routes"` |
| +} |
| + |
| +func postScope(t *testing.T, fx *consoleFixture, token, body string) (int, scopeAnswer, []byte) { |
| + t.Helper() |
| + status, out := postBody(t, scopeURL(fx, "titogo"), token, body) |
| + var res scopeAnswer |
| + if status == http.StatusOK { |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatalf("decode %s: %v", out, err) |
| + } |
| + } |
| + return status, res, out |
| +} |
| + |
| +func scopeHosts(res scopeAnswer) []string { |
| + out := make([]string, 0, len(res.Routes)) |
| + for _, r := range res.Routes { |
| + out = append(out, r.Host) |
| + } |
| + return out |
| +} |
| + |
| +// TestAPIRestartScopeEnvironmentAddressesOnlyThatBundle is the fix: a request |
| +// for one environment names the routes reading it — this app's, this account's |
| +// — and nothing else. The neighbouring app on the same bundle name and the |
| +// other account's identically-bound route are both absent, and that is the |
| +// tenant boundary, not a coincidence of the fixture. |
| +func TestAPIRestartScopeEnvironmentAddressesOnlyThatBundle(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-env") |
| + status, res, out := postScope(t, fx, token, `{"environment":"relay"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("restart scope = %d %s, want 200", status, out) |
| + } |
| + if !res.Requested || res.Scope.Kind != "environment" || res.Scope.Value != "relay" { |
| + t.Fatalf("answer = %+v, want a requested environment scope", res) |
| + } |
| + want := []string{"connect.go.tito.io", "relay2.go.tito.io", "sleepy.go.tito.io"} |
| + if got := scopeHosts(res); !slices.Equal(got, want) { |
| + t.Fatalf("addressed %v, want %v", got, want) |
| + } |
| + // The sleeping route is NAMED and flagged rather than filtered out: an |
| + // operator has to be able to see that it is in the set and will not cycle |
| + // now, which is correct rather than a failure. |
| + var sleepySeen bool |
| + for _, r := range res.Routes { |
| + if r.Host == "sleepy.go.tito.io" { |
| + sleepySeen = r.Hibernate |
| + } |
| + if r.Host == "relay2.go.tito.io" && r.Unit != "relay2.service" { |
| + t.Fatalf("route %+v lost the unit that owns its process", r) |
| + } |
| + } |
| + if !sleepySeen { |
| + t.Fatal("the hibernating route came back unflagged — an operator would read it as one that just cycled") |
| + } |
| + g := scopeLog(t, fx, "titogo") |
| + if len(g.Requests) != 1 || g.Requests[0].Scope.Value != "relay" { |
| + t.Fatalf("request log = %+v, want the one narrowed request", g.Requests) |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial after a narrowed request = %d, want 1 (otherwise boxes wait out the hourly floor)", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeLeavesTheAppWideObjectAlone is the additive property at |
| +// the door: a narrowed request must not move the object every OLD box watches, |
| +// or the fleet's un-upgraded boxes would each cycle the whole app. |
| +func TestAPIRestartScopeLeavesTheAppWideObjectAlone(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-additive") |
| + if status, _, out := postScope(t, fx, token, `{"host":"bill.go.tito.io"}`); status != http.StatusOK { |
| + t.Fatalf("restart scope = %d %s, want 200", status, out) |
| + } |
| + if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| + t.Fatalf("the app-wide restart generation exists (%q) after a narrowed request — every old box would cycle everything", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeRefusesAnUnknownEnvironment: the refusal that keeps a |
| +// typo from becoming the bug being fixed. It names what the app actually has, |
| +// because a refusal exists to let somebody spot their own mistake. |
| +func TestAPIRestartScopeRefusesAnUnknownEnvironment(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-typo") |
| + status, _, out := postScope(t, fx, token, `{"environment":"relayy"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("a typo'd environment = %d %s, want 400", status, out) |
| + } |
| + body := string(out) |
| + for _, want := range []string{"relayy", "relay", "router"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("refusal %s does not name %q — it must say what it looked for and what exists", body, want) |
| + } |
| + } |
| + if len(scopeLog(t, fx, "titogo").Requests) != 0 || restartGenEtag(t, fx, "titogo") != "" { |
| + t.Fatal("a refused selector still wrote a request") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d after a refusal, want it untouched", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeRefusesAnUnknownHost: the same rule for the other flag. |
| +func TestAPIRestartScopeRefusesAnUnknownHost(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-host-typo") |
| + status, _, out := postScope(t, fx, token, `{"host":"bil.go.tito.io"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("an unknown host = %d %s, want 400", status, out) |
| + } |
| + if !strings.Contains(string(out), "bill.go.tito.io") { |
| + t.Fatalf("refusal %s does not list the hosts this app has", out) |
| + } |
| + // Another account's route is not a host this caller may name, and the |
| + // refusal must not leak that it exists. |
| + if strings.Contains(string(out), "foreign.example.com") { |
| + t.Fatalf("refusal %s named another account's route", out) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeRefusesAMismatchedPair is the precedence rule's teeth. |
| +// --host wins over --environment, but only after the host is checked against |
| +// it: quietly ignoring the wider flag would hide that one of the two is a |
| +// typo, and this is not a verb to guess on. |
| +func TestAPIRestartScopeRefusesAMismatchedPair(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-mismatch") |
| + status, _, out := postScope(t, fx, token, `{"host":"platform.ie.go.tito.io","environment":"relay"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("a mismatched pair = %d %s, want 400", status, out) |
| + } |
| + if !strings.Contains(string(out), "router") || !strings.Contains(string(out), "relay") { |
| + t.Fatalf("refusal %s must name what the host actually reads and what was asked for", out) |
| + } |
| + if len(scopeLog(t, fx, "titogo").Requests) != 0 { |
| + t.Fatal("a mismatched pair still wrote a request") |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeHostWinsOverAMatchingEnvironment: the pair is accepted |
| +// when it agrees, and the narrower flag is what lands. |
| +func TestAPIRestartScopeHostWinsOverAMatchingEnvironment(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-pair") |
| + status, res, out := postScope(t, fx, token, `{"host":"connect.go.tito.io","environment":"relay"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("a matching pair = %d %s, want 200", status, out) |
| + } |
| + if res.Scope.Kind != "host" || res.Scope.Value != "connect.go.tito.io" { |
| + t.Fatalf("scope = %+v, want the host to win", res.Scope) |
| + } |
| + if got := scopeHosts(res); !slices.Equal(got, []string{"connect.go.tito.io"}) { |
| + t.Fatalf("addressed %v, want the one route", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeDryRunTouchesNothing: the answer an operator wanted |
| +// before cycling anything — the set, and no restart. |
| +func TestAPIRestartScopeDryRunTouchesNothing(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-dry") |
| + status, res, out := postScope(t, fx, token, `{"environment":"relay","dry_run":true}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("dry run = %d %s, want 200", status, out) |
| + } |
| + if res.Requested || !res.DryRun { |
| + t.Fatalf("answer = %+v, want dry_run true and requested false", res) |
| + } |
| + if got := scopeHosts(res); len(got) != 3 { |
| + t.Fatalf("dry run addressed %v, want the same three routes the real request would", got) |
| + } |
| + if len(scopeLog(t, fx, "titogo").Requests) != 0 || restartGenEtag(t, fx, "titogo") != "" { |
| + t.Fatal("a dry run wrote a request") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d after a dry run, want it untouched — a dry run must not even wake the fleet", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeDryRunOfTheWholeApp: with no selector, the dry run is how |
| +// somebody sees what a plain `carlos restart --app` would cycle. That is the |
| +// blast radius the reporter said they would not run the verb to discover. |
| +func TestAPIRestartScopeDryRunOfTheWholeApp(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-dry-app") |
| + status, res, out := postScope(t, fx, token, `{"dry_run":true}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("app-wide dry run = %d %s, want 200", status, out) |
| + } |
| + if res.Scope.Kind != "app" { |
| + t.Fatalf("scope = %+v, want the whole app", res.Scope) |
| + } |
| + want := []string{"bill.go.tito.io", "connect.go.tito.io", "platform.ie.go.tito.io", "relay2.go.tito.io", "sleepy.go.tito.io"} |
| + if got := scopeHosts(res); !slices.Equal(got, want) { |
| + t.Fatalf("addressed %v, want every route of the app: %v", got, want) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeRefusesAWriteWithNoSelector is the door's own |
| +// never-widen rule, turned on itself. |
| +// |
| +// This path exists BECAUSE the app-wide door widens a request it cannot read. |
| +// A body with no selector in it — a bare `{}`, or a client that renamed a |
| +// field — resolves to the whole app, and performing that here would be the |
| +// nineteen-route accident committed through the door built to prevent it. So |
| +// the app-wide case is answered only as a --dry-run (which is how somebody |
| +// sees the blast radius before typing the real verb), and a WRITE with no |
| +// narrowing is refused, naming the door that does own it. |
| +func TestAPIRestartScopeRefusesAWriteWithNoSelector(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-appwide") |
| + status, _, out := postScope(t, fx, token, `{}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("an app-wide write through the scope door = %d %s, want 400", status, out) |
| + } |
| + if !strings.Contains(string(out), "carlos restart --app") { |
| + t.Fatalf("refusal %s does not name the door that cycles the whole app", out) |
| + } |
| + if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| + t.Fatalf("a refused app-wide write still moved the object every box watches (%q) — that is the whole app cycled", got) |
| + } |
| + if len(scopeLog(t, fx, "titogo").Requests) != 0 { |
| + t.Fatal("a refused app-wide write still appended to the narrowed log") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d after a refusal, want it untouched", got) |
| + } |
| + // The dry run of the same body is still answered: that is the one form of |
| + // app-wide this door is for. |
| + if status, res, out := postScope(t, fx, token, `{"dry_run":true}`); status != http.StatusOK || res.Requested { |
| + t.Fatalf("app-wide dry run = %d %s (%+v), want 200 and nothing requested", status, out, res) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeRefusesAFieldItDoesNotKnow is the same rule one layer |
| +// out. A misspelled or renamed selector key that was merely IGNORED would |
| +// leave a body with no selector in it, which is a whole-app restart requested |
| +// by a client that asked for one route. The refusal names the field, because |
| +// the fix is a spelling. |
| +func TestAPIRestartScopeRefusesAFieldItDoesNotKnow(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-unknown-field") |
| + status, _, out := postScope(t, fx, token, `{"hosts":"relay2.go.tito.io"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf(`{"hosts":...} = %d %s, want 400 — an ignored key here is an app-wide restart`, status, out) |
| + } |
| + if !strings.Contains(string(out), "hosts") { |
| + t.Fatalf("refusal %s does not name the field, so nobody can find the typo", out) |
| + } |
| + if restartGenEtag(t, fx, "titogo") != "" || len(scopeLog(t, fx, "titogo").Requests) != 0 { |
| + t.Fatal("a body this door refused still wrote a request") |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeGateIsTheFamilys404: an app the account never claimed, a |
| +// stranger's token and a credential bound to another app are ONE answer, byte |
| +// for byte the answer the app-wide door gives — this repo's rule for these |
| +// gates is byte-identical, not merely same-status, because a differently |
| +// shaped 404 is an oracle that says "this door exists for you, that app does |
| +// not". |
| +func TestAPIRestartScopeGateIsTheFamilys404(t *testing.T) { |
| + fx, _ := scopeFixture(t, "code-scope-gate") |
| + // The old door's refusal, read from the old door rather than spelled out |
| + // here, so the two cannot drift apart without this failing. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-scope-gate-eve") |
| + oldStatus, oldBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", eve, "") |
| + if oldStatus != http.StatusNotFound { |
| + t.Fatalf("the app-wide door answered a stranger %d, want 404", oldStatus) |
| + } |
| + for _, c := range []struct { |
| + name string |
| + url string |
| + token string |
| + }{ |
| + {"a stranger", scopeURL(fx, "titogo"), eve}, |
| + // An app-scoped service credential reaching PAST its own app. The |
| + // binding is checked before the app is even looked up, so "not your |
| + // app" and "no such app" stay the same answer. |
| + {"a credential bound to another app", scopeURL(fx, "titogo"), |
| + serviceToken(t, fx.st, OpsAccountID, "kassbot", "kass", svcrole.Operate)}, |
| + {"an app this account never claimed", scopeURL(fx, "nosuchapp"), |
| + cliToken(t, fx, "alice@keymail.dev", "code-scope-gate-alice")}, |
| + } { |
| + status, body := postBody(t, c.url, c.token, `{"environment":"relay"}`) |
| + if status != oldStatus || string(body) != string(oldBody) { |
| + t.Fatalf("%s got %d %q, want the app-wide door's own %d %q", |
| + c.name, status, body, oldStatus, oldBody) |
| + } |
| + } |
| + if len(scopeLog(t, fx, "titogo").Requests) != 0 || restartGenEtag(t, fx, "titogo") != "" { |
| + t.Fatal("a refused caller still wrote a request") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d after refusals, want it untouched", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeServiceCredentialReachesItsOwnApp is the other half of |
| +// the case above: the 404 there is about the app binding, not about services |
| +// being turned away from this door. An Operate credential bound to titogo |
| +// narrows a restart of titogo exactly as a member does. |
| +func TestAPIRestartScopeServiceCredentialReachesItsOwnApp(t *testing.T) { |
| + fx, _ := scopeFixture(t, "code-scope-svc-own") |
| + tok := serviceToken(t, fx.st, OpsAccountID, "titogobot", "titogo", svcrole.Operate) |
| + status, res, out := postScope(t, fx, tok, `{"host":"bill.go.tito.io"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("a credential bound to titogo = %d %s, want 200", status, out) |
| + } |
| + if !res.Requested || res.Scope.Kind != "host" || res.Scope.Value != "bill.go.tito.io" { |
| + t.Fatalf("answer = %+v, want the narrowed request", res) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeNeedsARegistry: a console that cannot see routes cannot |
| +// resolve a selector or report a set, and must not answer "requested" for |
| +// something it never checked. The app-wide door still works there, and the |
| +// refusal says so. |
| +func TestAPIRestartScopeNeedsARegistry(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-noreg") |
| + fx.srv.cfg.Registry = "" |
| + status, _, out := postScope(t, fx, token, `{"environment":"relay"}`) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("a registry-less console answered %d %s, want 503", status, out) |
| + } |
| + if !strings.Contains(string(out), "carlos restart --app") { |
| + t.Fatalf("refusal %s does not name the form that still works", out) |
| + } |
| +} |
| + |
| +// TestAPIRestartScopeRefusesAMalformedSelector keeps a hostile value out |
| +// before the registry is even opened. |
| +func TestAPIRestartScopeRefusesAMalformedSelector(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-malformed") |
| + for _, body := range []string{ |
| + `{"host":"../../etc/passwd"}`, |
| + `{"environment":"NOT VALID"}`, |
| + `{"environment":"default"}`, |
| + } { |
| + if status, _, out := postScope(t, fx, token, body); status != http.StatusBadRequest { |
| + t.Fatalf("%s = %d %s, want 400", body, status, out) |
| + } |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 9875f2b64..c44a82887 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -489,6 +489,13 @@ func (s *Server) registerCLIRoutes() { |
| // promote and rollback, and the only one whose subject is a running |
| // process rather than a pointer. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/restart", svcrole.Allow(svcrole.Operate), s.handleAPIRestart) |
| + // The NARROWED form (discussion #16): one route, or everything reading one |
| + // config environment, plus the dry run that shows the set first. A |
| + // SEPARATE path rather than a body on the door above, and deliberately so: |
| + // that one reads no body, so a console predating this would answer a |
| + // narrowed request by cycling the whole app. Here it answers 404 and the |
| + // CLI says why. Same role — the subject is still a running process. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/restart/scope", svcrole.Allow(svcrole.Operate), s.handleAPIRestartScope) |
| // `carlos schedule` (apischedule.go, scheduled-work spec §8): declare |
| // recurring work, take it back, and make it fire now. The RESTART class, |
| // not the publish one — operating a running app rather than moving a |
| @@ -822,18 +829,51 @@ func readJSON(w http.ResponseWriter, r *http.Request, out any) bool { |
| // its own 413 rather than hiding inside "malformed JSON body" — the caller's |
| // fix (split the request, shorten the value) is nothing like a JSON fix. |
| func readJSONLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool { |
| + return decodeJSONBody(w, r, out, limit, false) |
| +} |
| + |
| +// readJSONStrictLimit is readJSONLimit that also refuses a field the struct |
| +// does not declare, with a 400 that NAMES it. |
| +// |
| +// It is for the doors where an unrecognised key would otherwise be dropped and |
| +// the request acted on as if it had not been sent — which, at a door whose job |
| +// is to NARROW something, silently widens it. The narrowed restart door |
| +// (apirestartscope.go) is the case it was written for: a client that renames |
| +// `host` to `hosts` would, without this, POST a body with no selector in it |
| +// and be answered by a restart of every route the app owns. A door that exists |
| +// because another door widens must not itself widen on a typo. |
| +// |
| +// The unknown-field check is a prefix match on encoding/json's own message |
| +// because the package gives no typed error for it; the message is stable and |
| +// already names the field, which is the whole thing the caller needs. |
| +func readJSONStrictLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool { |
| + return decodeJSONBody(w, r, out, limit, true) |
| +} |
| + |
| +// decodeJSONBody is the shared body of the two above: content type, byte cap, |
| +// decode, and one refusal per failure shape. |
| +func decodeJSONBody(w http.ResponseWriter, r *http.Request, out any, limit int64, strict bool) bool { |
| ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") |
| if strings.TrimSpace(strings.ToLower(ct)) != "application/json" { |
| jsonError(w, http.StatusUnsupportedMediaType, "send a JSON body with Content-Type: application/json") |
| return false |
| } |
| r.Body = http.MaxBytesReader(w, r.Body, limit) |
| - if err := json.NewDecoder(r.Body).Decode(out); err != nil { |
| + dec := json.NewDecoder(r.Body) |
| + if strict { |
| + dec.DisallowUnknownFields() |
| + } |
| + if err := dec.Decode(out); err != nil { |
| var tooLarge *http.MaxBytesError |
| if errors.As(err, &tooLarge) { |
| jsonError(w, http.StatusRequestEntityTooLarge, "request body too large") |
| return false |
| } |
| + if strict && strings.HasPrefix(err.Error(), "json: unknown field ") { |
| + jsonError(w, http.StatusBadRequest, err.Error()+ |
| + " — this door refuses a body it does not fully understand rather than acting on the part it does") |
| + return false |
| + } |
| jsonError(w, http.StatusBadRequest, "malformed JSON body") |
| return false |
| } |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index d2da8db09..130b6e1ce 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1436,12 +1436,14 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // Built once, used by both pass shapes below — see fastPassOpts for the |
| // DO-NOT list and why the stage requester is per-loop, not per-tick. |
| opts := fastPassOpts(dd, stageRequester(dd), restart, makeStartRoute(reg, systemctlStartUnit)) |
| - // The restart check's memory (issue #113), seeded ONCE here — one read per |
| - // app at arming and never again except on a serial move, which is what |
| - // keeps an idle box at exactly one serial GET per tick. See restartSeed for |
| - // why the baseline cannot wait for the first request to establish itself. |
| - restartCache := map[[2]string]string{} |
| - restartSeed(ctx, reg, store, dd, restartCache) |
| + // The restart check's memory (issue #113, narrowed requests added by |
| + // discussion #16), seeded ONCE here — two reads per app at arming (the |
| + // app-wide generation and the narrowed request log) and never again except |
| + // on a serial move, which is what keeps an idle box at exactly one serial |
| + // GET per tick. See restartSeed for why the baseline cannot wait for the |
| + // first request to establish itself. |
| + restartMem := newRestartMemory() |
| + restartSeed(ctx, reg, store, dd, restartMem) |
| restartBounce := bounceFn(svc) |
| // The schedule converge's own seed, on restartSeed's precedent and for the |
| // same shape of reason: the leg below runs only on a serial MOVE, so |
| @@ -1496,7 +1498,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| log.Printf("agent: restart check: list routes: %v", err) |
| return |
| } |
| - restartRequestLeg(ctx, routes, store, dd, restartBounce, restartCache) |
| + restartRequestLeg(ctx, routes, store, dd, restartBounce, restartMem) |
| } |
| // The schedule leg: converge this box's timer rows from the apps' |
| // declarations. Nil when the box has no scheduled work armed, which |
diff --git a/internal/host/restart.go b/internal/host/restart.go| index cfd75554e..757f7b740 100644 |
| --- a/internal/host/restart.go |
| +++ b/internal/host/restart.go |
| @@ -188,7 +188,7 @@ func bounceExecRoutes(routes []registry.Route, moved [][2]string, bounce func(ho |
| } |
| n := 0 |
| for _, rt := range routes { |
| - if !release.Adoptable(rt) || rt.Unit != "" || rt.Backing == registry.BackingUnit { |
| + if !release.Adoptable(rt) || !execBounceable(rt) { |
| continue |
| } |
| if !want[[2]string{release.RouteAccount(rt), rt.App}] { |
| @@ -266,6 +266,25 @@ func restartMarksPath(dd string) string { return filepath.Join(dd, restartMarksF |
| type restartMarks struct { |
| Schema int `json:"schema"` // 1 |
| Apps map[string]string `json:"apps"` |
| + // Scopes is the same memory for NARROWED requests (discussion #16), |
| + // keyed identically: the id of the last request in |
| + // release.RestartScopeKey's log this box has acted on, or |
| + // release.RestartScopeNone for "looked, nothing there". |
| + // |
| + // Additive in both directions, because this file is read and written by |
| + // two binaries that roll at different times. A binary that predates the |
| + // field ignores it on read (encoding/json drops unknown keys) and DROPS |
| + // it on write — which costs a downgraded box exactly one re-baseline, |
| + // after which it cycles nothing it was not asked to. |
| + // |
| + // The other direction is free of REPLAYS but not of drops, and the |
| + // difference matters: a marks file with no `scopes` key is a box that has |
| + // never looked at the log, which is a first sight, which is a seed — safe, |
| + // and exactly wrong for a narrowed request that lands before that seed is |
| + // written. Since EVERY box that ran the app-wide feature has such a file, |
| + // that window is closed from the agent's side rather than left to the |
| + // hourly floor; see seedRestartMarks' needRootPass. |
| + Scopes map[string]string `json:"scopes,omitempty"` |
| } |
| // restartAbsent is the marks value for an app this box has SEEN and found to |
| @@ -316,6 +335,13 @@ func lockRestartMarks(path string) (unlock func(), err error) { |
| }, nil |
| } |
| +// newRestartMarks is an empty memory: both maps non-nil, so every caller can |
| +// write to them without a nil check and a marks file that predates one of them |
| +// cannot leave a nil map behind. |
| +func newRestartMarks() *restartMarks { |
| + return &restartMarks{Schema: 1, Apps: map[string]string{}, Scopes: map[string]string{}} |
| +} |
| + |
| // loadRestartMarks reads the marks file. `known` is the seed-don't-replay |
| // switch and the only subtle thing here: it is FALSE when the file is missing, |
| // unreadable, or does not parse — three different accidents with one correct |
| @@ -330,7 +356,7 @@ func lockRestartMarks(path string) (unlock func(), err error) { |
| // other way is a box that cycles every app it serves the moment its state file |
| // goes missing. |
| func loadRestartMarks(path string) (marks *restartMarks, known bool) { |
| - m := &restartMarks{Schema: 1, Apps: map[string]string{}} |
| + m := newRestartMarks() |
| raw, err := os.ReadFile(path) |
| if err != nil { |
| if !errors.Is(err, os.ErrNotExist) { |
| @@ -340,11 +366,19 @@ func loadRestartMarks(path string) (marks *restartMarks, known bool) { |
| } |
| if err := json.Unmarshal(raw, m); err != nil { |
| log.Printf("adopt: restart marks %s do not parse (%v) — re-baselining, no restarts this pass", path, err) |
| - return &restartMarks{Schema: 1, Apps: map[string]string{}}, false |
| + return newRestartMarks(), false |
| } |
| if m.Apps == nil { |
| m.Apps = map[string]string{} |
| } |
| + // A file written before narrowed restarts existed carries no `scopes` key |
| + // at all; that is a first sight of every app's log, not an empty memory of |
| + // one, and the seed rules below turn it into exactly that. Which is why |
| + // the agent asks for a pass that performs that seed as it arms, instead of |
| + // leaving a request typed in the meantime to meet it — scopeBaselineIncomplete. |
| + if m.Scopes == nil { |
| + m.Scopes = map[string]string{} |
| + } |
| m.Schema = 1 |
| return m, true |
| } |
| @@ -413,36 +447,106 @@ func (m *restartMarks) save(path string) error { |
| // Absent objects ARE recorded, with restartAbsent: "looked, nothing there" is |
| // the fact that makes an app's first ever request movement rather than a first |
| // sight. |
| -func seedRestartMarks(path string, cache map[[2]string]string) error { |
| - if len(cache) == 0 { |
| - return nil // nothing was seen; nothing may be claimed |
| +// |
| +// needRootPass is the SECOND half of the handover, and it exists because |
| +// create-only cannot repair a baseline that is merely INCOMPLETE. Every box |
| +// that ran the app-wide feature already has a marks file, written before |
| +// narrowed restarts existed and so carrying no `scopes` key at all. This |
| +// function will not touch it — that is the whole create-only rule — so the |
| +// narrowed mark stays missing until a ROOT pass records it, and until then |
| +// restartScopeLeg reads every app as a first sight and cycles nothing for a |
| +// request the CLI has already answered "restart requested". Exec-shaped routes |
| +// survive that (the agent bounces its own children from mem.scope), but |
| +// unit-shaped ones — Tito's relay and router, the shape these flags exist for |
| +// — are dropped silently, for as long as it takes the next root pass to run: |
| +// worst case the hourly timer. |
| +// |
| +// So when the baseline on disk is missing a narrowed mark for an app this box |
| +// serves, say so, and let restartSeed ask for ONE root pass. That pass is the |
| +// same pass carlos-adopt.timer runs anyway, under the same lock, against the |
| +// same marks file and the same seed rules — this changes WHEN it runs, never |
| +// what it decides — so it can neither replay a request (it dispatches on the |
| +// per-app marks, which nothing here writes) nor drop one (it strictly shrinks |
| +// the window in which a narrowed request meets a pass with no narrowed |
| +// baseline, from an hour to the moment the agent arms, which is the same floor |
| +// seedRestartMarks already buys the app-wide half). |
| +func seedRestartMarks(path string, mem *restartMemory) (needRootPass bool, err error) { |
| + if mem == nil || len(mem.gen) == 0 { |
| + return false, nil // nothing was seen; nothing may be claimed |
| } |
| if _, err := os.Stat(path); err == nil { |
| - return nil // the root pass (or an earlier agent) already has a baseline |
| + // The root pass (or an earlier agent) already has a baseline — but it |
| + // may predate narrowing, and a half-baseline is what silently eats a |
| + // narrowed request. |
| + return scopeBaselineIncomplete(path, mem), nil |
| } else if !errors.Is(err, os.ErrNotExist) { |
| - return err |
| + return false, err |
| } |
| - m := &restartMarks{Schema: 1, Apps: map[string]string{}} |
| - for k, etag := range cache { |
| + m := newRestartMarks() |
| + for k, etag := range mem.gen { |
| m.Apps[restartMarkKey(k[0], k[1])] = etag |
| } |
| + // Both memories or neither: restartSeed only gets here when every app's |
| + // BOTH objects read cleanly, so a baseline that recorded one and left the |
| + // other for the root pass to establish would be a baseline with a known |
| + // hole in it — which is the one shape restartPass refuses to write. |
| + for k, id := range mem.scope { |
| + m.Scopes[restartMarkKey(k[0], k[1])] = id |
| + } |
| raw, err := json.MarshalIndent(m, "", " ") |
| if err != nil { |
| - return err |
| + return false, err |
| } |
| raw = append(raw, '\n') |
| f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) |
| if err != nil { |
| if errors.Is(err, os.ErrExist) { |
| - return nil // lost the race to the root pass; its mark is the better one |
| + // Lost the race to the root pass; its mark is the better one — but |
| + // a pass running an OLD binary writes no scopes key, so the same |
| + // question has to be asked of what actually landed. |
| + return scopeBaselineIncomplete(path, mem), nil |
| } |
| - return err |
| + return false, err |
| } |
| _, writeErr := f.Write(raw) |
| if err := f.Close(); err != nil && writeErr == nil { |
| writeErr = err |
| } |
| - return writeErr |
| + // The file this call just wrote carries both memories, so there is nothing |
| + // left for a root pass to record. |
| + return false, writeErr |
| +} |
| + |
| +// scopeBaselineIncomplete reports whether the marks file on disk leaves the |
| +// ROOT pass with no narrowed-restart position for an app this box serves. |
| +// |
| +// Two shapes count, and they are the same fault: |
| +// |
| +// - the file does not load (missing, unreadable, unparseable), so the next |
| +// pass re-baselines everything — which is right, and worth doing NOW |
| +// rather than at the hourly floor, since a re-baselining pass dispatches |
| +// nothing; |
| +// - the file loads but has no `scopes` entry for some app in this sweep, |
| +// which is exactly what a marks file written before narrowing existed |
| +// looks like. |
| +// |
| +// It reads and never writes. The agent cannot take restartPass's lock — that |
| +// lock file is created by root and doubles as the ROOT pass's writability |
| +// test — and a lock-free read-modify-write here would race a running pass and |
| +// revert the advance it had just made, which is the one accident the lock |
| +// exists to prevent. Reporting the gap and letting the pass that owns the file |
| +// fill it in is the whole fix. |
| +func scopeBaselineIncomplete(path string, mem *restartMemory) bool { |
| + marks, known := loadRestartMarks(path) |
| + if !known { |
| + return true |
| + } |
| + for k := range mem.scope { |
| + if _, seen := marks.Scopes[restartMarkKey(k[0], k[1])]; !seen { |
| + return true |
| + } |
| + } |
| + return false |
| } |
| // restartPass is the ROOT half: for every app on this box whose restart |
| @@ -514,6 +618,10 @@ func restartPass(ctx context.Context, routes []registry.Route, store release.Sto |
| marks, known := loadRestartMarks(marksPath) |
| changed := false |
| readFailed := false |
| + // What this pass has already cycled. A route restarted by an app-wide |
| + // request must not be restarted a second time by a narrowed one that |
| + // landed in the same window: one pass, one cycle per host. |
| + already := map[string]bool{} |
| for _, k := range apps { |
| _, etag, err := store.Get(ctx, release.RestartGenKey(k[0], k[1])) |
| switch { |
| @@ -554,6 +662,7 @@ func restartPass(ctx context.Context, routes []registry.Route, store release.Sto |
| log.Printf("adopt: restart requested for %s/%s: %s did not cycle: %v", k[0], k[1], rt.Host, err) |
| continue |
| } |
| + already[rt.Host] = true |
| dispatched++ |
| } |
| // One line per APP, not per route, and it says DISPATCHED rather than |
| @@ -566,6 +675,12 @@ func restartPass(ctx context.Context, routes []registry.Route, store release.Sto |
| marks.Apps[key] = etag |
| changed = true |
| } |
| + // The narrowed half (discussion #16), under the SAME lock, against the |
| + // same marks file and the same seed rules — a second pass with a second |
| + // lock would be two memories of one box's restarts, free to disagree. |
| + scopeChanged, scopeReadFailed := restartScopeLeg(ctx, routes, store, apps, marks, known, restart, already) |
| + changed = changed || scopeChanged |
| + readFailed = readFailed || scopeReadFailed |
| if !known && readFailed { |
| // A baseline that omits the apps a blip hid is worse than no baseline: |
| // this pass would write "known", and the next one would read those |
| @@ -599,22 +714,30 @@ func restartPass(ctx context.Context, routes []registry.Route, store release.Sto |
| // instant, while the marker write can block on a busy /data, and the children |
| // bounced here are the ones cycled here or nowhere. |
| func restartRequestLeg(ctx context.Context, routes []registry.Route, store release.Store, dd string, |
| - bounce func(host string) bool, cache map[[2]string]string) bool { |
| - moved, readErrs := restartGenPass(ctx, routes, store, cache) |
| + bounce func(host string) bool, mem *restartMemory) bool { |
| + moved, readErrs := restartGenPass(ctx, routes, store, mem.gen) |
| + // The narrowed requests are read on the same move and reported the same |
| + // way. Both, always: an app-wide request and a narrowed one can land in |
| + // one poll window, and a leg that stopped at the first would leave the |
| + // other for the hourly floor. |
| + scoped, scopeErrs := restartScopePass(ctx, routes, store, mem.scope) |
| + readErrs = append(readErrs, scopeErrs...) |
| if len(readErrs) > 0 { |
| // ONE line per pass, never one per app: the count carries the scale, |
| // the first error the cause — they are the same cause in practice. |
| log.Printf("agent: restart check: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| } |
| - if len(moved) == 0 { |
| + if len(moved) == 0 && len(scoped) == 0 { |
| return false |
| } |
| bounceExecRoutes(routes, moved, bounce) |
| + bounceScopedRoutes(routes, scoped, bounce) |
| if err := requestRootRestart(dd); err != nil { |
| log.Printf("agent: request root restart pass: %v", err) |
| return false |
| } |
| - log.Printf("agent: restart requested for %d app(s) on this box, asked for a root restart pass", len(moved)) |
| + log.Printf("agent: restart requested for %d app(s) and narrowed for %d on this box, asked for a root restart pass", |
| + len(moved), len(scoped)) |
| return true |
| } |
| @@ -633,18 +756,41 @@ func restartRequestLeg(ctx context.Context, routes []registry.Route, store relea |
| // sight — safe) and blocks the marks seed entirely, for the reason |
| // seedRestartMarks and restartPass both give: a baseline with holes in it is |
| // the thing that manufactures restarts. |
| -func restartSeed(ctx context.Context, reg *registry.Registry, store release.Store, dd string, cache map[[2]string]string) { |
| +// |
| +// The one thing it may ask for is a root pass, and only when the baseline on |
| +// disk is INCOMPLETE — see seedRestartMarks' needRootPass. That is the case of |
| +// every box already carrying a pre-narrowing marks file, and asking is what |
| +// keeps the first narrowed request after a roll from being answered "restart |
| +// requested" and then cycling nothing unit-shaped. It is one marker touch, |
| +// once, at arming: the pass it starts is the ordinary one, and once that pass |
| +// has recorded the narrowed marks no later agent start asks again. |
| +func restartSeed(ctx context.Context, reg *registry.Registry, store release.Store, dd string, mem *restartMemory) { |
| routes, err := reg.List() |
| if err != nil { |
| log.Printf("agent: restart baseline: list routes: %v (the first request may need a second try)", err) |
| return |
| } |
| - _, readErrs := restartGenPass(ctx, routes, store, cache) |
| + _, readErrs := restartGenPass(ctx, routes, store, mem.gen) |
| + _, scopeErrs := restartScopePass(ctx, routes, store, mem.scope) |
| + readErrs = append(readErrs, scopeErrs...) |
| if len(readErrs) > 0 { |
| log.Printf("agent: restart baseline: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| return |
| } |
| - if err := seedRestartMarks(restartMarksPath(dd), cache); err != nil { |
| + needRootPass, err := seedRestartMarks(restartMarksPath(dd), mem) |
| + if err != nil { |
| log.Printf("agent: seed restart marks: %v", err) |
| + return |
| + } |
| + if !needRootPass { |
| + return |
| + } |
| + if err := requestRootRestart(dd); err != nil { |
| + // Latency, not correctness: carlos-adopt.timer's hourly pass records |
| + // the same marks, it just leaves the window open for longer. |
| + log.Printf("agent: ask for a baselining root restart pass: %v", err) |
| + return |
| } |
| + log.Printf("agent: restart baseline: the root pass has no narrowed-restart mark for at least one app on this box " + |
| + "(a marks file written before narrowed restarts existed) — asked for one root pass to record it") |
| } |
diff --git a/internal/host/restart_test.go b/internal/host/restart_test.go| index bf59e7a98..bf302619a 100644 |
| --- a/internal/host/restart_test.go |
| +++ b/internal/host/restart_test.go |
| @@ -260,11 +260,11 @@ func TestRestartRequestLegBouncesAndRelays(t *testing.T) { |
| } |
| var bounced []string |
| bounce := func(h string) bool { bounced = append(bounced, h); return true } |
| - cache := map[[2]string]string{} |
| + mem := newRestartMemory() |
| // Baseline (what restartSeed does at arming): silent, no marker. |
| askRestart(t, ctx, s, "bab", "eleven") |
| - if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); asked { |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, mem); asked { |
| t.Fatal("the baselining leg asked for a root pass — first sight must be silent") |
| } |
| if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { |
| @@ -272,7 +272,7 @@ func TestRestartRequestLegBouncesAndRelays(t *testing.T) { |
| } |
| askRestart(t, ctx, s, "bab", "eleven") |
| - if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); !asked { |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, mem); !asked { |
| t.Fatal("a moved restart generation must ask for a root pass") |
| } |
| if !slices.Equal(bounced, []string{"exec.example.com"}) { |
| @@ -284,7 +284,7 @@ func TestRestartRequestLegBouncesAndRelays(t *testing.T) { |
| // And a leg with nothing moved neither bounces nor re-asks. |
| bounced = nil |
| - if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); asked { |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, mem); asked { |
| t.Fatal("an unmoved generation asked for a root pass") |
| } |
| if len(bounced) != 0 { |
| @@ -639,22 +639,40 @@ func TestRestartPassUnreadableMarksReBaseline(t *testing.T) { |
| // sentinel is what makes an app's first ever request movement. |
| func TestSeedRestartMarksIsCreateOnly(t *testing.T) { |
| path := filepath.Join(t.TempDir(), "restart-marks.json") |
| - cache := map[[2]string]string{ |
| + mem := newRestartMemory() |
| + mem.gen = map[[2]string]string{ |
| {"bab", "eleven"}: "etag-1", |
| {"bab", "titogo"}: genAbsent, // seen, and it has no object |
| } |
| - if err := seedRestartMarks(path, cache); err != nil { |
| + mem.scope = map[[2]string]string{ |
| + {"bab", "eleven"}: release.RestartScopeNone, |
| + } |
| + need, err := seedRestartMarks(path, mem) |
| + if err != nil { |
| t.Fatalf("seed: %v", err) |
| } |
| + if need { |
| + // The file this seed just wrote carries both memories, so there is |
| + // nothing for a root pass to fill in. |
| + t.Fatal("a seed that wrote the whole baseline still asked for a root pass") |
| + } |
| m := marksOf(t, path) |
| if m.Apps[restartMarkKey("bab", "eleven")] != "etag-1" || |
| m.Apps[restartMarkKey("bab", "titogo")] != restartAbsent { |
| t.Fatalf("seeded marks = %v, want the etag and the recorded absence", m.Apps) |
| } |
| + // The narrowed memory is seeded by the same sweep and in the same file: |
| + // a baseline that recorded one signal and not the other would leave the |
| + // root pass reading the second one as a first sight for a second time. |
| + if m.Scopes[restartMarkKey("bab", "eleven")] != release.RestartScopeNone { |
| + t.Fatalf("seeded scope marks = %v, want the recorded empty log", m.Scopes) |
| + } |
| - cache[[2]string{"bab", "eleven"}] = "etag-2" |
| - if err := seedRestartMarks(path, cache); err != nil { |
| + mem.gen[[2]string{"bab", "eleven"}] = "etag-2" |
| + if need, err := seedRestartMarks(path, mem); err != nil { |
| t.Fatalf("second seed: %v", err) |
| + } else if need { |
| + t.Fatal("a complete baseline on disk still asked for a root pass — every agent start would ask") |
| } |
| if got := marksOf(t, path).Apps[restartMarkKey("bab", "eleven")]; got != "etag-1" { |
| t.Fatalf("mark = %q after a second seed, want the original %q — seeding must never overwrite", got, "etag-1") |
| @@ -667,8 +685,10 @@ func TestSeedRestartMarksIsCreateOnly(t *testing.T) { |
| // the box later gains as movement. |
| func TestSeedRestartMarksRefusesAnEmptyBaseline(t *testing.T) { |
| path := filepath.Join(t.TempDir(), "restart-marks.json") |
| - if err := seedRestartMarks(path, map[[2]string]string{}); err != nil { |
| + if need, err := seedRestartMarks(path, newRestartMemory()); err != nil { |
| t.Fatalf("seed: %v", err) |
| + } else if need { |
| + t.Fatal("a sweep that saw nothing asked for a root pass; it may claim nothing at all") |
| } |
| if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("an empty sweep wrote %s (err %v), want no file at all", path, err) |
| @@ -687,14 +707,15 @@ func TestSeededBaselineIsWhatTheRootPassTrusts(t *testing.T) { |
| askRestart(t, ctx, s, "bab", "eleven") // an older request, already served |
| // The agent's first tick: baseline in memory, baseline on disk. |
| - cache := map[[2]string]string{} |
| - restartGenPass(ctx, routes, s, cache) |
| - if err := seedRestartMarks(restartMarksPath(dd), cache); err != nil { |
| + mem := newRestartMemory() |
| + restartGenPass(ctx, routes, s, mem.gen) |
| + restartScopePass(ctx, routes, s, mem.scope) |
| + if _, err := seedRestartMarks(restartMarksPath(dd), mem); err != nil { |
| t.Fatalf("seed: %v", err) |
| } |
| askRestart(t, ctx, s, "bab", "eleven") // the member types `carlos restart` |
| - moved, _ := restartGenPass(ctx, routes, s, cache) |
| + moved, _ := restartGenPass(ctx, routes, s, mem.gen) |
| if len(moved) != 1 { |
| t.Fatalf("the agent missed the request: moved = %v", moved) |
| } |
diff --git a/internal/host/restartscope.go b/internal/host/restartscope.go| new file mode 100644 |
| index 000000000..55f1d3ba9 |
| --- /dev/null |
| +++ b/internal/host/restartscope.go |
| @@ -0,0 +1,289 @@ |
| +// The box half of a NARROWED restart (discussion #16): the same two-process |
| +// dance restart.go describes, reading the request log at |
| +// release.RestartScopeKey instead of the app-wide generation, and cycling only |
| +// the routes a request actually names. |
| +// |
| +// Everything load-bearing in restart.go still holds here, and this file is |
| +// deliberately built to inherit rather than restate it: |
| +// |
| +// - It rides the SERIAL. No poll of its own; the agent's existing 2s serial |
| +// read is what makes either object get looked at, and an idle box reads |
| +// neither. The cost of this feature at rest is zero, and on a serial MOVE |
| +// it is one extra GET per app on this box. |
| +// - SEED-DON'T-REPLAY, per app, on this object's own mark. A box that has |
| +// never looked records where the log stands and cycles nothing. |
| +// - HIBERNATING routes are never woken. The root half skips them outright; |
| +// the agent's bounce is a no-op on a sleeping instance and cycles a warm |
| +// one. That is restart.go's rule verbatim, and narrowing the route set |
| +// cannot weaken it. |
| +// - A selector this binary does not understand addresses NOTHING. The |
| +// request is stepped over and the mark advances past it, so a future |
| +// `--region` cannot make today's box cycle an app. |
| +// |
| +// The one thing that is genuinely different: the CONTENT is read, not just the |
| +// etag. See internal/release/restartscope.go for why that grants nothing — |
| +// the account, the app and the candidate routes all come from this box's own |
| +// registry, and a selector can only ever pick a subset of them. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// restartMemory is the agent's in-process memory for BOTH restart signals: the |
| +// app-wide generation's etag, and the id of the last narrowed request acted |
| +// on. One struct rather than two loose maps because they are seeded together, |
| +// compared together on the same serial move, and are only ever meaningful as a |
| +// pair — a leg holding one and not the other would replay or drop half a |
| +// request set. |
| +// |
| +// It lives for the life of the agent process (fastConvergeLoop owns it, |
| +// restartSeed fills it), which is the whole point: there is nowhere else to |
| +// remember what "changed" means between two serial moves that may be days |
| +// apart. |
| +type restartMemory struct { |
| + gen map[[2]string]string // app -> restart-gen.json etag, or genAbsent |
| + scope map[[2]string]string // app -> last applied request id, or release.RestartScopeNone |
| +} |
| + |
| +func newRestartMemory() *restartMemory { |
| + return &restartMemory{gen: map[[2]string]string{}, scope: map[[2]string]string{}} |
| +} |
| + |
| +// scopedRestart is one app's outstanding narrowed work: the requests this box |
| +// has not acted on yet, newest last. |
| +type scopedRestart struct { |
| + app [2]string |
| + requests []release.RestartRequest |
| +} |
| + |
| +// restartScopePass is the agent's comparison for narrowed requests — the twin |
| +// of restartGenPass, with the same first-sight, absent-forever and |
| +// read-error rules and for the same reasons. What it reports is not "this app |
| +// moved" but WHICH REQUESTS are outstanding, because that is the only thing |
| +// that says which routes to touch. |
| +// |
| +// Two requests inside one poll window are two entries in the log, so the pair |
| +// is not collapsed into whichever landed last — the failure a bare generation |
| +// object would have here, and the reason the object is a log at all. |
| +// |
| +// A mark that is no longer IN the log (this box was away long enough for its |
| +// position to roll off) re-baselines and asks for nothing, with a line saying |
| +// so. Restart requests are point-in-time events: a dropped one is recovered by |
| +// typing the verb again, while a replayed one cycles production for something |
| +// somebody asked for last week. |
| +func restartScopePass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (out []scopedRestart, readErrs []error) { |
| + apps := restartApps(routes) |
| + live := make(map[[2]string]bool, len(apps)) |
| + for _, k := range apps { |
| + live[k] = true |
| + g, _, err := release.LoadRestartScopes(ctx, store, k[0], k[1]) |
| + if err != nil { |
| + // Unreadable OR unparseable, one answer: leave the mark exactly as |
| + // it was and evaluate the rest. Clearing it would turn a recovered |
| + // blip into a bogus first sight; advancing it past a log this |
| + // binary cannot read would drop the requests in it for good. |
| + readErrs = append(readErrs, fmt.Errorf("%s/%s: %w", k[0], k[1], err)) |
| + continue |
| + } |
| + newest := g.Newest() |
| + prev, seen := cache[k] |
| + if !seen { |
| + cache[k] = newest // first sight: record, ask for nothing |
| + continue |
| + } |
| + if prev == newest { |
| + continue |
| + } |
| + pending, found := g.PendingSince(prev) |
| + cache[k] = newest |
| + if !found { |
| + log.Printf("agent: narrowed restarts for %s/%s: this box's position is no longer in the log — re-baselined, nothing cycled (run the verb again if something is still waiting)", k[0], k[1]) |
| + continue |
| + } |
| + if len(pending) > 0 { |
| + out = append(out, scopedRestart{app: k, requests: pending}) |
| + } |
| + } |
| + for k := range cache { |
| + if !live[k] { |
| + delete(cache, k) |
| + } |
| + } |
| + return out, readErrs |
| +} |
| + |
| +// scopedHosts resolves outstanding requests against this box's routes: which |
| +// hosts of that app the requests actually name, deduplicated. |
| +// |
| +// Deduplicated across the whole set, not per request, because two pending |
| +// requests that both name a host mean one cycle of it, not two — and a verb |
| +// whose entire job is stopping processes must not manufacture a second stop |
| +// out of an operator typing the same thing twice. |
| +// |
| +// An UNKNOWN selector kind is stepped over with a log line and addresses |
| +// nothing. That is the forward-compatibility rule this feature is built on: |
| +// a box that cannot understand a narrowing must never fall back to the wider |
| +// thing. |
| +// |
| +// keep is the caller's own route filter — the root pass and the agent reach |
| +// different processes and each passes its own — so this function decides |
| +// membership and nothing else. |
| +func scopedHosts(routes []registry.Route, app [2]string, requests []release.RestartRequest, keep func(registry.Route) bool) []string { |
| + seen := map[string]bool{} |
| + var out []string |
| + for _, req := range requests { |
| + // Asked of the KIND alone, once, before any route is considered: an |
| + // app with no routes on this box must not look like an app whose |
| + // selector we failed to understand. |
| + if _, understood := req.Scope.Selects(registry.Route{}); !understood { |
| + log.Printf("restart: narrowed request for %s/%s names selector %q, which this build does not understand — nothing cycled for it (upgrade carlos on this box)", |
| + app[0], app[1], req.Scope.Kind) |
| + continue |
| + } |
| + for _, rt := range routes { |
| + if !release.Adoptable(rt) || rt.App != app[1] || release.RouteAccount(rt) != app[0] { |
| + continue |
| + } |
| + if sel, _ := req.Scope.Selects(rt); !sel || seen[rt.Host] || !keep(rt) { |
| + continue |
| + } |
| + seen[rt.Host] = true |
| + out = append(out, rt.Host) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// execBounceable is the complement of what rootRestartRoute's systemctl arms |
| +// can reach — no named unit, no unit backing — so the agent's half and the |
| +// root half never cycle the same process twice. Split out of bounceExecRoutes |
| +// so the narrowed half cannot drift from the app-wide one on which processes |
| +// belong to whom. |
| +func execBounceable(rt registry.Route) bool { |
| + return rt.Unit == "" && rt.Backing != registry.BackingUnit |
| +} |
| + |
| +// bounceScopedRoutes cycles the agent's OWN children for the routes the |
| +// outstanding narrowed requests name. bounceExecRoutes' argument, one selector |
| +// narrower — including on hibernation: Bounce cannot start anything, so a |
| +// sleeping instance is untouched and a warm one gets the cycle that was asked |
| +// for. |
| +func bounceScopedRoutes(routes []registry.Route, work []scopedRestart, bounce func(host string) bool) int { |
| + if bounce == nil || len(work) == 0 { |
| + return 0 |
| + } |
| + n := 0 |
| + for _, w := range work { |
| + for _, host := range scopedHosts(routes, w.app, w.requests, execBounceable) { |
| + if bounce(host) { |
| + n++ |
| + log.Printf("agent: narrowed restart requested: bounced exec instance %s", host) |
| + } |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// restartScopeLeg is the ROOT pass's narrowed half, called from inside |
| +// restartPass under the SAME lock and against the same marks file: for every |
| +// app whose request log has moved past this box's mark, cycle the routes those |
| +// requests name. |
| +// |
| +// already is the hosts the app-wide half has cycled on this pass. A route that |
| +// has just been restarted by a wider request must not be restarted a second |
| +// time by a narrower one that arrived in the same window — one pass, one cycle |
| +// per host. |
| +// |
| +// The rules are restartPass's, applied to this object: |
| +// |
| +// - An app this box has not recorded is recorded and NOT cycled. |
| +// - An unknown baseline records and dispatches nothing. |
| +// - A mark that rolled off the log re-baselines and dispatches nothing. |
| +// - HIBERNATING routes are skipped and not counted as failures: restarting a |
| +// process that is not running is meaningless, waking it to restart it |
| +// defeats hibernation for no gain, and it execs fresh on its next wake. |
| +// - A dispatch FAILURE still advances the mark. A request is a point-in-time |
| +// event, not a desired state; retrying it every hour forever is how one |
| +// wrong unit name becomes a permanent restart loop. |
| +// - The mark advances only AFTER the dispatch, so a pass that dies mid-cycle |
| +// retries rather than swallowing the request. |
| +func restartScopeLeg(ctx context.Context, routes []registry.Route, store release.Store, apps [][2]string, |
| + marks *restartMarks, known bool, restart func(host string) error, already map[string]bool) (changed, readFailed bool) { |
| + for _, k := range apps { |
| + g, _, err := release.LoadRestartScopes(ctx, store, k[0], k[1]) |
| + if err != nil { |
| + log.Printf("adopt: narrowed restarts %s/%s: %v (skipped this pass)", k[0], k[1], err) |
| + readFailed = true |
| + continue |
| + } |
| + newest := g.Newest() |
| + key := restartMarkKey(k[0], k[1]) |
| + prev, seen := marks.Scopes[key] |
| + if !known || !seen { |
| + if !seen || prev != newest { |
| + marks.Scopes[key] = newest |
| + changed = true |
| + } |
| + continue |
| + } |
| + if prev == newest { |
| + continue |
| + } |
| + pending, found := g.PendingSince(prev) |
| + if !found { |
| + log.Printf("adopt: narrowed restarts for %s/%s: this box's position is no longer in the log — re-baselined, nothing cycled", k[0], k[1]) |
| + marks.Scopes[key] = newest |
| + changed = true |
| + continue |
| + } |
| + dispatched := 0 |
| + for _, host := range scopedHosts(routes, k, pending, func(registry.Route) bool { return true }) { |
| + if already[host] { |
| + continue |
| + } |
| + rt, ok := routeByHost(routes, host) |
| + if !ok { |
| + continue |
| + } |
| + if rt.Hibernate { |
| + log.Printf("adopt: narrowed restart for %s/%s: %s is hibernating — left asleep (it execs fresh on its next wake)", k[0], k[1], host) |
| + continue |
| + } |
| + if err := restart(host); err != nil { |
| + log.Printf("adopt: narrowed restart for %s/%s: %s did not cycle: %v", k[0], k[1], host, err) |
| + continue |
| + } |
| + already[host] = true |
| + dispatched++ |
| + } |
| + // One line per APP, saying DISPATCHED rather than cycled, for the |
| + // reason the app-wide half says it: an exec-backed route reaching |
| + // rootRestartRoute lands on a try-restart of a carlos-app@ unit that |
| + // does not exist — a deliberate no-op this pass cannot tell apart from |
| + // a real one. |
| + log.Printf("adopt: narrowed restart for %s/%s: %d request(s), dispatched to %d route(s)", k[0], k[1], len(pending), dispatched) |
| + marks.Scopes[key] = newest |
| + changed = true |
| + } |
| + return changed, readFailed |
| +} |
| + |
| +// routeByHost finds a route by host among the ones this box serves. Linear |
| +// because the route set is a box's own table, and a map built per call would |
| +// be the same walk with an allocation on top. |
| +func routeByHost(routes []registry.Route, host string) (registry.Route, bool) { |
| + for _, rt := range routes { |
| + if rt.Host == host { |
| + return rt, true |
| + } |
| + } |
| + return registry.Route{}, false |
| +} |
diff --git a/internal/host/restartscope_test.go b/internal/host/restartscope_test.go| new file mode 100644 |
| index 000000000..2e54ecd6d |
| --- /dev/null |
| +++ b/internal/host/restartscope_test.go |
| @@ -0,0 +1,577 @@ |
| +package host |
| + |
| +// The box half of a narrowed restart (discussion #16): that a request naming |
| +// one route cycles one route, that the rules restart.go pays for — seed |
| +// don't replay, never wake a sleeper, one cycle per host per pass — survive |
| +// the narrowing, and that a selector this build cannot read cycles NOTHING. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// askScoped is a member's `carlos restart --host/--environment` landing in the |
| +// bucket. |
| +func askScoped(t *testing.T, ctx context.Context, s release.Store, acct, app, kind, value string) release.RestartRequest { |
| + t.Helper() |
| + req, err := release.AppendRestartScope(ctx, s, acct, app, release.RestartScope{Kind: kind, Value: value}) |
| + if err != nil { |
| + t.Fatalf("AppendRestartScope %s/%s (%s %s): %v", acct, app, kind, value, err) |
| + } |
| + return req |
| +} |
| + |
| +// envRoute is a unit-backed instance bound to a named config bundle — the |
| +// shape `--environment` exists for, and the shape only the root pass can |
| +// cycle. |
| +func envRoute(host, acct, app, env string) registry.Route { |
| + rt := unitRoute(host, acct, app) |
| + rt.Environment = env |
| + return rt |
| +} |
| + |
| +// titogoRoutes is the fixture the whole discussion is about, small: one app, |
| +// several routes, two bundles between them, one on nothing. |
| +func titogoRoutes() []registry.Route { |
| + return []registry.Route{ |
| + envRoute("connect.example.com", "bab", "titogo", "relay"), |
| + envRoute("relay2.example.com", "bab", "titogo", "relay"), |
| + envRoute("platform.example.com", "bab", "titogo", "router"), |
| + unitRoute("bill.example.com", "bab", "titogo"), |
| + } |
| +} |
| + |
| +// ---- the agent's sweep ------------------------------------------------------- |
| + |
| +// TestRestartScopePassFirstSightIsSilent: a box that has never looked records |
| +// where the log stands and asks for nothing. Without this, a box coming up |
| +// after a request would cycle for it a second time — and a box whose memory |
| +// was lost would cycle for every request still in the log. |
| +func TestRestartScopePassFirstSightIsSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") |
| + cache := map[[2]string]string{} |
| + work, errs := restartScopePass(ctx, titogoRoutes(), s, cache) |
| + if len(work) != 0 || len(errs) != 0 { |
| + t.Fatalf("first sight asked for %v (errs %v) — it must only record", work, errs) |
| + } |
| + if len(cache) != 1 { |
| + t.Fatalf("cache = %v, want the app recorded", cache) |
| + } |
| +} |
| + |
| +// TestRestartScopePassCarriesEveryRequestInAWindow is why the object is a log: |
| +// two requests between two sweeps are TWO selectors, and collapsing them into |
| +// the last one would silently drop a restart somebody was told was requested. |
| +func TestRestartScopePassCarriesEveryRequestInAWindow(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := titogoRoutes() |
| + cache := map[[2]string]string{} |
| + restartScopePass(ctx, routes, s, cache) // baseline |
| + |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "router") |
| + work, _ := restartScopePass(ctx, routes, s, cache) |
| + if len(work) != 1 || len(work[0].requests) != 2 { |
| + t.Fatalf("work = %+v, want one app carrying both requests", work) |
| + } |
| + hosts := scopedHosts(routes, work[0].app, work[0].requests, func(registry.Route) bool { return true }) |
| + if !slices.Equal(hosts, []string{"bill.example.com", "platform.example.com"}) { |
| + t.Fatalf("addressed %v, want exactly the two named routes", hosts) |
| + } |
| + // And an unmoved log asks for nothing on the next sweep. |
| + if work, _ := restartScopePass(ctx, routes, s, cache); len(work) != 0 { |
| + t.Fatalf("an unmoved log asked for %+v", work) |
| + } |
| +} |
| + |
| +// TestRestartScopePassReBaselinesALostPosition is the agent's own half of the |
| +// rolled-off-mark rule, exercised directly rather than through the root pass: |
| +// a box away long enough for its position to leave the log has no honest |
| +// answer to "what did I miss", so it records where the log now stands and asks |
| +// for nothing. Replaying instead would cycle routes for requests served days |
| +// ago; a dropped one is recovered by typing the verb again. |
| +func TestRestartScopePassReBaselinesALostPosition(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := titogoRoutes() |
| + req := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") |
| + |
| + // This box acted up to a request that has since been trimmed off the front |
| + // of the log. |
| + cache := map[[2]string]string{{"bab", "titogo"}: "rolled-off"} |
| + work, errs := restartScopePass(ctx, routes, s, cache) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(work) != 0 { |
| + t.Fatalf("a lost position asked for %+v — it must re-baseline instead of replaying the log", work) |
| + } |
| + if got := cache[[2]string{"bab", "titogo"}]; got != req.ID { |
| + t.Fatalf("cache = %q, want the re-baseline to the log's newest %q", got, req.ID) |
| + } |
| + // And the NEXT request is acted on: re-baselining is not a box giving up. |
| + next := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") |
| + work, _ = restartScopePass(ctx, routes, s, cache) |
| + if len(work) != 1 || len(work[0].requests) != 1 || work[0].requests[0].ID != next.ID { |
| + t.Fatalf("work = %+v, want the one request made after the re-baseline", work) |
| + } |
| +} |
| + |
| +// TestScopedHostsNarrowsToTheSelector is the whole complaint, tested: one |
| +// credential delivered to one bundle must cycle the routes reading that |
| +// bundle, not every route the app owns. |
| +func TestScopedHostsNarrowsToTheSelector(t *testing.T) { |
| + routes := titogoRoutes() |
| + app := [2]string{"bab", "titogo"} |
| + all := func(registry.Route) bool { return true } |
| + |
| + got := scopedHosts(routes, app, []release.RestartRequest{ |
| + {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeEnvironment, Value: "relay"}}, |
| + }, all) |
| + if !slices.Equal(got, []string{"connect.example.com", "relay2.example.com"}) { |
| + t.Fatalf("environment relay addressed %v, want the two relay routes and nothing else", got) |
| + } |
| + got = scopedHosts(routes, app, []release.RestartRequest{ |
| + {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "bill.example.com"}}, |
| + }, all) |
| + if !slices.Equal(got, []string{"bill.example.com"}) { |
| + t.Fatalf("host selector addressed %v, want the one route", got) |
| + } |
| + // Another account's route with the same host is not this app's route. |
| + other := append(routes, envRoute("connect.example.com", "zzz", "titogo", "relay")) |
| + got = scopedHosts(other, app, []release.RestartRequest{ |
| + {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "connect.example.com"}}, |
| + }, all) |
| + if !slices.Equal(got, []string{"connect.example.com"}) { |
| + t.Fatalf("addressed %v — a host must be resolved within this account and app only", got) |
| + } |
| +} |
| + |
| +// TestScopedHostsIgnoresAnUnknownSelector: the forward-compatibility rule at |
| +// the point it matters. A `--region` invented after this binary shipped must |
| +// address nothing here, never fall back to the app. |
| +func TestScopedHostsIgnoresAnUnknownSelector(t *testing.T) { |
| + got := scopedHosts(titogoRoutes(), [2]string{"bab", "titogo"}, []release.RestartRequest{ |
| + {ID: "1", Scope: release.RestartScope{Kind: "region", Value: "eu-west-1"}}, |
| + }, func(registry.Route) bool { return true }) |
| + if len(got) != 0 { |
| + t.Fatalf("an unreadable selector addressed %v — a box must never widen what it cannot understand", got) |
| + } |
| +} |
| + |
| +// TestScopedHostsDeduplicates: two requests naming the same route mean one |
| +// cycle of it. A verb whose job is stopping processes must not manufacture a |
| +// second stop out of somebody typing the same thing twice. |
| +func TestScopedHostsDeduplicates(t *testing.T) { |
| + got := scopedHosts(titogoRoutes(), [2]string{"bab", "titogo"}, []release.RestartRequest{ |
| + {ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "connect.example.com"}}, |
| + {ID: "2", Scope: release.RestartScope{Kind: release.RestartScopeEnvironment, Value: "relay"}}, |
| + }, func(registry.Route) bool { return true }) |
| + if !slices.Equal(got, []string{"connect.example.com", "relay2.example.com"}) { |
| + t.Fatalf("addressed %v, want each route once", got) |
| + } |
| +} |
| + |
| +// TestBounceScopedRoutesCyclesOnlyItsOwnChildren: the agent's half reaches |
| +// exec children and nothing else, exactly as the app-wide bounce does — the |
| +// two halves must never cycle one process twice. |
| +func TestBounceScopedRoutesCyclesOnlyItsOwnChildren(t *testing.T) { |
| + routes := []registry.Route{ |
| + execRoute("exec.example.com", "bab", "titogo"), |
| + unitRoute("unit.example.com", "bab", "titogo"), |
| + } |
| + routes[0].Environment, routes[1].Environment = "relay", "relay" |
| + var bounced []string |
| + n := bounceScopedRoutes(routes, []scopedRestart{{ |
| + app: [2]string{"bab", "titogo"}, |
| + requests: []release.RestartRequest{{ID: "1", Scope: release.RestartScope{Kind: release.RestartScopeEnvironment, Value: "relay"}}}, |
| + }}, func(h string) bool { bounced = append(bounced, h); return true }) |
| + if n != 1 || !slices.Equal(bounced, []string{"exec.example.com"}) { |
| + t.Fatalf("bounced %v (n=%d), want the exec child only — the unit route is root's", bounced, n) |
| + } |
| +} |
| + |
| +// TestRestartRequestLegCarriesBothSignals: an app-wide request and a narrowed |
| +// one can land in the same poll window, and the leg that stopped at the first |
| +// would leave the other to the hourly floor. |
| +func TestRestartRequestLegCarriesBothSignals(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "eleven"), |
| + } |
| + routes[1].Environment = "relay" |
| + mem := newRestartMemory() |
| + var bounced []string |
| + bounce := func(h string) bool { bounced = append(bounced, h); return true } |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, mem); asked { |
| + t.Fatal("the baselining leg asked for a root pass") |
| + } |
| + askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeEnvironment, "relay") |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, mem); !asked { |
| + t.Fatal("a narrowed request did not ask for a root pass") |
| + } |
| + if !slices.Equal(bounced, []string{"b.example.com"}) { |
| + t.Fatalf("bounced %v, want only the route reading relay", bounced) |
| + } |
| + if _, err := os.Stat(filepath.Join(dd, restartRequestMarker)); err != nil { |
| + t.Fatalf("no marker after a narrowed request: %v", err) |
| + } |
| +} |
| + |
| +// ---- the root pass ----------------------------------------------------------- |
| + |
| +// TestRestartPassCyclesOnlyTheNarrowedRoutes is the fix itself: a request |
| +// naming one environment cycles the routes on it, and leaves the other |
| +// seventeen production routes running. |
| +func TestRestartPassCyclesOnlyTheNarrowedRoutes(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + marks := restartMarksPath(dd) |
| + |
| + restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) // baseline |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"connect.example.com", "relay2.example.com"}) { |
| + t.Fatalf("cycled %v, want only the routes reading relay", rec.hosts) |
| + } |
| + // And the request is not served twice. |
| + rec2 := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec2.fn) |
| + if len(rec2.hosts) != 0 { |
| + t.Fatalf("a second pass cycled %v for a request already served", rec2.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassSeedsNarrowedRequestsWithoutReplaying: a box that has never |
| +// looked at the log records where it stands and cycles nothing — the property |
| +// that keeps a lost marks file from bouncing a box's whole route table. |
| +func TestRestartPassSeedsNarrowedRequestsWithoutReplaying(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + req := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") |
| + |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, restartMarksPath(dd), rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("the seeding pass cycled %v", rec.hosts) |
| + } |
| + m := marksOf(t, restartMarksPath(dd)) |
| + if got := m.Scopes[restartMarkKey("bab", "titogo")]; got != req.ID { |
| + t.Fatalf("scope mark = %q, want the newest request %q", got, req.ID) |
| + } |
| +} |
| + |
| +// TestRestartPassLeavesAHibernatingRouteAsleep: the load-bearing one. A |
| +// narrowed request that names a sleeping instance must not wake it — the |
| +// instance execs fresh on its next wake, which IS the restart. |
| +func TestRestartPassLeavesAHibernatingRouteAsleep(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + sleeper := envRoute("sleeper.example.com", "bab", "titogo", "relay") |
| + sleeper.Hibernate = true |
| + routes := append(titogoRoutes(), sleeper) |
| + marks := restartMarksPath(dd) |
| + |
| + restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if slices.Contains(rec.hosts, "sleeper.example.com") { |
| + t.Fatalf("cycled %v — a hibernating instance was woken by a restart request", rec.hosts) |
| + } |
| + if !slices.Equal(rec.hosts, []string{"connect.example.com", "relay2.example.com"}) { |
| + t.Fatalf("cycled %v, want the two awake relay routes", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassCyclesEachHostOnceAcrossBothSignals: an app-wide request and |
| +// a narrowed one landing in the same window are one cycle per route, not two. |
| +func TestRestartPassCyclesEachHostOnceAcrossBothSignals(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + marks := restartMarksPath(dd) |
| + |
| + restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) |
| + askRestart(t, ctx, s, "bab", "titogo") |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "relay") |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + slices.Sort(rec.hosts) |
| + want := []string{"bill.example.com", "connect.example.com", "platform.example.com", "relay2.example.com"} |
| + if !slices.Equal(rec.hosts, want) { |
| + t.Fatalf("cycled %v, want each route exactly once: %v", rec.hosts, want) |
| + } |
| +} |
| + |
| +// TestRestartPassIgnoresAnUnreadableSelector: the box-level half of the |
| +// never-widen rule. A request this build cannot interpret cycles nothing, and |
| +// the mark still advances so the pass does not re-read it hourly forever. |
| +func TestRestartPassIgnoresAnUnreadableSelector(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + marks := restartMarksPath(dd) |
| + restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) |
| + |
| + // Written straight to the bucket, because AppendRestartScope refuses to |
| + // write a kind it does not know — this is a NEWER console's request |
| + // reaching an older box, which is the case that matters. |
| + raw, err := json.Marshal(release.ScopedRestartGeneration{Schema: 1, Requests: []release.RestartRequest{ |
| + {ID: "future-1", Scope: release.RestartScope{Kind: "region", Value: "eu-west-1"}}, |
| + }}) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + if _, err := s.Put(ctx, release.RestartScopeKey("bab", "titogo"), raw, ""); err != nil { |
| + t.Fatalf("put a future request: %v", err) |
| + } |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("cycled %v for a selector this build cannot read — it must cycle nothing", rec.hosts) |
| + } |
| + if got := marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")]; got != "future-1" { |
| + t.Fatalf("scope mark = %q, want the request stepped over rather than re-read forever", got) |
| + } |
| +} |
| + |
| +// TestRestartPassReBaselinesALostPosition: a box away long enough for its mark |
| +// to roll off the log re-baselines and cycles nothing. A dropped request is |
| +// recoverable by typing the verb again; a replayed one is not. |
| +func TestRestartPassReBaselinesALostPosition(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + marks := restartMarksPath(dd) |
| + restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) |
| + |
| + raw, err := json.Marshal(release.ScopedRestartGeneration{Schema: 1, Requests: []release.RestartRequest{ |
| + {ID: "gone", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "bill.example.com"}}, |
| + }}) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + if _, err := s.Put(ctx, release.RestartScopeKey("bab", "titogo"), raw, ""); err != nil { |
| + t.Fatalf("seed a log: %v", err) |
| + } |
| + // This box acted up to a request that has since been trimmed away. |
| + m := marksOf(t, marks) |
| + m.Scopes[restartMarkKey("bab", "titogo")] = "rolled-off" |
| + saveMarks(t, marks, m) |
| + |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("cycled %v after losing its position — it must re-baseline instead", rec.hosts) |
| + } |
| + if got := marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")]; got != "gone" { |
| + t.Fatalf("scope mark = %q, want the re-baseline to the log's newest", got) |
| + } |
| +} |
| + |
| +// ---- the handover on a box that predates narrowing --------------------------- |
| + |
| +// writePreNarrowingMarks puts on disk exactly what the binary before this |
| +// feature wrote: schema, apps, and no `scopes` key at all. Every box that has |
| +// ever run the app-wide restart has one of these. |
| +func writePreNarrowingMarks(t *testing.T, path string, apps map[string]string) { |
| + t.Helper() |
| + raw, err := json.Marshal(map[string]any{"schema": 1, "apps": apps}) |
| + if err != nil { |
| + t.Fatalf("marshal old marks: %v", err) |
| + } |
| + if err := os.WriteFile(path, raw, 0o644); err != nil { |
| + t.Fatalf("write old marks: %v", err) |
| + } |
| +} |
| + |
| +// armAgent is one agent start: the two silent sweeps that fill its memory, and |
| +// the seed that either writes the root pass's baseline or reports that the one |
| +// already on disk is missing a narrowed mark. |
| +func armAgent(t *testing.T, ctx context.Context, routes []registry.Route, s release.Store, dd string) (*restartMemory, bool) { |
| + t.Helper() |
| + mem := newRestartMemory() |
| + if _, errs := restartGenPass(ctx, routes, s, mem.gen); len(errs) != 0 { |
| + t.Fatalf("gen sweep: %v", errs) |
| + } |
| + if _, errs := restartScopePass(ctx, routes, s, mem.scope); len(errs) != 0 { |
| + t.Fatalf("scope sweep: %v", errs) |
| + } |
| + need, err := seedRestartMarks(restartMarksPath(dd), mem) |
| + if err != nil { |
| + t.Fatalf("seed: %v", err) |
| + } |
| + return mem, need |
| +} |
| + |
| +// TestAPreNarrowingMarksFileIsCompletedBeforeTheNextRequestIsDropped is the |
| +// shape of every production box on the day this rolls, and the drop it used to |
| +// cause. |
| +// |
| +// The marks file was written before narrowed restarts existed, so it has |
| +// `apps` and no `scopes`. The agent's create-only seed will not touch it, |
| +// which leaves the ROOT pass with no narrowed position — and a missing |
| +// position reads as a FIRST SIGHT, so that pass records the log's newest id |
| +// and cycles nothing. An operator who ran `carlos restart --host relay2` in |
| +// that window was told "restart requested" and got no restart: the exec-shaped |
| +// routes survive because the agent bounces its own children out of memory, and |
| +// the unit-shaped ones — the relay and the router these flags exist for — are |
| +// the ones that vanish. |
| +// |
| +// The fix is the seed REPORTING the gap so that one root pass records the |
| +// marks. This walks the whole handover: arm, let that pass run, then ask. |
| +func TestAPreNarrowingMarksFileIsCompletedBeforeTheNextRequestIsDropped(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() // unit-shaped: only the root pass can cycle these |
| + marks := restartMarksPath(dd) |
| + // A request from before the roll, already in the log. Nothing here may |
| + // replay it. |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "router") |
| + writePreNarrowingMarks(t, marks, map[string]string{ |
| + restartMarkKey("bab", "titogo"): genAbsent, // no app-wide object; the old binary recorded the absence |
| + }) |
| + |
| + if _, need := armAgent(t, ctx, routes, s, dd); !need { |
| + t.Fatal("the agent saw a marks file with no narrowed mark and asked for nothing — " + |
| + `the next narrowed request would be answered "requested" and cycle nothing unit-shaped`) |
| + } |
| + if got := marksOf(t, marks).Scopes; len(got) != 0 { |
| + t.Fatalf("the seed wrote %v into an existing marks file — it must only report, never race the root pass's own write", got) |
| + } |
| + |
| + // The root pass that touch asks for. It records and cycles nothing: the |
| + // request already in the log is this box's history, not work it owes. |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("the baselining pass cycled %v — a box completing its baseline must replay nothing", rec.hosts) |
| + } |
| + if marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")] == "" { |
| + t.Fatal("the pass recorded no scope mark, so the box would re-seed forever") |
| + } |
| + |
| + // And NOW the operator types the verb. This is the request that used to be |
| + // dropped. |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "relay2.example.com") |
| + rec = &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"relay2.example.com"}) { |
| + t.Fatalf("cycled %v, want the one route the operator named", rec.hosts) |
| + } |
| + |
| + // A second agent start over a now-complete baseline asks for nothing: the |
| + // repair is one pass per box, not one per agent start. |
| + if _, need := armAgent(t, ctx, routes, s, dd); need { |
| + t.Fatal("an agent restarting over a complete baseline asked for another root pass") |
| + } |
| +} |
| + |
| +// TestSeedOnAFreshBoxAsksForNoRootPass is the genuinely-first-sight case, kept |
| +// separate because it is the one the create-only seed already handles: with no |
| +// marks file at all the agent writes BOTH memories itself, so there is nothing |
| +// left for a root pass to fill in — and the next narrowed request is acted on. |
| +func TestSeedOnAFreshBoxAsksForNoRootPass(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + marks := restartMarksPath(dd) |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeEnvironment, "router") // history |
| + |
| + if _, need := armAgent(t, ctx, routes, s, dd); need { |
| + t.Fatal("a seed that wrote the whole baseline itself still asked for a root pass") |
| + } |
| + if marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")] == "" { |
| + t.Fatal("the fresh seed recorded no scope mark") |
| + } |
| + |
| + askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"bill.example.com"}) { |
| + t.Fatalf("cycled %v, want the named route — the seeded baseline is what the root pass trusts", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartSeedAsksTheRootPassThroughTheMarker is the same repair through |
| +// the real entry point, so the marker file — the only way this unprivileged |
| +// process can reach a root pass at all — is pinned rather than assumed. |
| +func TestRestartSeedAsksTheRootPassThroughTheMarker(t *testing.T) { |
| + ctx, s := genKit(t) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + for _, rt := range titogoRoutes() { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + dd := t.TempDir() |
| + writePreNarrowingMarks(t, restartMarksPath(dd), map[string]string{ |
| + restartMarkKey("bab", "titogo"): genAbsent, |
| + }) |
| + restartSeed(ctx, reg, s, dd, newRestartMemory()) |
| + if _, err := os.Stat(filepath.Join(dd, restartRequestMarker)); err != nil { |
| + t.Fatalf("no marker after arming over a pre-narrowing baseline: %v — "+ |
| + "the root pass would record no narrowed mark until the hourly floor", err) |
| + } |
| + |
| + // A baseline this agent wrote itself leaves the marker alone: an agent that |
| + // touched it on every start would start a root pass on every start. |
| + fresh := t.TempDir() |
| + restartSeed(ctx, reg, s, fresh, newRestartMemory()) |
| + if _, err := os.Stat(filepath.Join(fresh, restartRequestMarker)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the fresh seed touched the marker (err %v), want no request at all", err) |
| + } |
| +} |
| + |
| +// TestRestartPassAdvancesTheScopeMarkOnAFailedDispatch: a request is a |
| +// point-in-time event, not a desired state. A unit that will not come back |
| +// must not be retried on every pass forever for something somebody asked once. |
| +func TestRestartPassAdvancesTheScopeMarkOnAFailedDispatch(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := titogoRoutes() |
| + marks := restartMarksPath(dd) |
| + restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) |
| + |
| + req := askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeHost, "bill.example.com") |
| + rec := &recordingRestart{fail: map[string]error{"bill.example.com": os.ErrPermission}} |
| + restartPass(ctx, routes, s, marks, rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"bill.example.com"}) { |
| + t.Fatalf("tried %v, want the named route", rec.hosts) |
| + } |
| + if got := marksOf(t, marks).Scopes[restartMarkKey("bab", "titogo")]; got != req.ID { |
| + t.Fatalf("scope mark = %q after a failed dispatch, want %q — an unadvanced mark retries forever", got, req.ID) |
| + } |
| +} |
| + |
| +// saveMarks rewrites the marks file the way the pass does, so a test can put |
| +// this box's memory where it needs it. |
| +func saveMarks(t *testing.T, path string, m *restartMarks) { |
| + t.Helper() |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatalf("marshal marks: %v", err) |
| + } |
| + if err := os.WriteFile(path, raw, 0o644); err != nil { |
| + t.Fatalf("write marks: %v", err) |
| + } |
| +} |
diff --git a/internal/release/restartscope.go b/internal/release/restartscope.go| new file mode 100644 |
| index 000000000..741fb508d |
| --- /dev/null |
| +++ b/internal/release/restartscope.go |
| @@ -0,0 +1,314 @@ |
| +// The NARROWED restart signal (discussion #16): a request to cycle ONE route, |
| +// or the routes reading ONE config environment, rather than every instance an |
| +// app owns. |
| +// |
| +// Why a second object rather than a field on restart-gen.json. A box acts on |
| +// that object's ETAG MOVING — it never reads the content — so a selector |
| +// written INTO it would be invisible to every box running today's binary, and |
| +// those boxes would read the movement the only way they know how: restart the |
| +// whole app. For titogo that is nineteen production routes cycled to deliver |
| +// one credential to one of them. The rule this repo already has for wire |
| +// shapes ("anything written before your change must still parse") has a twin |
| +// that this file is built on: an old reader that cannot understand a NARROWED |
| +// request must do NOTHING, never something wider than was asked for. |
| +// |
| +// A sibling key gets that for free. Nothing that exists today reads |
| +// restart-scope.json, so: |
| +// |
| +// - OLD box, NEW (narrowed) request — never looks at the key, sees no |
| +// movement on restart-gen.json, cycles nothing. The request simply does |
| +// not reach it. The console says which routes it addressed and on which |
| +// box's registry it resolved them, so an operator whose fleet still has |
| +// an old box can see that it is one box's answer. |
| +// - NEW box, OLD (app-wide) request — restart-gen.json moves exactly as it |
| +// always did and this file takes no part. |
| +// - NEW box, FUTURE request kind — an entry whose `kind` this binary does |
| +// not know is SKIPPED, not widened (see RestartScope.Selects). A selector |
| +// nobody here understands addresses nothing. |
| +// |
| +// Bucket layout addition: |
| +// |
| +// apps/<acct>/<app>/restart-scope.json CAS-guarded, schema-1 request log |
| +// |
| +// It is a LOG rather than a single generation, which is the one place this |
| +// shape costs more than restart-gen.json's. Movement is enough to carry "some |
| +// restart was asked for"; it is not enough to carry WHICH routes, because two |
| +// requests inside one 2s poll window collapse into one etag move and the |
| +// earlier selector would be lost — silently, having told its caller |
| +// "requested". Recording each request with an id, and remembering the last id |
| +// acted on, makes a coalesced pair two requests again. |
| +// |
| +// The content is TRUSTED here in a way restart-gen.json's is not, so it is |
| +// worth saying exactly how far that goes: an entry can only ever NARROW. The |
| +// box matches entries against routes it already serves for that account and |
| +// app — the account, the app and the route set come from the box's own |
| +// registry, never from this object — so the worst a forged or corrupted entry |
| +// can do is name a selector matching every route of one app, which is |
| +// precisely what the unsigned app-wide object next to it already permits. |
| +// Nothing about what runs, or about which account owns what, is decided here. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The selector kinds. Only Host and Environment are ever written into the |
| +// object; App names an app-wide request, which is restart-gen.json's job and |
| +// travels no further than the HTTP door and the CLI that talks to it. It |
| +// lives here so the three of them spell it the same way. |
| +const ( |
| + RestartScopeHost = "host" |
| + RestartScopeEnvironment = "environment" |
| + RestartScopeApp = "app" |
| +) |
| + |
| +// restartScopeKeep bounds the log. Entries older than the last few dozen are |
| +// dropped on the next append: a box that has been away long enough to miss |
| +// them re-baselines (see PendingSince) rather than replaying a week of |
| +// requests, and the object stays a small, cheap read. |
| +const restartScopeKeep = 32 |
| + |
| +// RestartScopeNone is the mark a box records for "I have looked and there is |
| +// nothing here" — the object is absent, or present with an empty log. The two |
| +// collapse deliberately: an object that appears carrying its first request |
| +// moves the mark from this sentinel to that request's id, which is movement, |
| +// which is exactly what it is. |
| +// |
| +// The NUL byte is what makes it safe as a sentinel — no request id contains |
| +// one, since ids are hex — and it is the same trick the box's own absent |
| +// marker uses for etags. |
| +const RestartScopeNone = "\x00none" |
| + |
| +// RestartScope is one selector: what to narrow a restart to. |
| +// |
| +// Kind is compared, never interpreted — an unknown one addresses nothing. |
| +// Value is matched against a registry column and never becomes a bucket key, |
| +// a filesystem path or a shell word; the strict shape checks belong at the |
| +// door that accepts it from a person, where a typo can still be answered with |
| +// what actually exists. |
| +type RestartScope struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value"` |
| +} |
| + |
| +// RestartRequest is one recorded request. ID is what a box remembers; nothing |
| +// is derived from it beyond "have I acted on this one". |
| +type RestartRequest struct { |
| + ID string `json:"id"` |
| + RequestedAt string `json:"requested_at"` |
| + Scope RestartScope `json:"scope"` |
| +} |
| + |
| +// ScopedRestartGeneration is the object: apps/<acct>/<app>/restart-scope.json. |
| +// Requests are OLDEST FIRST, so the newest is the last element and a box's |
| +// "what have I not acted on" is a suffix. |
| +type ScopedRestartGeneration struct { |
| + Schema int `json:"schema"` // 1 |
| + Requests []RestartRequest `json:"requests"` |
| +} |
| + |
| +// RestartScopeKey is the bucket key of app's narrowed-request log, derived |
| +// from AppPrefix like every other per-app key. |
| +func RestartScopeKey(acct, app string) string { return AppPrefix(acct, app) + "restart-scope.json" } |
| + |
| +// Selects reports whether rt is addressed by this scope, and whether this |
| +// binary understands the scope's kind at all. |
| +// |
| +// The second return is the forward-compatibility hinge, and the reason this |
| +// is two bools rather than one. A selector kind added after this binary was |
| +// built must address NOTHING here — not everything — so a caller that gets |
| +// understood=false skips the whole request rather than falling back to the |
| +// app. Widening on ignorance is the failure this whole file exists to avoid. |
| +// |
| +// The caller has already narrowed to the right account and app: this decides |
| +// only which of THOSE routes the selector names. |
| +func (sc RestartScope) Selects(rt registry.Route) (selected, understood bool) { |
| + switch sc.Kind { |
| + case RestartScopeHost: |
| + return rt.Host == sc.Value, true |
| + case RestartScopeEnvironment: |
| + // An environment selector never matches the unbound routes: "" is not |
| + // an environment name, it is the absence of one, and `--environment ""` |
| + // cannot be typed. A request to cycle what reads the DEFAULT bundle is |
| + // an app-wide request, and has the app-wide object. |
| + return sc.Value != "" && rt.Environment == sc.Value, true |
| + } |
| + return false, false |
| +} |
| + |
| +// ValidRestartScope refuses a selector this package would write but no box |
| +// could act on. It is the last gate before the bucket, not the first: the |
| +// door that took the value from a person has already checked its shape and |
| +// answered a typo with what exists. |
| +func ValidRestartScope(sc RestartScope) error { |
| + switch sc.Kind { |
| + case RestartScopeHost, RestartScopeEnvironment: |
| + default: |
| + return fmt.Errorf("restart scope: unknown kind %q", sc.Kind) |
| + } |
| + if sc.Value == "" { |
| + return fmt.Errorf("restart scope: %s has no value", sc.Kind) |
| + } |
| + if len(sc.Value) > 253 || strings.ContainsAny(sc.Value, "\x00\n\r") { |
| + return fmt.Errorf("restart scope: %s value is not usable", sc.Kind) |
| + } |
| + return nil |
| +} |
| + |
| +// LoadRestartScopes reads the log. found=false means the object is absent, |
| +// which is the ordinary state of almost every app and is legal forever. |
| +// |
| +// A newer schema is an ERROR rather than a best-effort parse: a box that |
| +// cannot be sure what a request means must do nothing, and its caller records |
| +// nothing, so the request is picked up whenever the box is upgraded rather |
| +// than dropped or guessed at. |
| +func LoadRestartScopes(ctx context.Context, s Store, acct, app string) (*ScopedRestartGeneration, bool, error) { |
| + key := RestartScopeKey(acct, app) |
| + raw, _, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return &ScopedRestartGeneration{Schema: 1}, false, nil |
| + } |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + g, err := parseRestartScopes(key, raw) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + return g, true, nil |
| +} |
| + |
| +func parseRestartScopes(key string, raw []byte) (*ScopedRestartGeneration, error) { |
| + var g ScopedRestartGeneration |
| + if err := json.Unmarshal(raw, &g); err != nil { |
| + return nil, fmt.Errorf("restart scopes at %s: %w", key, err) |
| + } |
| + if g.Schema > 1 { |
| + return nil, fmt.Errorf("restart scopes at %s: schema %d is newer than this binary understands — upgrade carlos", key, g.Schema) |
| + } |
| + return &g, nil |
| +} |
| + |
| +// Newest is the id of the last request in the log, or RestartScopeNone for an |
| +// empty one — the value a box records when it has nothing to act on. |
| +func (g *ScopedRestartGeneration) Newest() string { |
| + if g == nil || len(g.Requests) == 0 { |
| + return RestartScopeNone |
| + } |
| + return g.Requests[len(g.Requests)-1].ID |
| +} |
| + |
| +// PendingSince returns the requests recorded after mark — what a box that has |
| +// already acted up to mark still owes — and whether mark could be located at |
| +// all. |
| +// |
| +// found=false means the mark is an id that is no longer in the log: the box |
| +// has been away long enough for its position to roll off the end. There is no |
| +// honest answer to "what did I miss" then, and the two candidate guesses are |
| +// not equal — replaying the whole log would cycle routes for requests served |
| +// days ago, while re-baselining drops requests that are recoverable by typing |
| +// the verb again. Callers re-baseline; see the box's restart pass, which makes |
| +// the same choice everywhere else it is offered. |
| +// |
| +// RestartScopeNone as the mark is NOT an unknown position: it is a box that |
| +// looked and found nothing, so everything now in the log is new to it. |
| +func (g *ScopedRestartGeneration) PendingSince(mark string) (pending []RestartRequest, found bool) { |
| + if g == nil { |
| + return nil, true |
| + } |
| + if mark == RestartScopeNone { |
| + return g.Requests, true |
| + } |
| + for i, r := range g.Requests { |
| + if r.ID == mark { |
| + return g.Requests[i+1:], true |
| + } |
| + } |
| + if len(g.Requests) == 0 { |
| + // The log emptied under a mark that named a request — only possible if |
| + // the object was replaced wholesale. Unknown position, same answer. |
| + return nil, false |
| + } |
| + return nil, false |
| +} |
| + |
| +// AppendRestartScope records one narrowed request and returns it. |
| +// |
| +// A CAS conflict is RETRIED here, which is the opposite of TouchRestartGen's |
| +// treatment of the same error, and the difference is the whole point of this |
| +// object. There, any touch means "restart this app", so a racing writer has |
| +// already fired the exact signal this call exists to fire and a retry would |
| +// only manufacture a second restart. Here the other writer's request names a |
| +// DIFFERENT set of routes: dropping ours would answer "requested" for a |
| +// restart that will never happen. So we re-read and append again. |
| +// |
| +// The retry budget is small on purpose. This is a human-paced verb; a |
| +// contended object means something is wrong that another lap will not fix, |
| +// and the caller gets an error rather than a lie. |
| +func AppendRestartScope(ctx context.Context, s Store, acct, app string, scope RestartScope) (RestartRequest, error) { |
| + if !ValidAccount(acct) { |
| + return RestartRequest{}, fmt.Errorf("restart: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return RestartRequest{}, fmt.Errorf("restart: invalid app %q", app) |
| + } |
| + if err := ValidRestartScope(scope); err != nil { |
| + return RestartRequest{}, err |
| + } |
| + key := RestartScopeKey(acct, app) |
| + id := make([]byte, 16) |
| + if _, err := io.ReadFull(rand.Reader, id); err != nil { |
| + return RestartRequest{}, err |
| + } |
| + req := RestartRequest{ |
| + ID: hex.EncodeToString(id), |
| + RequestedAt: time.Now().UTC().Format(time.RFC3339), |
| + Scope: scope, |
| + } |
| + var lastErr error |
| + for attempt := 0; attempt < 3; attempt++ { |
| + raw, etag, err := s.Get(ctx, key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return RestartRequest{}, err |
| + } |
| + g := &ScopedRestartGeneration{Schema: 1} |
| + if err == nil { |
| + if g, err = parseRestartScopes(key, raw); err != nil { |
| + // Refusing beats overwriting: a log this binary cannot read is |
| + // one whose entries it would silently drop on the next write. |
| + return RestartRequest{}, err |
| + } |
| + } else { |
| + etag = "" // create-only |
| + } |
| + g.Schema = 1 |
| + g.Requests = append(g.Requests, req) |
| + if len(g.Requests) > restartScopeKeep { |
| + g.Requests = g.Requests[len(g.Requests)-restartScopeKeep:] |
| + } |
| + body, err := json.Marshal(g) |
| + if err != nil { |
| + return RestartRequest{}, err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + if errors.Is(err, ErrCASConflict) { |
| + lastErr = err |
| + continue |
| + } |
| + return RestartRequest{}, err |
| + } |
| + return req, nil |
| + } |
| + return RestartRequest{}, fmt.Errorf("restart: %s is being written by someone else — nothing was requested; try again: %w", key, lastErr) |
| +} |
diff --git a/internal/release/restartscope_test.go b/internal/release/restartscope_test.go| new file mode 100644 |
| index 000000000..caf3d9a8b |
| --- /dev/null |
| +++ b/internal/release/restartscope_test.go |
| @@ -0,0 +1,301 @@ |
| +package release |
| + |
| +// The narrowed restart's WIRE SHAPE (discussion #16): what lands in the |
| +// bucket, what a reader makes of it, and — the properties the whole design |
| +// rests on — that an old reader is untouched by it and a new reader never |
| +// widens a request it cannot understand. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func loadScopes(t *testing.T, s Store, acct, app string) *ScopedRestartGeneration { |
| + t.Helper() |
| + g, _, err := LoadRestartScopes(context.Background(), s, acct, app) |
| + if err != nil { |
| + t.Fatalf("LoadRestartScopes: %v", err) |
| + } |
| + return g |
| +} |
| + |
| +// TestAppendRestartScopeRecordsEachRequest: the object is a LOG, not a |
| +// generation. Two requests inside one poll window must both survive — the etag |
| +// would have collapsed them into whichever landed last, and the earlier |
| +// selector would vanish having been answered "requested". |
| +func TestAppendRestartScopeRecordsEachRequest(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + first, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}) |
| + if err != nil { |
| + t.Fatalf("first append: %v", err) |
| + } |
| + second, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeEnvironment, Value: "relay"}) |
| + if err != nil { |
| + t.Fatalf("second append: %v", err) |
| + } |
| + if first.ID == second.ID { |
| + t.Fatalf("both requests share id %q — a box could not tell them apart", first.ID) |
| + } |
| + if _, err := time.Parse(time.RFC3339, first.RequestedAt); err != nil { |
| + t.Fatalf("RequestedAt %q is not RFC3339: %v", first.RequestedAt, err) |
| + } |
| + g := loadScopes(t, s, "bab", "titogo") |
| + if g.Schema != 1 { |
| + t.Fatalf("Schema = %d, want 1", g.Schema) |
| + } |
| + if len(g.Requests) != 2 || g.Requests[0].ID != first.ID || g.Requests[1].ID != second.ID { |
| + t.Fatalf("log = %+v, want both requests, oldest first", g.Requests) |
| + } |
| + if g.Newest() != second.ID { |
| + t.Fatalf("Newest = %q, want the last request %q", g.Newest(), second.ID) |
| + } |
| +} |
| + |
| +// TestAppendRestartScopeLeavesTheAppWideObjectAlone is the ADDITIVE property |
| +// stated as a test: an old box watches restart-gen.json's etag and nothing |
| +// else, so a narrowed request must not touch it. If it did, every box in the |
| +// fleet that predates narrowing would read the movement the only way it knows |
| +// how — restart the whole app — which is the accident this feature prevents. |
| +func TestAppendRestartScopeLeavesTheAppWideObjectAlone(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, s, "bab", "titogo"); err != nil { |
| + t.Fatalf("TouchRestartGen: %v", err) |
| + } |
| + before := restartEtag(t, s, "bab", "titogo") |
| + if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}); err != nil { |
| + t.Fatalf("append: %v", err) |
| + } |
| + if after := restartEtag(t, s, "bab", "titogo"); after != before { |
| + t.Fatalf("restart-gen etag moved from %q to %q on a narrowed request — every old box would cycle the whole app", before, after) |
| + } |
| +} |
| + |
| +// TestRestartScopeAbsentIsLegal: an app nobody has narrowed a restart for has |
| +// no object, forever, and reading that is not an error. |
| +func TestRestartScopeAbsentIsLegal(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + g, found, err := LoadRestartScopes(context.Background(), s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("LoadRestartScopes on an absent object: %v", err) |
| + } |
| + if found { |
| + t.Fatal("found = true for an app with no object") |
| + } |
| + if g.Newest() != RestartScopeNone { |
| + t.Fatalf("Newest = %q, want the recorded-nothing sentinel", g.Newest()) |
| + } |
| + pending, ok := g.PendingSince(RestartScopeNone) |
| + if !ok || len(pending) != 0 { |
| + t.Fatalf("PendingSince(none) = %v, %v — an absent object owes nobody a restart", pending, ok) |
| + } |
| +} |
| + |
| +// TestPendingSinceReturnsOnlyWhatIsNew: a box that acted up to a mark owes |
| +// exactly the suffix after it, and nothing else. |
| +func TestPendingSinceReturnsOnlyWhatIsNew(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + var ids []string |
| + for _, env := range []string{"relay", "router", "demo"} { |
| + req, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeEnvironment, Value: env}) |
| + if err != nil { |
| + t.Fatalf("append %s: %v", env, err) |
| + } |
| + ids = append(ids, req.ID) |
| + } |
| + g := loadScopes(t, s, "bab", "titogo") |
| + |
| + // A box that has never looked records "none" and then owes everything. |
| + pending, ok := g.PendingSince(RestartScopeNone) |
| + if !ok || len(pending) != 3 { |
| + t.Fatalf("PendingSince(none) = %d requests, %v; want all three", len(pending), ok) |
| + } |
| + pending, ok = g.PendingSince(ids[0]) |
| + if !ok || len(pending) != 2 || pending[0].Scope.Value != "router" { |
| + t.Fatalf("PendingSince(first) = %+v, %v; want router and demo", pending, ok) |
| + } |
| + if pending, ok := g.PendingSince(ids[2]); !ok || len(pending) != 0 { |
| + t.Fatalf("PendingSince(newest) = %+v, %v; want nothing owed", pending, ok) |
| + } |
| +} |
| + |
| +// TestPendingSinceReportsALostPosition: a mark that has rolled off the end of |
| +// the log has no honest answer, and the caller must be told so rather than |
| +// handed a guess. Replaying the whole log would cycle production for requests |
| +// served days ago; the box re-baselines instead. |
| +func TestPendingSinceReportsALostPosition(t *testing.T) { |
| + g := &ScopedRestartGeneration{Schema: 1, Requests: []RestartRequest{ |
| + {ID: "b", Scope: RestartScope{Kind: RestartScopeHost, Value: "b.example.com"}}, |
| + }} |
| + if pending, ok := g.PendingSince("a-that-rolled-off"); ok || len(pending) != 0 { |
| + t.Fatalf("PendingSince(lost) = %+v, %v; want nothing and a report that the position is unknown", pending, ok) |
| + } |
| +} |
| + |
| +// TestRestartScopeLogIsBounded: the object stays small, and the oldest entries |
| +// are what go. A box far enough behind to lose its position re-baselines, |
| +// which is the safe direction. |
| +func TestRestartScopeLogIsBounded(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + var first RestartRequest |
| + for i := 0; i < restartScopeKeep+5; i++ { |
| + req, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}) |
| + if err != nil { |
| + t.Fatalf("append %d: %v", i, err) |
| + } |
| + if i == 0 { |
| + first = req |
| + } |
| + } |
| + g := loadScopes(t, s, "bab", "titogo") |
| + if len(g.Requests) != restartScopeKeep { |
| + t.Fatalf("log holds %d requests, want it trimmed to %d", len(g.Requests), restartScopeKeep) |
| + } |
| + if _, ok := g.PendingSince(first.ID); ok { |
| + t.Fatal("the oldest request survived the trim — the log is unbounded") |
| + } |
| +} |
| + |
| +// TestRestartScopeSelectsNarrowsAndNeverWidens is the forward-compatibility |
| +// rule: an unknown selector kind addresses NOTHING and says it did not |
| +// understand, so a `--region` invented later cannot make this binary fall back |
| +// to cycling the app. |
| +func TestRestartScopeSelectsNarrowsAndNeverWidens(t *testing.T) { |
| + relay := registry.Route{Host: "connect.example.com", App: "titogo", Environment: "relay"} |
| + plain := registry.Route{Host: "bill.example.com", App: "titogo"} |
| + |
| + if sel, ok := (RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}).Selects(plain); !sel || !ok { |
| + t.Fatalf("host selector missed its own route (sel %v, understood %v)", sel, ok) |
| + } |
| + if sel, _ := (RestartScope{Kind: RestartScopeHost, Value: "bill.example.com"}).Selects(relay); sel { |
| + t.Fatal("host selector matched another route") |
| + } |
| + if sel, ok := (RestartScope{Kind: RestartScopeEnvironment, Value: "relay"}).Selects(relay); !sel || !ok { |
| + t.Fatalf("environment selector missed a bound route (sel %v, understood %v)", sel, ok) |
| + } |
| + // An unbound route reads the app's DEFAULT bundle. "" is the absence of an |
| + // environment, not the name of one, so an environment selector never |
| + // reaches it — otherwise a bundle-shaped request would silently become an |
| + // app-wide one for every route nobody had bound. |
| + if sel, _ := (RestartScope{Kind: RestartScopeEnvironment, Value: ""}).Selects(plain); sel { |
| + t.Fatal("an empty environment selector matched an unbound route — that is an app-wide restart in disguise") |
| + } |
| + for _, rt := range []registry.Route{relay, plain} { |
| + sel, understood := (RestartScope{Kind: "region", Value: "eu-west-1"}).Selects(rt) |
| + if sel || understood { |
| + t.Fatalf("a selector kind this build does not know addressed %s (sel %v, understood %v)", rt.Host, sel, understood) |
| + } |
| + } |
| +} |
| + |
| +// TestValidRestartScopeRefusesWhatNoBoxCouldAct: the last gate before the |
| +// bucket. A request nothing could ever match must not be written, or a box |
| +// would step over it forever. |
| +func TestValidRestartScopeRefusesWhatNoBoxCouldAct(t *testing.T) { |
| + for _, sc := range []RestartScope{ |
| + {Kind: "", Value: "x"}, |
| + {Kind: "region", Value: "eu-west-1"}, |
| + {Kind: RestartScopeApp, Value: ""}, // app-wide has its own object |
| + {Kind: RestartScopeHost, Value: ""}, |
| + {Kind: RestartScopeEnvironment, Value: "bad\nvalue"}, |
| + } { |
| + if err := ValidRestartScope(sc); err == nil { |
| + t.Fatalf("ValidRestartScope(%+v) accepted it", sc) |
| + } |
| + } |
| + if err := ValidRestartScope(RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err != nil { |
| + t.Fatalf("a good host scope was refused: %v", err) |
| + } |
| +} |
| + |
| +// TestRestartScopeRefusesANewerSchema: a log written by a console this binary |
| +// is older than must not be guessed at, and must not be OVERWRITTEN either — |
| +// appending to it would silently drop entries this build cannot see. Both |
| +// paths refuse, which leaves the request to be picked up after an upgrade. |
| +func TestRestartScopeRefusesANewerSchema(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + raw, err := json.Marshal(map[string]any{"schema": 2, "requests": []any{}}) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + if _, err := s.Put(ctx, RestartScopeKey("bab", "titogo"), raw, ""); err != nil { |
| + t.Fatalf("seed a newer object: %v", err) |
| + } |
| + if _, _, err := LoadRestartScopes(ctx, s, "bab", "titogo"); err == nil { |
| + t.Fatal("a schema-2 log parsed as though this build understood it") |
| + } |
| + if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { |
| + t.Fatal("appended to a schema-2 log — entries this build cannot read would have been dropped") |
| + } |
| +} |
| + |
| +// TestAppendRestartScopeRetriesACASConflict is the difference from |
| +// TouchRestartGen, which treats a conflict as success. There, any touch fires |
| +// the one signal that exists; here the other writer's request names a |
| +// DIFFERENT set of routes, so losing ours would answer "requested" for a |
| +// restart that never happens. |
| +func TestAppendRestartScopeRetriesACASConflict(t *testing.T) { |
| + s := &conflictOnceStore{Store: NewDirStore(t.TempDir())} |
| + ctx := context.Background() |
| + if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err != nil { |
| + t.Fatalf("append through one conflict: %v", err) |
| + } |
| + if s.conflicts != 1 { |
| + t.Fatalf("the store served %d conflicts, want the one this test staged", s.conflicts) |
| + } |
| + if g := loadScopes(t, s.Store, "bab", "titogo"); len(g.Requests) != 1 { |
| + t.Fatalf("log = %+v, want the retried request to have landed", g.Requests) |
| + } |
| +} |
| + |
| +// conflictOnceStore fails the FIRST Put with a CAS conflict, as a racing |
| +// writer would, and behaves normally after that. |
| +type conflictOnceStore struct { |
| + Store |
| + conflicts int |
| +} |
| + |
| +func (c *conflictOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if c.conflicts == 0 { |
| + c.conflicts++ |
| + return "", ErrCASConflict |
| + } |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// TestRestartScopeKeyIsUnderTheAppPrefix: everything an app owns lives under |
| +// one account-scoped prefix, permanently — trash, retention and per-app |
| +// deletion all depend on it. |
| +func TestRestartScopeKeyIsUnderTheAppPrefix(t *testing.T) { |
| + if got, want := RestartScopeKey("bab", "titogo"), AppPrefix("bab", "titogo")+"restart-scope.json"; got != want { |
| + t.Fatalf("RestartScopeKey = %q, want %q", got, want) |
| + } |
| + if RestartScopeKey("bab", "titogo") == RestartGenKey("bab", "titogo") { |
| + t.Fatal("the narrowed log shares a key with the app-wide generation — an old box would read it as an app-wide request") |
| + } |
| +} |
| + |
| +// TestAppendRestartScopeRefusesABadName keeps a hostile account or app out of |
| +// the key, the same gate TouchRestartGen applies. |
| +func TestAppendRestartScopeRefusesABadName(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := AppendRestartScope(ctx, s, "../etc", "titogo", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { |
| + t.Fatal("a traversal-shaped account was accepted") |
| + } |
| + if _, err := AppendRestartScope(ctx, s, "bab", "../x", RestartScope{Kind: RestartScopeHost, Value: "a.example.com"}); err == nil { |
| + t.Fatal("a traversal-shaped app was accepted") |
| + } |
| + if _, err := os.Stat(t.TempDir()); err != nil { |
| + t.Fatalf("temp dir: %v", err) |
| + } |
| +} |
diff --git a/main.go b/main.go| index 4ec5ccee6..662b28bf5 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -167,7 +167,8 @@ Your apps — ship and run software on a CARLOS deployment |
| deploy ship + promote + wait until the URL serves the new version |
| canary the same, onto a host and channel of this branch's own, so |
| nothing the app already serves changes: deploy | ls | rm |
| - restart cycle an app's instances — no new version, no config change |
| + restart cycle an app's instances — no new version, no config change; |
| + --host or --environment narrows it, --dry-run names the set |
| schedule recurring or one-shot work per app: ls | set | rm | run |
| store object storage for an app: create | status | grant | rotate |
| email tenant email sending: enable | domains add | status | test | |
diff --git a/restart.go b/restart.go| index 57bda6fbe..3edad0b3d 100644 |
| --- a/restart.go |
| +++ b/restart.go |
| @@ -8,6 +8,31 @@ |
| // Nothing is pushed at a box, nothing wakes a sleeping instance, and no |
| // release ladder or bake window is involved — there is no new version for one |
| // to gate. |
| +// |
| +// SELECTORS (discussion #16). The app was once the only thing this verb could |
| +// address, which for an app like titogo — one app, nineteen production routes, |
| +// relay and router and tunnel and every customer instance under it — made the |
| +// only available request "bounce all of production" when what had changed was |
| +// one credential in one bundle. So: |
| +// |
| +// --host <h> exactly that route. |
| +// --environment <e> every route reading that config bundle. The shape that |
| +// pairs with `carlos secrets set --environment relay`, |
| +// because a bundle is delivered to an ENVIRONMENT and |
| +// "restart what reads this" is the request that follows. |
| +// --app alone the whole app, byte for byte what it always did. |
| +// |
| +// PRECEDENCE: --host wins over --environment, but only after the console has |
| +// checked that the named host really does read the named environment — |
| +// silently ignoring the wider flag would hide the case where one of the two is |
| +// a typo. A selector that names nothing is REFUSED, with what the app actually |
| +// has; it is never widened. And --dry-run answers the question the reporter |
| +// could not: which routes would this stop, before any of them stop. |
| +// |
| +// The narrowed forms go to a DIFFERENT console door, which is a safety |
| +// property, not tidiness — see restartScoped in apimode.go. The old door reads |
| +// no body, so a console that predates this would answer a narrowed request by |
| +// cycling the whole app. |
| package main |
| import ( |
| @@ -16,6 +41,8 @@ import ( |
| "fmt" |
| "log" |
| "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // runRestart is the verb. API mode ONLY, like deploy: the request is a console |
| @@ -23,12 +50,36 @@ import ( |
| func runRestart(args []string) { |
| fs := flag.NewFlagSet("restart", flag.ExitOnError) |
| app := appFlag(fs, "app whose instances to cycle") |
| + host := fs.String("host", "", "cycle exactly this route, instead of every instance of the app") |
| + environment := environmentFlag(fs, "cycle every route reading this named config bundle, instead of every instance of the app") |
| + dryRun := fs.Bool("dry-run", false, "name the routes this would cycle and stop; nothing is restarted") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to restart through (API mode only; default: the only logged-in one)") |
| - fs.Usage = flagUsage(fs, "") |
| + fs.Usage = flagUsage(fs, restartUsage) |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - usage := "usage: carlos restart --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| + hostSel := strings.TrimSpace(*host) |
| + envSel := strings.TrimSpace(*environment) |
| + usage := restartUsage |
| + // An EMPTY selector that was actually typed is refused rather than treated |
| + // as "no selector". `--host ""` (a shell variable that expanded to nothing |
| + // is how it happens) would otherwise fall through to an app-wide restart — |
| + // somebody asking to cycle one route and cycling every one instead, which |
| + // is precisely the accident these flags exist to prevent, arriving through |
| + // the flags themselves. |
| + for _, f := range []struct{ name, value string }{ |
| + {"host", *host}, |
| + {"environment", *environment}, |
| + } { |
| + if flagWasSet(fs, f.name) && strings.TrimSpace(f.value) == "" { |
| + log.Fatalf("restart: --%s was given with an empty value — nothing was restarted. "+ |
| + "Name a route or a bundle, or drop the flag to cycle the whole app.", f.name) |
| + } |
| + } |
| + // The same client-side gate `carlos env` applies to the same flag, for the |
| + // same reason: a value the console is certain to refuse should cost a typo |
| + // nothing. |
| + checkEnvironmentFlag(envSel) |
| if name != "" { |
| // Client-side gate before the server's, the same one `carlos apps create` |
| // applies: a name the console is certain to refuse should cost a typo |
| @@ -42,6 +93,15 @@ func runRestart(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| + // Anything narrowed, and any dry run, goes to the door that can resolve a |
| + // selector and name what it resolved to. A plain --app takes the original |
| + // door, unchanged: it is the one form that works against every console in |
| + // the fleet, including one that predates all of this, and an existing |
| + // script must not start depending on a door its console may not serve. |
| + if hostSel != "" || envSel != "" || *dryRun { |
| + runRestartScoped(sess, acct, name, hostSel, envSel, *dryRun) |
| + return |
| + } |
| res, err := sess.restart(context.Background(), acct, name) |
| if err != nil { |
| log.Fatalf("restart: %v", err) |
| @@ -58,12 +118,98 @@ func runRestart(args []string) { |
| // and respawn on their next request or scheduled run — which for an idle |
| // app may be a while, and is the correct behaviour, not a delay to |
| // apologise for. |
| - fmt.Fprintf(stdout, "%s for %s/%s — running instances stop within seconds; "+ |
| - "long-running ones restart, on-demand ones respawn on their next request "+ |
| - "(sleeping instances stay asleep and start fresh when next woken)\n", |
| - colorFor(stdout).ok("restart requested"), res.Account, res.App) |
| + fmt.Fprintf(stdout, "%s for %s/%s — %s\n", |
| + colorFor(stdout).ok("restart requested"), res.Account, res.App, restartTimingCopy) |
| } |
| +// restartUsage is the one place the flags and their precedence are stated for |
| +// somebody at a terminal. Precedence is written down HERE and in |
| +// docs/site/cli.md rather than left to be inferred, because the failure this |
| +// verb was narrowed to prevent is somebody inferring wrongly. |
| +const restartUsage = `usage: carlos restart [--app <a>] [--host <h>] [--environment <e>] [--dry-run] |
| + [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] |
| + |
| +Cycles running processes with no new version and no config change. |
| + |
| + --app alone every instance of the app. |
| + --environment <e> only the routes reading that config bundle — what to |
| + reach for after carlos secrets set --environment <e>. |
| + --host <h> only that one route. |
| + |
| +--host wins over --environment when both are given, and the console refuses |
| +the pair if that host does not actually read that environment: one of the two |
| +is then a typo. A selector matching nothing is refused, never widened. |
| + |
| +--dry-run names the routes the same request would cycle and stops there. |
| + |
| +Signed-in only (carlos auth login).` |
| + |
| +// runRestartScoped is the narrowed and dry-run half: ask the console to |
| +// resolve the selector, then print WHAT IT RESOLVED TO. Naming the set is the |
| +// whole point of the verb having selectors at all — the complaint that got |
| +// them built was that an operator could not tell what the blast radius would |
| +// be, and a narrowed restart that reported no more than the old one would have |
| +// fixed the radius and left the blindness. |
| +func runRestartScoped(sess *apiSession, acct apiAcct, app, host, environment string, dryRun bool) { |
| + res, err := sess.restartScoped(context.Background(), acct, app, host, environment, dryRun) |
| + if err != nil { |
| + log.Fatalf("restart: %v", err) |
| + } |
| + c := colorFor(stdout) |
| + what := restartScopeWords(res.Scope.Kind, res.Scope.Value) |
| + if res.Requested { |
| + fmt.Fprintf(stdout, "%s for %s/%s — %s, %d route(s):\n", |
| + c.ok("restart requested"), res.Account, res.App, what, len(res.Routes)) |
| + } else { |
| + // Said FIRST, before the list, so a skimmed answer cannot be mistaken |
| + // for a restart that happened. |
| + fmt.Fprintf(stdout, "%s — nothing was restarted. This would cycle %d route(s) of %s/%s (%s):\n", |
| + c.warn("dry run"), len(res.Routes), res.Account, res.App, what) |
| + } |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "HOST\tENVIRONMENT\tUNIT\tNOTE") |
| + for _, rt := range res.Routes { |
| + note := "—" |
| + if rt.Hibernate { |
| + // Named rather than filtered out. A route in the set that will not |
| + // cycle now is a fact the operator needs, and "asleep" is the |
| + // correct outcome — it execs fresh when something next wakes it — |
| + // not a failure to apologise for or to hide. |
| + note = "asleep — left as it is, execs fresh on its next wake" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Host, dashIfEmpty(rt.Environment), dashIfEmpty(rt.Unit), note) |
| + } |
| + w.Flush() |
| + if res.Requested { |
| + fmt.Fprintln(stdout, restartTimingCopy) |
| + } |
| + // Said every time: this console reads its OWN box's registry, so on a |
| + // multi-box fleet the list is what one box can see. The REQUEST is not |
| + // limited that way — every box serving the app converges on it and applies |
| + // the same selector to its own routes — and conflating the two would be |
| + // its own lie. |
| + mutedf(stdout, "(routes on this deployment's own box; other boxes in a fleet apply the same selector to their own routes and are not listed)") |
| +} |
| + |
| +// restartScopeWords renders a selector as the phrase both halves of the output |
| +// use, so the header and any refusal name the same thing the same way. |
| +func restartScopeWords(kind, value string) string { |
| + switch kind { |
| + case release.RestartScopeHost: |
| + return "host " + value |
| + case release.RestartScopeEnvironment: |
| + return "environment " + value |
| + } |
| + return "the whole app" |
| +} |
| + |
| +// restartTimingCopy is the sentence the app-wide form has printed since #113, |
| +// kept identical here: what "requested" actually means for each shape of |
| +// instance. One string rather than two, because two copies of one sentence is |
| +// how they end up disagreeing. |
| +const restartTimingCopy = "running instances stop within seconds; long-running ones restart, " + |
| + "on-demand ones respawn on their next request (sleeping instances stay asleep and start fresh when next woken)" |
| + |
| // requireRestartAPISession resolves signed-in mode for restart, refusing |
| // direct-bucket mode with restart's OWN message — the pattern |
| // requireDeployAPISession sets, and for its reason: a refusal that names |
diff --git a/restart_test.go b/restart_test.go| index 1954c7a12..94a7dd19d 100644 |
| --- a/restart_test.go |
| +++ b/restart_test.go |
| @@ -155,3 +155,185 @@ func TestRestartVerbRefusals(t *testing.T) { |
| t.Fatalf("bad app name = %q, want the name gate", out) |
| } |
| } |
| + |
| +// ---- the narrowed forms (discussion #16) ------------------------------------ |
| + |
| +// TestAPISessionRestartScopedWireShape pins the SEPARATE path and the body it |
| +// carries. Separate is the safety property: the app-wide door reads no body at |
| +// all, so a narrowed request sent there would be answered by cycling every |
| +// instance the app owns. This client must never send one there. |
| +func TestAPISessionRestartScopedWireShape(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.RequestURI(), r.Method |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "account": "bab", "app": "titogo", "requested": true, "dry_run": false, |
| + "scope": map[string]string{"kind": "environment", "value": "relay"}, |
| + "routes": []map[string]any{ |
| + {"host": "connect.go.tito.io", "environment": "relay", "hibernate": false}, |
| + {"host": "sleepy.go.tito.io", "environment": "relay", "hibernate": true}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).restartScoped(context.Background(), |
| + apiAcct{Seg: "bab"}, "titogo", "", "relay", false) |
| + if err != nil { |
| + t.Fatalf("restartScoped: %v", err) |
| + } |
| + if gotMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bab/titogo/restart/scope" { |
| + t.Fatalf("path = %s — a narrowed request must not reach the app-wide door", gotPath) |
| + } |
| + if gotBody["environment"] != "relay" || gotBody["host"] != "" || gotBody["dry_run"] != false { |
| + t.Fatalf("body = %v, want the selector and no dry run", gotBody) |
| + } |
| + // The ADDRESSED SET comes back, which is the whole point of the flags: the |
| + // complaint was that an operator could not tell what the blast radius was. |
| + if len(res.Routes) != 2 || res.Routes[0].Host != "connect.go.tito.io" || !res.Routes[1].Hibernate { |
| + t.Fatalf("routes = %+v, want both rows and the sleeping one flagged", res.Routes) |
| + } |
| + if !res.Requested || res.Scope.Kind != "environment" || res.Scope.Value != "relay" { |
| + t.Fatalf("result = %+v, want the console's own account of what it did", res) |
| + } |
| +} |
| + |
| +// TestAPISessionRestartScopedDryRunAsksForNothing: the flag travels, and the |
| +// console's answer — not this client's own flags — is what says whether |
| +// anything was restarted. |
| +func TestAPISessionRestartScopedDryRunAsksForNothing(t *testing.T) { |
| + var gotBody map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "account": "bab", "app": "titogo", "requested": false, "dry_run": true, |
| + "scope": map[string]string{"kind": "app"}, "routes": []map[string]any{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "", true) |
| + if err != nil { |
| + t.Fatalf("restartScoped dry run: %v", err) |
| + } |
| + if gotBody["dry_run"] != true { |
| + t.Fatalf("body = %v, want the dry run asked for", gotBody) |
| + } |
| + if res.Requested { |
| + t.Fatal("a dry run came back reporting a restart") |
| + } |
| +} |
| + |
| +// TestAPISessionRestartScoped404NamesTheOldConsole: a console that predates |
| +// the narrowed door answers net/http's byte-identical "404 page not found", |
| +// exactly as the membership gate does. The message must name all three |
| +// possibilities — including the form that still works there, because telling |
| +// somebody to redeploy their console and nothing else leaves them stuck. |
| +func TestAPISessionRestartScoped404NamesTheOldConsole(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "bill.go.tito.io", "", false) |
| + if err == nil { |
| + t.Fatal("a 404 must error") |
| + } |
| + for _, want := range []string{"-account", "predates", "redeploy the console", "carlos restart --app"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Fatalf("error %q does not mention %q", err, want) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISessionRestartScopedServerRefusalSurfacesVerbatim: the refusal that |
| +// names what exists ("no route of this app reads environment relayy — this |
| +// console's box has relay, router") is the console's own sentence, and it is |
| +// the sentence that lets somebody spot their typo. It must not be replaced |
| +// with a generic one here. |
| +func TestAPISessionRestartScopedServerRefusalSurfacesVerbatim(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusBadRequest) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "no route of this app reads environment relayy — this console's box has relay, router", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "relayy", false) |
| + if err == nil || !strings.Contains(err.Error(), "relay, router") { |
| + t.Fatalf("restart scope 400 = %v, want the console's own list of what exists", err) |
| + } |
| +} |
| + |
| +// TestRestartSelectorRefusals drives the real binary for the selector gates it |
| +// applies before dialling anything — the client-side half of "a typo should |
| +// cost nothing". |
| +func TestRestartSelectorRefusals(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + run := func(args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t) |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // "default" is the base bundle no route is BOUND to, so a restart of what |
| + // reads it is a restart of the app — refused by name rather than resolved |
| + // to an empty set. Same gate `carlos env` applies to the same flag. |
| + out, err := run("restart", "-app", "titogo", "-environment", "default") |
| + if err == nil { |
| + t.Fatalf("restart --environment default exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "already names the base bundle") { |
| + t.Fatalf("--environment default = %q, want the named refusal", out) |
| + } |
| + |
| + out, err = run("restart", "-app", "titogo", "-environment", "Not Valid") |
| + if err == nil { |
| + t.Fatalf("restart with a malformed environment exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "not a valid environment name") { |
| + t.Fatalf("bad environment = %q, want the shape gate", out) |
| + } |
| + |
| + // A selector that was TYPED but empty — a shell variable that expanded to |
| + // nothing — must not fall through to "no selector", which is an app-wide |
| + // restart. Somebody asking to cycle one route and cycling all of them |
| + // instead is the accident the flags exist to prevent, arriving through the |
| + // flags themselves. |
| + for _, args := range [][]string{ |
| + {"restart", "-app", "titogo", "-host", ""}, |
| + {"restart", "-app", "titogo", "-environment", ""}, |
| + } { |
| + out, err := run(args...) |
| + if err == nil { |
| + t.Fatalf("%v exited 0:\n%s", args, out) |
| + } |
| + if !strings.Contains(out, "empty value") || !strings.Contains(out, "nothing was restarted") { |
| + t.Fatalf("%v = %q, want the empty-selector refusal", args, out) |
| + } |
| + } |
| + |
| + // The selectors do not change the mode refusal: a narrowed restart is |
| + // still a console verb, and direct bucket mode still says so in restart's |
| + // own words. |
| + cmd := exec.Command(bin, "restart", "-app", "titogo", "-host", "bill.go.tito.io") |
| + cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+t.TempDir()) |
| + cmd.Dir = scratch |
| + raw, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("a narrowed restart in direct bucket mode exited 0:\n%s", raw) |
| + } |
| + if !strings.Contains(string(raw), "carlos restart works through a console") { |
| + t.Fatalf("direct-mode refusal = %q, want restart's own message", raw) |
| + } |
| +} |