// restart.go is `carlos restart`: cycle an app's processes with no version // and no config change (issue #113). It is the member-facing answer to a // wedged instance, which until now meant `systemctl` on a box — and every // carlos command is designed for someone with no box access at all. // // Pull-based, like everything else here: the console touches one small object, // every box serving the app notices within seconds and cycles its own routes. // 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 exactly that route. // --environment 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 ( "context" "flag" "fmt" "log" "strings" "github.com/carlosframework/platform/internal/release" ) // runRestart is the verb. API mode ONLY, like deploy: the request is a console // write, and the object it touches means nothing without a fleet reading it. func runRestart(args []string) { fs := flag.NewFlagSet("restart", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) 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 := consoleOption(fs, "console origin to restart through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, restartUsage) fs.Parse(args) name := strings.TrimSpace(*app) 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 // nothing, and the shared write budget is spent per attempt. if err := validAppName(name); err != nil { log.Fatalf("restart: %v", err) } } sess := requireRestartAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) message := "" if !*dryRun { var merr error message, merr = resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("restart: %v", merr) } } // 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, message) return } res, err := sess.restart(context.Background(), acct, name, message) if err != nil { log.Fatalf("restart: %v", err) } // One line, and it says REQUESTED rather than restarted, because that is // what actually happened: the boxes converge on their own within seconds, // and this terminal is not watching them do it. Claiming a restart here // would be the same lie issue #110 was made of, told by the CLI instead of // a header. // // It also does not promise that everything is back up in seconds, because // that is not how the box behaves: long-running tenants are restarted // within seconds, while exec-backed instances are STOPPED within seconds // 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 — %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 ] [--host ] [--environment ] [--dry-run] [--account ] [--account-id ] [--console ] Cycles running processes with no new version and no config change. --app alone every instance of the app. --environment only the routes reading that config bundle — what to reach for after carlos secrets set --environment . --host 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, message string) { res, err := sess.restartScoped(context.Background(), acct, app, host, environment, dryRun, message) 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 // another verb's advice sends people somewhere that cannot help them. // // There is deliberately no headless equivalent to fall back to. A bucket-mode // operator asking for a restart already has the box (that is what bucket mode // means), so the honest answer is the box's own systemctl — not a bucket write // this CLI could make and no console would announce. func requireRestartAPISession(consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("carlos restart works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). restart is a member verb with no\n" + "direct-bucket equivalent — the request is a console write the fleet listens for. Unset the\n" + "store env and run: carlos auth login --console .") } return sess }