carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
36package main
37
38import (
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.
50func 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.
138const restartUsage = `usage: carlos restart [--app <a>] [--host <h>] [--environment <e>] [--dry-run]
139 [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]
140
141Cycles 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
149the pair if that host does not actually read that environment: one of the two
150is 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
154Signed-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.
162func 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.
205func 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.
219const 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.
231func 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