| 1 | // restart.go is `carlos restart`: cycle an app's processes with no version |
| 2 | // and no config change (issue #113). It is the member-facing answer to a |
| 3 | // wedged instance, which until now meant `systemctl` on a box — and every |
| 4 | // carlos command is designed for someone with no box access at all. |
| 5 | // |
| 6 | // Pull-based, like everything else here: the console touches one small object, |
| 7 | // every box serving the app notices within seconds and cycles its own routes. |
| 8 | // Nothing is pushed at a box, nothing wakes a sleeping instance, and no |
| 9 | // release ladder or bake window is involved — there is no new version for one |
| 10 | // to gate. |
| 11 | // |
| 12 | // SELECTORS (discussion #16). The app was once the only thing this verb could |
| 13 | // address, which for an app like titogo — one app, nineteen production routes, |
| 14 | // relay and router and tunnel and every customer instance under it — made the |
| 15 | // only available request "bounce all of production" when what had changed was |
| 16 | // one credential in one bundle. So: |
| 17 | // |
| 18 | // --host <h> exactly that route. |
| 19 | // --environment <e> every route reading that config bundle. The shape that |
| 20 | // pairs with `carlos secrets set --environment relay`, |
| 21 | // because a bundle is delivered to an ENVIRONMENT and |
| 22 | // "restart what reads this" is the request that follows. |
| 23 | // --app alone the whole app, byte for byte what it always did. |
| 24 | // |
| 25 | // PRECEDENCE: --host wins over --environment, but only after the console has |
| 26 | // checked that the named host really does read the named environment — |
| 27 | // silently ignoring the wider flag would hide the case where one of the two is |
| 28 | // a typo. A selector that names nothing is REFUSED, with what the app actually |
| 29 | // has; it is never widened. And --dry-run answers the question the reporter |
| 30 | // could not: which routes would this stop, before any of them stop. |
| 31 | // |
| 32 | // The narrowed forms go to a DIFFERENT console door, which is a safety |
| 33 | // property, not tidiness — see restartScoped in apimode.go. The old door reads |
| 34 | // no body, so a console that predates this would answer a narrowed request by |
| 35 | // cycling the whole app. |
| 36 | package main |
| 37 | |
| 38 | import ( |
| 39 | "context" |
| 40 | "flag" |
| 41 | "fmt" |
| 42 | "log" |
| 43 | "strings" |
| 44 | |
| 45 | "github.com/carlosframework/platform/internal/release" |
| 46 | ) |
| 47 | |
| 48 | // runRestart is the verb. API mode ONLY, like deploy: the request is a console |
| 49 | // write, and the object it touches means nothing without a fleet reading it. |
| 50 | func runRestart(args []string) { |
| 51 | fs := flag.NewFlagSet("restart", flag.ExitOnError) |
| 52 | messageFlags := addMutationMessageFlags(fs) |
| 53 | app := appFlag(fs, "app whose instances to cycle") |
| 54 | host := fs.String("host", "", "cycle exactly this route, instead of every instance of the app") |
| 55 | environment := environmentFlag(fs, "cycle every route reading this named config bundle, instead of every instance of the app") |
| 56 | dryRun := fs.Bool("dry-run", false, "name the routes this would cycle and stop; nothing is restarted") |
| 57 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 58 | consoleFlag := consoleOption(fs, "console origin to restart through (API mode only; default: the only logged-in one)") |
| 59 | fs.Usage = flagUsage(fs, restartUsage) |
| 60 | fs.Parse(args) |
| 61 | name := strings.TrimSpace(*app) |
| 62 | hostSel := strings.TrimSpace(*host) |
| 63 | envSel := strings.TrimSpace(*environment) |
| 64 | usage := restartUsage |
| 65 | // An EMPTY selector that was actually typed is refused rather than treated |
| 66 | // as "no selector". `--host ""` (a shell variable that expanded to nothing |
| 67 | // is how it happens) would otherwise fall through to an app-wide restart — |
| 68 | // somebody asking to cycle one route and cycling every one instead, which |
| 69 | // is precisely the accident these flags exist to prevent, arriving through |
| 70 | // the flags themselves. |
| 71 | for _, f := range []struct{ name, value string }{ |
| 72 | {"host", *host}, |
| 73 | {"environment", *environment}, |
| 74 | } { |
| 75 | if flagWasSet(fs, f.name) && strings.TrimSpace(f.value) == "" { |
| 76 | log.Fatalf("restart: --%s was given with an empty value — nothing was restarted. "+ |
| 77 | "Name a route or a bundle, or drop the flag to cycle the whole app.", f.name) |
| 78 | } |
| 79 | } |
| 80 | // The same client-side gate `carlos env` applies to the same flag, for the |
| 81 | // same reason: a value the console is certain to refuse should cost a typo |
| 82 | // nothing. |
| 83 | checkEnvironmentFlag(envSel) |
| 84 | if name != "" { |
| 85 | // Client-side gate before the server's, the same one `carlos apps create` |
| 86 | // applies: a name the console is certain to refuse should cost a typo |
| 87 | // nothing, and the shared write budget is spent per attempt. |
| 88 | if err := validAppName(name); err != nil { |
| 89 | log.Fatalf("restart: %v", err) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | sess := requireRestartAPISession(*consoleFlag) |
| 94 | printUsing(stdout, sess) |
| 95 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 96 | name = resolvedApp(sess, acct, name, usage) |
| 97 | message := "" |
| 98 | if !*dryRun { |
| 99 | var merr error |
| 100 | message, merr = resolveMutationMessage(fs, messageFlags) |
| 101 | if merr != nil { |
| 102 | log.Fatalf("restart: %v", merr) |
| 103 | } |
| 104 | } |
| 105 | // Anything narrowed, and any dry run, goes to the door that can resolve a |
| 106 | // selector and name what it resolved to. A plain --app takes the original |
| 107 | // door, unchanged: it is the one form that works against every console in |
| 108 | // the fleet, including one that predates all of this, and an existing |
| 109 | // script must not start depending on a door its console may not serve. |
| 110 | if hostSel != "" || envSel != "" || *dryRun { |
| 111 | runRestartScoped(sess, acct, name, hostSel, envSel, *dryRun, message) |
| 112 | return |
| 113 | } |
| 114 | res, err := sess.restart(context.Background(), acct, name, message) |
| 115 | if err != nil { |
| 116 | log.Fatalf("restart: %v", err) |
| 117 | } |
| 118 | // One line, and it says REQUESTED rather than restarted, because that is |
| 119 | // what actually happened: the boxes converge on their own within seconds, |
| 120 | // and this terminal is not watching them do it. Claiming a restart here |
| 121 | // would be the same lie issue #110 was made of, told by the CLI instead of |
| 122 | // a header. |
| 123 | // |
| 124 | // It also does not promise that everything is back up in seconds, because |
| 125 | // that is not how the box behaves: long-running tenants are restarted |
| 126 | // within seconds, while exec-backed instances are STOPPED within seconds |
| 127 | // and respawn on their next request or scheduled run — which for an idle |
| 128 | // app may be a while, and is the correct behaviour, not a delay to |
| 129 | // apologise for. |
| 130 | fmt.Fprintf(stdout, "%s for %s/%s — %s\n", |
| 131 | colorFor(stdout).ok("restart requested"), res.Account, res.App, restartTimingCopy) |
| 132 | } |
| 133 | |
| 134 | // restartUsage is the one place the flags and their precedence are stated for |
| 135 | // somebody at a terminal. Precedence is written down HERE and in |
| 136 | // docs/site/cli.md rather than left to be inferred, because the failure this |
| 137 | // verb was narrowed to prevent is somebody inferring wrongly. |
| 138 | const restartUsage = `usage: carlos restart [--app <a>] [--host <h>] [--environment <e>] [--dry-run] |
| 139 | [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] |
| 140 | |
| 141 | Cycles running processes with no new version and no config change. |
| 142 | |
| 143 | --app alone every instance of the app. |
| 144 | --environment <e> only the routes reading that config bundle — what to |
| 145 | reach for after carlos secrets set --environment <e>. |
| 146 | --host <h> only that one route. |
| 147 | |
| 148 | --host wins over --environment when both are given, and the console refuses |
| 149 | the pair if that host does not actually read that environment: one of the two |
| 150 | is then a typo. A selector matching nothing is refused, never widened. |
| 151 | |
| 152 | --dry-run names the routes the same request would cycle and stops there. |
| 153 | |
| 154 | Signed-in only (carlos auth login).` |
| 155 | |
| 156 | // runRestartScoped is the narrowed and dry-run half: ask the console to |
| 157 | // resolve the selector, then print WHAT IT RESOLVED TO. Naming the set is the |
| 158 | // whole point of the verb having selectors at all — the complaint that got |
| 159 | // them built was that an operator could not tell what the blast radius would |
| 160 | // be, and a narrowed restart that reported no more than the old one would have |
| 161 | // fixed the radius and left the blindness. |
| 162 | func runRestartScoped(sess *apiSession, acct apiAcct, app, host, environment string, dryRun bool, message string) { |
| 163 | res, err := sess.restartScoped(context.Background(), acct, app, host, environment, dryRun, message) |
| 164 | if err != nil { |
| 165 | log.Fatalf("restart: %v", err) |
| 166 | } |
| 167 | c := colorFor(stdout) |
| 168 | what := restartScopeWords(res.Scope.Kind, res.Scope.Value) |
| 169 | if res.Requested { |
| 170 | fmt.Fprintf(stdout, "%s for %s/%s — %s, %d route(s):\n", |
| 171 | c.ok("restart requested"), res.Account, res.App, what, len(res.Routes)) |
| 172 | } else { |
| 173 | // Said FIRST, before the list, so a skimmed answer cannot be mistaken |
| 174 | // for a restart that happened. |
| 175 | fmt.Fprintf(stdout, "%s — nothing was restarted. This would cycle %d route(s) of %s/%s (%s):\n", |
| 176 | c.warn("dry run"), len(res.Routes), res.Account, res.App, what) |
| 177 | } |
| 178 | w := newTable(stdout) |
| 179 | fmt.Fprintln(w, "HOST\tENVIRONMENT\tUNIT\tNOTE") |
| 180 | for _, rt := range res.Routes { |
| 181 | note := "—" |
| 182 | if rt.Hibernate { |
| 183 | // Named rather than filtered out. A route in the set that will not |
| 184 | // cycle now is a fact the operator needs, and "asleep" is the |
| 185 | // correct outcome — it execs fresh when something next wakes it — |
| 186 | // not a failure to apologise for or to hide. |
| 187 | note = "asleep — left as it is, execs fresh on its next wake" |
| 188 | } |
| 189 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Host, dashIfEmpty(rt.Environment), dashIfEmpty(rt.Unit), note) |
| 190 | } |
| 191 | w.Flush() |
| 192 | if res.Requested { |
| 193 | fmt.Fprintln(stdout, restartTimingCopy) |
| 194 | } |
| 195 | // Said every time: this console reads its OWN box's registry, so on a |
| 196 | // multi-box fleet the list is what one box can see. The REQUEST is not |
| 197 | // limited that way — every box serving the app converges on it and applies |
| 198 | // the same selector to its own routes — and conflating the two would be |
| 199 | // its own lie. |
| 200 | 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)") |
| 201 | } |
| 202 | |
| 203 | // restartScopeWords renders a selector as the phrase both halves of the output |
| 204 | // use, so the header and any refusal name the same thing the same way. |
| 205 | func restartScopeWords(kind, value string) string { |
| 206 | switch kind { |
| 207 | case release.RestartScopeHost: |
| 208 | return "host " + value |
| 209 | case release.RestartScopeEnvironment: |
| 210 | return "environment " + value |
| 211 | } |
| 212 | return "the whole app" |
| 213 | } |
| 214 | |
| 215 | // restartTimingCopy is the sentence the app-wide form has printed since #113, |
| 216 | // kept identical here: what "requested" actually means for each shape of |
| 217 | // instance. One string rather than two, because two copies of one sentence is |
| 218 | // how they end up disagreeing. |
| 219 | const restartTimingCopy = "running instances stop within seconds; long-running ones restart, " + |
| 220 | "on-demand ones respawn on their next request (sleeping instances stay asleep and start fresh when next woken)" |
| 221 | |
| 222 | // requireRestartAPISession resolves signed-in mode for restart, refusing |
| 223 | // direct-bucket mode with restart's OWN message — the pattern |
| 224 | // requireDeployAPISession sets, and for its reason: a refusal that names |
| 225 | // another verb's advice sends people somewhere that cannot help them. |
| 226 | // |
| 227 | // There is deliberately no headless equivalent to fall back to. A bucket-mode |
| 228 | // operator asking for a restart already has the box (that is what bucket mode |
| 229 | // means), so the honest answer is the box's own systemctl — not a bucket write |
| 230 | // this CLI could make and no console would announce. |
| 231 | func requireRestartAPISession(consoleFlag string) *apiSession { |
| 232 | sess, err := resolveMode(consoleFlag) |
| 233 | if err != nil { |
| 234 | log.Fatal(err) |
| 235 | } |
| 236 | if sess == nil { |
| 237 | log.Fatal("carlos restart works through a console, and this shell is in direct bucket mode\n" + |
| 238 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). restart is a member verb with no\n" + |
| 239 | "direct-bucket equivalent — the request is a console write the fleet listens for. Unset the\n" + |
| 240 | "store env and run: carlos auth login --console <url>.") |
| 241 | } |
| 242 | return sess |
| 243 | } |
| 244 | |