carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The `carlos instances` verb family beyond the bare box-side listing:
2//
3// - `enable` — the app's opt-in to instance provisioning (spec §3.1).
4// Two modes, resolved credential-first like ship/promote (apimode.go):
5// store mode writes apps/<sqid>/<app>/instances.json via
6// provision.PutManifest with every field under the operator's hand;
7// API mode (issue #66) asks the console, which pins the manifest's
8// domain to the account's own platform subdomain — so a member can
9// bring an app live on <app>.<sqid>.<apps-domain> with a login alone,
10// and custom domains stay an operator act.
11// - `create` / `list` / `delete` / `set-upstreams` — the CLI front over
12// the console's instance-record verbs (spec §9's "natural follow-up
13// slice", built here because the records were otherwise API-only: the
14// one day-to-day surface with no carlos command). API mode ONLY: a
15// record is a console-authorized write, and the box-side operator
16// equivalent has always been `carlos add`. set-upstreams is the CLI
17// front over the multi-upstream repoint PATCH (HA design, issue #152):
18// `carlos add` remains the box-local, registry-direct way to place a
19// route by hand — this is its console-authorized, member-facing
20// counterpart for repointing an already-provisioned instance's upstream
21// set without a box shell.
22package main
23
24import (
25 "context"
26 "encoding/json"
27 "errors"
28 "flag"
29 "fmt"
30 "log"
31 "net/http"
32 "net/url"
33 "os"
34 "strings"
35
36 "github.com/carlosframework/platform/internal/appconfig"
37 "github.com/carlosframework/platform/internal/provision"
38 "github.com/carlosframework/platform/internal/release"
39 "github.com/carlosframework/platform/internal/rolloutwords"
40 "github.com/carlosframework/platform/internal/updatewords"
41)
42
43const instancesEnableUsage = `usage: carlos instances enable --app <a> [flags]
44
45Signed-in mode (carlos auth login; no CARLOS_DEPLOYMENT_* env): the console
46writes the manifest, and pins its domain to your account's own platform
47subdomain (<sqid>.<apps-domain>). --domain, --pool and --regions are operator,
48store-mode flags and are refused here.
49
50Store mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR set): every field
51is the operator's call, and --domain is required.
52
53`
54
55func runInstancesEnable(args []string) {
56 fs := flag.NewFlagSet("instances enable", flag.ExitOnError)
57 app := appFlag(fs, "app whose instances this enables")
58 domain := fs.String("domain", "", "instance host domain suffix (store mode: required; signed-in mode: refused — the console pins it)")
59 pool := fs.String("pool", "", "pool stamped into every record at create (store mode only)")
60 // Default "" so signed-in mode can tell "unset" from "asked for": an
61 // omitted --channel must preserve whatever the manifest already carries,
62 // not silently reset it to stable. Store mode applies the documented
63 // "stable" default below, so its behaviour is unchanged.
64 channel := channelFlag(fs, "release channel stamped into every record at create (store-mode default stable; signed-in mode: omitted preserves the manifest's)")
65 regions := fs.String("regions", "", "comma-separated region allow-list (store mode only); empty = any shape-valid region")
66 seed := fs.String("seed", "", "argv of the app's first-boot init command, whitespace-split (strings.Fields)")
67 // A bool flag rather than a string, read through flagWasSet for the
68 // same absent-vs-explicit split --channel and --seed use: omitting it
69 // preserves whatever policy the manifest already carries, and
70 // --private-storage=false turns the policy off.
71 privateStorage := fs.Bool("private-storage", false, "give every instance of this app a config environment and an object store of its own (issue #412); --private-storage=false turns the policy off")
72 consoleFlag := consoleOption(fs, "console to enable through (signed-in mode only; default: the only logged-in one)")
73 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
74 fs.Usage = flagUsage(fs, instancesEnableUsage)
75 fs.Parse(args)
76 if *app != "" && !release.ValidApp(*app) {
77 log.Fatalf("carlos instances enable: %q is not a valid app name", *app)
78 }
79 sess, err := resolveMode(*consoleFlag)
80 if err != nil {
81 log.Fatal(err)
82 }
83 if sess != nil {
84 // Signed-in mode. The three operator fields are refused rather than
85 // ignored — a flag that silently did nothing would read as policy
86 // applied.
87 for _, f := range []string{"domain", "pool", "regions"} {
88 if flagWasSet(fs, f) {
89 log.Fatalf("carlos instances enable: -%s is an operator, store-mode flag — the console pins the manifest to your account's platform subdomain. Set CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR to enable in store mode instead", f)
90 }
91 }
92 var channelReq *string
93 if flagWasSet(fs, "channel") {
94 channelReq = channel
95 }
96 var privateStorageReq *bool
97 if flagWasSet(fs, "private-storage") {
98 privateStorageReq = privateStorage
99 }
100 var seedReq *[]string
101 if flagWasSet(fs, "seed") {
102 // parseSeed("") is an empty (non-nil after this) argv: an explicit
103 // --seed "" clears the manifest's seed, matching --channel's
104 // explicit-vs-absent split.
105 argv := parseSeed(*seed)
106 if argv == nil {
107 argv = []string{}
108 }
109 seedReq = &argv
110 }
111 printUsing(stdout, sess)
112 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
113 app2 := resolvedApp(sess, acct, *app, instancesEnableUsage)
114 m, err := sess.instancesEnable(context.Background(), acct, app2, channelReq, seedReq, privateStorageReq)
115 if err != nil {
116 log.Fatalf("instances enable: %v", err)
117 }
118 fmt.Fprintf(stdout, "%s for %s under %s\n", colorFor(stdout).ok("enabled instances"), m.App, m.Domain)
119 // The policy IN FORCE, from the door's echo, not the flag that was
120 // typed: an enable that never mentioned storage still prints what
121 // the app does, which is the fact the next create will act on.
122 if m.PrivateStorage {
123 mutedf(stdout, " every instance gets its own config environment and object store\n")
124 }
125 mutedf(stdout, " first instance: carlos instances create --app %s --host <label>.%s\n", m.App, m.Domain)
126 return
127 }
128
129 // Store mode — the operator path, unchanged: an ABSENT --channel takes
130 // the documented "stable" default, while an explicitly empty one stays
131 // the "not a valid channel" refusal it has always been (the flag's
132 // default moved from "stable" to "" for the signed-in mode's
133 // preserve-vs-replace split; flagWasSet is what keeps the two cases
134 // apart, and it must be flagWasSet, not a raw fs.Visit set: Visit
135 // reports the spelling typed, so -c would read as "absent" and the
136 // user's explicit channel would be silently dropped).
137 if *app == "" {
138 log.Fatal(instancesEnableUsage)
139 }
140 if *domain == "" {
141 log.Fatal(instancesEnableUsage)
142 }
143 if *channel == "" && !flagWasSet(fs, "channel") {
144 *channel = "stable"
145 }
146 // A domain is dot-joined labels with no suffix of its own to check
147 // against (unlike a host, which is validated against ITS domain by
148 // provision.ValidHost) — ValidHostShape is exactly that shape check,
149 // refusing a hostile domain before it is ever stamped into a manifest
150 // every future host gets checked against.
151 if !provision.ValidHostShape(*domain) {
152 log.Fatalf("carlos instances enable: %q is not a valid domain", *domain)
153 }
154 if !provision.ValidPool(*pool) {
155 log.Fatalf("carlos instances enable: %q is not a valid pool", *pool)
156 }
157 if !release.ValidChannel(*channel) {
158 log.Fatalf("carlos instances enable: %q is not a valid channel", *channel)
159 }
160 var regionList []string
161 if *regions != "" {
162 for _, r := range strings.Split(*regions, ",") {
163 r = strings.TrimSpace(r)
164 if r == "" || !provision.ValidRegion(r) {
165 log.Fatalf("carlos instances enable: %q is not a valid region", r)
166 }
167 regionList = append(regionList, r)
168 }
169 }
170 m := provision.Manifest{
171 Schema: 1,
172 App: *app,
173 Domain: *domain,
174 Pool: *pool,
175 Channel: *channel,
176 Regions: regionList,
177 Seed: parseSeed(*seed),
178 PrivateStorage: *privateStorage,
179 }
180 if err := instancesEnable(context.Background(), openStore(), acctOf(), *app, m); err != nil {
181 log.Fatal(err)
182 }
183 fmt.Printf("%s for %s under %s\n", colorFor(os.Stdout).ok("enabled instances"), *app, *domain)
184 if *privateStorage {
185 // Store mode writes the manifest straight to the bucket, so nothing
186 // checked that this deployment HAS object storage or that the
187 // account is approved for it — the console's enable door does both
188 // and refuses, but the operator path has no console to ask. Named
189 // here so the policy is not discovered to be inert at the first
190 // signup.
191 mutedf(os.Stdout, " every instance gets its own config environment and object store\n")
192 mutedf(os.Stdout, " the console minting them needs CARLOS_STORE_BACKEND and an approved store entitlement for this account\n")
193 }
194}
195
196// parseSeed splits --seed's raw value on whitespace (strings.Fields) into the
197// manifest's Seed argv. Shell-word semantics — quoting, escaping, anything
198// beyond a flat whitespace-separated argv — are deliberately NOT supported:
199// this flag is shell-word-free by contract, and anything richer belongs in
200// the app's own first-boot init command, not in this flag's parsing.
201func parseSeed(raw string) []string {
202 return strings.Fields(raw)
203}
204
205// instancesEnable is the core store-mode `carlos instances enable` writes
206// through — separated from flag parsing/validation above so a test can call
207// it directly against a release.Store, the same shape provision_test.go's
208// TestManifestRoundTrip exercises PutManifest with.
209func instancesEnable(ctx context.Context, s release.Store, acct, app string, m provision.Manifest) error {
210 return provision.PutManifest(ctx, s, acct, app, m)
211}
212
213var instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> [--region <r>] [--channel <c>]
214 [--kind instance|service|static] [--environment <e>]
215 [--addr <a:p,...>] [--health-path <p>] [--health-status <n>]
216 carlos instances list --app <a> [--match <substr>] [--health <slug>]
217 carlos instances delete --app <a> --host <h>
218 carlos instances set-upstreams --app <a> --host <h> [--addrs <a:p,...>]
219 [--health-path <p>] [--health-status <n>]
220 carlos instances set-channel --app <a> --host <h> --channel <c>
221 carlos instances set-environment --app <a> --host <h> --environment <e>
222 carlos instances retry --app <a> <host>
223` + updatewords.Text("settings.cli.instances-usage-lines") + `
224
225Signed-in only (carlos auth login): instance records are console-authorized
226writes — the console checks the host against the app's manifest and the
227box's reconcile pass materializes the route.
228
229create stamps a channel on the record, and prints the one it chose. Named
230with --channel it is yours; omitted, it is the app's own enable channel, else
231the channel this app's other instances already follow, else its pipeline
232entry — where "carlos deploy" lands, so a brand-new app's first instance is
233deployable without a passkey. Instances that follow SEVERAL channels are
234refused rather than guessed at: pass --channel.
235
236set-channel moves ONE route onto a different release channel. The channel
237lives in the instance record, which the box converges the route from every
238pass — so this is the verb that changes it, not "carlos route --channel",
239which edits a row the next reconcile overwrites. A move that the app's
240pipeline gates is proposed and finished in the console, like a promote.
241
242retry clears the failure hold on one instance: the build that failed on its
243box is tried again there, as a canary. "carlos promote --retry <version>
244<channel>" does the same on every box.
245
246` + updatewords.Text("settings.cli.instances-usage-para") + `
247
248--environment binds a route to one of the app's named config bundles
249("carlos env environments" lists them; "carlos env --environment <e>" writes
250them). Omitted, a route serves the app's default bundle — which matters most
251when a route stands ALONGSIDE a live one: config is per-app, so a second
252instance inherits values naming the first one's host, and anything the app
253keys off a single origin (the Origin header it checks, magic-link URLs, a
254WebAuthn RP ID, the cookie Secure flag) fails closed on the new host while
255GETs look healthy. set-environment rebinds an existing route the same way,
256and for the same reason set-channel exists rather than "carlos route
257--environment": the reconciler reverts the box-side edit within seconds.
258Restart the instance afterwards to pick the new values up.
259
260set-upstreams-only flags:
261
262 --addrs <a:p,...> comma-separated TCP upstream set, host:port each,
263 replacing the record's whole upstream set;
264 omitted leaves the existing set alone, an
265 explicit --addrs "" clears it to empty (refused
266 with a 400 for a kind:instance record — emptying
267 an addr-backed instance route reverts it to a
268 socket-backed shape, and the DerivesPaths rail
269 refuses that transition in place; delete and
270 re-create instead)
271 --health-path <p> states the WHOLE probe block, replacing whatever
272 was there — NOT a per-field update: a field
273 neither flag names reverts to the platform's own
274 default, even if a prior probe had set it
275 --health-status <n> probe's expected response status; --health-path
276 and --health-status may be given together or
277 alone — either one, given at all, replaces the
278 whole probe (omitting both leaves the existing
279 probe untouched)
280
281Operators placing routes by hand on a box use carlos add, not this.
282`
283
284// requireAPISession resolves signed-in mode for the record verbs, refusing
285// direct mode by name: there IS no store-mode record write (the console is
286// the door, spec §9), and the operator equivalent is a different verb.
287func requireAPISession(verb, consoleFlag string) *apiSession {
288 sess, err := resolveMode(consoleFlag)
289 if err != nil {
290 log.Fatal(err)
291 }
292 if sess == nil {
293 log.Fatalf("carlos instances %s works through a console, and this shell is in direct bucket mode\n"+
294 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Instance records are\n"+
295 "console-authorized writes; unset the store env and run: carlos auth login --console <url>.\n"+
296 "To place a route by hand on THIS box, use carlos add instead", verb)
297 }
298 return sess
299}
300
301func runInstancesCreate(args []string) {
302 fs := flag.NewFlagSet("instances create", flag.ExitOnError)
303 messageFlags := addMutationMessageFlags(fs)
304 app := appFlag(fs, "app the instance belongs to")
305 host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
306 region := fs.String("region", "", flagRegionInstancesCreate)
307 channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry)")
308 kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process")
309 environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them")
310 addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream")
311 healthPath := fs.String("health-path", "", "states the whole probe alongside --health-status: request path (default \"/\"); honoured for a single --addr too")
312 healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults")
313 consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)")
314 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
315 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
316 role := fs.String("role", "", "instance role (default: the serving home); \"replica\" provisions a second instance of the account holding a one-way mirror of it, selling near the buyer — needs --region naming the replica's placement")
317 from := fs.String("from", "", "copy this instance's database into the new one. The source must be an instance of the same app, in this console's region")
318 at := fs.Int64("at", 0, "with --from, the transaction id to copy at (default: the latest)")
319 replicas := fs.String("replicas", "", "comma-separated placement codes the account's replicas live in (the HOME record's opt-in; e.g. ie,uk) — this instance's own replica must be --role replica with --region")
320 fs.Usage = flagUsage(fs, instancesRecordsUsage)
321 fs.Parse(args)
322 if *host == "" {
323 log.Fatal(instancesRecordsUsage)
324 }
325 set := map[string]bool{}
326 fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
327 addrs := parseAddrList(*addr, set["addr"])
328 probe := parseProbeFlags(*healthPath, *healthStatus, set["health-path"], set["health-status"])
329 if set["at"] && (*at <= 0 || *from == "") {
330 log.Fatalf("instances create: %s", "at must be a transaction id, a whole number above 0")
331 }
332 if *from != "" && (*kind != "" || set["addr"] || *role != "" || *replicas != "") {
333 log.Fatalf("instances create: %s", "a seeded instance is an ordinary instance. Drop --kind, --addr and --role.")
334 }
335 sess := requireAPISession("create", *consoleFlag)
336 printUsing(stdout, sess)
337 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
338 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
339 message, merr := resolveMutationMessage(fs, messageFlags)
340 if merr != nil {
341 log.Fatalf("instances create: %v", merr)
342 }
343 if *from != "" {
344 if err := sess.runSeededCreate(context.Background(), stdout, acct, app2, strings.TrimSpace(*host), strings.TrimSpace(*region),
345 strings.TrimSpace(*channel), strings.TrimSpace(*environment), strings.TrimSpace(*from), uint64(*at), message); err != nil {
346 log.Fatalf("instances create: %v", err)
347 }
348 return
349 }
350 rec, err := sess.instanceCreate(context.Background(), acct, app2,
351 strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel),
352 strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe,
353 replicaReqFromFlags(*role, *replicas, set["region"]), message)
354 if err != nil {
355 log.Fatalf("instances create: %v", err)
356 }
357 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("created instance"),
358 instanceCreatedLine(rec, environmentSuffix(rec.Environment)))
359 if rec.PrivateStoreBucket != "" {
360 mutedf(stdout, " private storage: bucket %s\n", rec.PrivateStoreBucket)
361 }
362 mutedf(stdout, " the host comes live on the next reconcile pass on its box")
363}
364
365// seedCopyingLine is what `instances create --from` says when the console
366// has reserved the record and the copy begins.
367func seedCopyingLine(source, host string) string {
368 return fmt.Sprintf("copying %s into %s. A large database can take several minutes.", source, host)
369}
370
371// instanceCreatedLine is the text after "created instance" or "canary
372// host": today's "host (channel X<env>)" byte for byte from an older
373// console, C14 when the console names the place.
374func instanceCreatedLine(rec apiInstanceRecord, envSuffix string) string {
375 if rec.RegionName == "" {
376 return fmt.Sprintf("%s (channel %s%s)", rec.Host, rec.Channel, envSuffix)
377 }
378 return fmt.Sprintf("%s (%s%s)", rec.Host, instanceCreatedSuffix(rec.Channel, rec.RegionName), envSuffix)
379}
380
381// replicaReqFromFlags builds the create body's replica slice from the CLI's
382// --role/--replicas pair, or nil when neither flag was given — a nil request
383// sends neither key, byte-identical to every create before #162 existed.
384// The refusals mirror the console door's (checkReplicaCreate): a replica's
385// placement IS its --region, the placement set is the HOME record's opt-in
386// and rides a PLAIN create, and a replica never carries a set of its own.
387// Refusing here names what is missing without a console round trip.
388func replicaReqFromFlags(role, replicas string, regionGiven bool) *instanceReplicaReq {
389 if role == "" && replicas == "" {
390 return nil
391 }
392 if role != "" && replicas != "" {
393 log.Fatal("instances create: --role replica with --replicas: the placement set is the HOME record's opt-in — a replica does not carry one (drop --replicas, or drop --role)")
394 }
395 if role == "" {
396 return &instanceReplicaReq{Set: parseAddrList(replicas, true)}
397 }
398 if !regionGiven {
399 log.Fatal("instances create: --role replica needs --region naming the replica's placement")
400 }
401 return &instanceReplicaReq{Role: role}
402}
403
404// parseAddrList splits a comma-separated --addr/--addrs flag into the wire's
405// []string, trimming whitespace and dropping empty elements the same way
406// --regions' comma-split does (instances_cli.go's runInstancesEnable). given
407// is fs.Visit's "was this flag on the command line at all" — an absent flag
408// must return nil (the wire's "leave/default", not an empty, explicit list),
409// which a bare `raw == ""` check cannot tell apart from `--addr ""`.
410func parseAddrList(raw string, given bool) []string {
411 if !given {
412 return nil
413 }
414 var out []string
415 for _, a := range strings.Split(raw, ",") {
416 if a = strings.TrimSpace(a); a != "" {
417 out = append(out, a)
418 }
419 }
420 return out
421}
422
423// parseProbeFlags builds the wire's optional probe block from --health-path/
424// --health-status: nil when NEITHER flag was given (leave/default, matching
425// parseAddrList's given-vs-absent split), populated when either was — a
426// caller may set only the path, only the status, or both, and this is where
427// that distinction is made before it reaches the request struct's `omitempty`
428// pointer.
429func parseProbeFlags(path string, status int, pathGiven, statusGiven bool) *instanceProbeReq {
430 if !pathGiven && !statusGiven {
431 return nil
432 }
433 return &instanceProbeReq{Path: strings.TrimSpace(path), Status: status}
434}
435
436// upstreamsCell renders the joined per-upstream probe state as "2/3"
437// (up/total) — "-" when the record carries none, which is the ordinary case
438// for a single-upstream record (the join is omitempty on the wire, so a
439// pre-Task-8 console and a plain single-upstream record both decode to a nil
440// slice here) and for a talking-to-an-old-console read.
441func upstreamsCell(ups []apiUpstreamState) string {
442 if len(ups) == 0 {
443 return "-"
444 }
445 up := 0
446 for _, u := range ups {
447 if u.Up {
448 up++
449 }
450 }
451 return fmt.Sprintf("%d/%d", up, len(ups))
452}
453
454const (
455 instancesListHeader = "HOST\tREGION\tCHANNEL\tPOOL\tINSTALLED\tSERVING\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY\tREASON"
456 instancesListRetryHint = "a build that failed is tried again on every box with carlos promote --app %s --retry <version> <channel>, or on one instance with carlos instances retry --app %s <host>"
457)
458
459// installedCell is the build the instance's box has installed: the rollout
460// report's when there is one, else the staged version VERSION always showed.
461func installedCell(r apiInstanceRecord) string {
462 if r.Rollout != nil && r.Rollout.Installed != "" {
463 return r.Rollout.Installed
464 }
465 return r.StagedVersion
466}
467
468// servingCell is the build answering requests, or a dash where no box
469// reports one: an older console, a box with CARLOS_SWAP off, or a box whose
470// report is unavailable.
471func servingCell(r apiInstanceRecord) string {
472 if r.Rollout == nil || r.Rollout.Unavailable != "" || r.Rollout.Serving == "" {
473 return "—"
474 }
475 return r.Rollout.Serving
476}
477
478// rolloutReason is the REASON cell (spec §9.1): why the serving build is not
479// the installed one, or that new config waits for a process (§8.1). A box that
480// cannot arm its scheduler says why first: its other fields are meaningless.
481// An asleep instance serves nothing (plan 5 reports Serving "" for it), so its
482// installed build is compared with Good, the build it last ran.
483func rolloutReason(r *apiRollout) string {
484 switch {
485 case r == nil:
486 return ""
487 case r.Unavailable != "":
488 return rolloutwords.Unavailable(r.Unavailable)
489 case r.HeldBy != "":
490 return fmt.Sprintf(rolloutwords.StateHeld, r.HeldBy)
491 case !r.Awake && r.Installed != r.Good:
492 return fmt.Sprintf(rolloutwords.StateAsleep, r.Installed)
493 case r.Awake && r.Installed != r.Serving:
494 return rolloutwords.StateRollingOut
495 case r.ConfigPending:
496 return rolloutwords.StateConfigPending
497 }
498 return ""
499}
500
501// updatesReason is the settings half of REASON (spec §5): the EFFECTIVE
502// reason the instance's box reports (Astra I1 #12), whether the record or the
503// app's default set it: manual, pinned, held at major N (the number from the
504// report), and the other reasons root reports, with a request waiting on bake
505// named beside the reason, never instead of it. A reason word from a newer box
506// renders nothing: updatewords.Text panics on an id nobody registered.
507func updatesReason(u *apiUpdatesJoin, settingsDefault bool) string {
508 if u == nil {
509 return ""
510 }
511 if !u.Enforced {
512 if settingsDefault {
513 return ""
514 }
515 return updatewords.Text("settings.cli.not-enforced-reason")
516 }
517 var r string
518 switch u.Reason {
519 case "major":
520 n := 0
521 if u.Major != nil {
522 n = *u.Major
523 }
524 r = fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail)
525 case "unknown", "new":
526 r = updatewords.Text("settings.box.reason." + u.Reason)
527 case "pin", "request", "manual", "failed":
528 r = fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail)
529 }
530 if u.Baking != "" {
531 b := fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), u.Baking)
532 if r == "" {
533 return b
534 }
535 return r + "; " + b
536 }
537 return r
538}
539
540// reasonCell is the REASON column: a rollout reason first (a failure hold
541// outranks a preference), else the settings reason.
542func reasonCell(r apiInstanceRecord) string {
543 if s := rolloutReason(r.Rollout); s != "" {
544 return s
545 }
546 return updatesReason(r.Updates, r.SettingsDefault)
547}
548
549func anyHeld(recs []apiInstanceRecord) bool {
550 for _, r := range recs {
551 if r.Rollout != nil && r.Rollout.HeldBy != "" {
552 return true
553 }
554 }
555 return false
556}
557
558func runInstancesList(args []string) {
559 fs := flag.NewFlagSet("instances list", flag.ExitOnError)
560 app := appFlag(fs, "app whose instance records to list")
561 match := fs.String("match", "", "only hosts containing this substring")
562 health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed")
563 consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)")
564 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
565 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
566 fs.Usage = flagUsage(fs, instancesRecordsUsage)
567 fs.Parse(args)
568 sess := requireAPISession("list", *consoleFlag)
569 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
570 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
571 recs, truncated, total, err := sess.instanceList(context.Background(), acct, app2, *match, *health)
572 if err != nil {
573 log.Fatalf("instances list: %v", err)
574 }
575 w := newTable(stdout)
576 fmt.Fprintln(w, instancesListHeader+"\t"+"SEED")
577 for _, r := range recs {
578 rowHealth := r.Health
579 if rowHealth == "" {
580 rowHealth = "—" // an old console: the field predates it, not "unhealthy"
581 }
582 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool,
583 installedCell(r), servingCell(r), rowHealth, upstreamsCell(r.Upstreams), humanBytes(r.DBBytes),
584 r.CreatedAt, r.CreatedBy, reasonCell(r), seedCell(r))
585 }
586 w.Flush()
587 // Spec §8.4: a held build is retried everywhere by `promote --retry`, or
588 // on one instance by `instances retry`. Said once, under the table, only
589 // when something is held.
590 if anyHeld(recs) {
591 mutedf(stdout, instancesListRetryHint, app2, app2)
592 }
593 if truncated {
594 mutedf(stdout, "(truncated at 500 records)")
595 }
596 // total is the server's count of every record matching --match/--health, not
597 // just the page returned above — worth a line only when a filter is
598 // active AND the console actually reported one (a pre-Task-9 console
599 // sends no "total" field, which decodes as 0; treated as unknown here
600 // rather than "zero matches", since len(recs) itself already proves that
601 // wrong whenever recs is non-empty).
602 if (*match != "" || *health != "") && total > len(recs) {
603 mutedf(stdout, "(showing %d of %d matches)", len(recs), total)
604 }
605}
606
607func runInstancesDelete(args []string) {
608 fs := flag.NewFlagSet("instances delete", flag.ExitOnError)
609 messageFlags := addMutationMessageFlags(fs)
610 app := appFlag(fs, "app the instance belongs to")
611 host := fs.String("host", "", "instance host whose record to retire")
612 consoleFlag := consoleOption(fs, "console to delete through (default: the only logged-in one)")
613 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
614 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
615 fs.Usage = flagUsage(fs, instancesRecordsUsage)
616 fs.Parse(args)
617 if *host == "" {
618 log.Fatal(instancesRecordsUsage)
619 }
620 sess := requireAPISession("delete", *consoleFlag)
621 printUsing(stdout, sess)
622 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
623 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
624 message, merr := resolveMutationMessage(fs, messageFlags)
625 if merr != nil {
626 log.Fatalf("instances delete: %v", merr)
627 }
628 if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host), message); err != nil {
629 log.Fatalf("instances delete: %v", err)
630 }
631 fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n",
632 colorFor(stdout).ok("deleted instance"), *host)
633}
634
635// runInstancesSetUpstreams is `carlos instances set-upstreams` — the CLI
636// front over handleAPIInstanceUpdate's PATCH (Task 7, issue #152): repoint a
637// live instance's upstream set and/or its probe without a box shell. Unlike
638// create/list/delete this is an update to an ALREADY-provisioned record, so
639// there is deliberately no --region/--channel here — those have their own
640// doors (placement, and there is none for channel via this record; see
641// spec §9).
642//
643// Neither --addrs nor a --health-* flag is required by this parser: the
644// console's own refusal ("nothing to update — send addrs, probe, steering,
645// or a combination") is left to fire when a caller gives neither, so that
646// refusal's wording
647// reaches the operator verbatim rather than being paraphrased here — the
648// self-review rule this task carries (ValidateShape's own sentences are
649// operator guidance, not something to summarize).
650func runInstancesSetUpstreams(args []string) {
651 fs := flag.NewFlagSet("instances set-upstreams", flag.ExitOnError)
652 app := appFlag(fs, "app the instance belongs to")
653 host := fs.String("host", "", "instance host whose upstream set to repoint")
654 addrsFlag := fs.String("addrs", "", "comma-separated TCP upstream set, host:port each, replacing the record's whole upstream set; omitted leaves the existing set alone; an explicit --addrs \"\" clears it (400 for a kind:instance record — the DerivesPaths rail refuses that shape transition in place)")
655 healthPath := fs.String("health-path", "", "states the WHOLE probe block, replacing whatever was there — not a per-field update; a field neither flag names reverts to the platform's own default")
656 healthStatus := fs.Int("health-status", 0, "probe's expected response status; either --health-path or --health-status, given at all, replaces the whole probe")
657 consoleFlag := consoleOption(fs, "console to update through (default: the only logged-in one)")
658 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
659 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
660 fs.Usage = flagUsage(fs, instancesRecordsUsage)
661 fs.Parse(args)
662 if *host == "" {
663 log.Fatal(instancesRecordsUsage)
664 }
665 set := map[string]bool{}
666 fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
667 var addrs *[]string
668 if set["addrs"] {
669 list := parseAddrList(*addrsFlag, true)
670 if list == nil {
671 // --addrs given but empty (or all-whitespace) — the wire's explicit
672 // "clear the set" spelling ("addrs":[]), not "leave it alone"
673 // (which is what a nil *[]string would mean). parseAddrList's
674 // given=true already tells nil from absent for this caller;
675 // this turns that nil into the non-nil empty slice the PATCH
676 // door requires to see a clear rather than a no-op.
677 list = []string{}
678 }
679 addrs = &list
680 }
681 probe := parseProbeFlags(*healthPath, *healthStatus, set["health-path"], set["health-status"])
682 sess := requireAPISession("set-upstreams", *consoleFlag)
683 printUsing(stdout, sess)
684 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
685 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
686 rec, err := sess.instanceSetUpstreams(context.Background(), acct, app2,
687 strings.TrimSpace(*host), addrs, probe)
688 if err != nil {
689 log.Fatalf("instances set-upstreams: %v", err)
690 }
691 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("updated upstreams for"), rec.Host)
692 mutedf(stdout, " the change lands on its box within seconds of the next converge tick")
693}
694
695// requireSteeringAPISession resolves signed-in mode for `carlos steering`,
696// refusing direct-bucket mode with THIS verb's own name in the message — the
697// requireRestartAPISession precedent (restart.go), not requireAPISession's:
698// steering is a top-level verb ("carlos steering", not "carlos instances
699// steering"), so requireAPISession's hardcoded "carlos instances %s" would
700// name a command that does not exist.
701func requireSteeringAPISession(consoleFlag string) *apiSession {
702 sess, err := resolveMode(consoleFlag)
703 if err != nil {
704 log.Fatal(err)
705 }
706 if sess == nil {
707 log.Fatal("carlos steering works through a console, and this shell is in direct bucket mode\n" +
708 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Steering is a console-authorized\n" +
709 "write, the same family instance records belong to; unset the store env and run:\n" +
710 "carlos auth login --console <url>.")
711 }
712 return sess
713}
714
715const steeringUsage = `usage: carlos steering --app <a> --host <h> latency|off
716
717Sets an instance's DNS steering opt-in — a top-level verb, not an
718"instances" sub-verb, because unlike set-upstreams this is not repointing a
719route's upstreams: it decides whether the host's DNS answer varies by
720requester at all.
721
722 latency opt into a Route53 latency record per armed edge — the nearest
723 edge answers, instead of today's single static answer
724 off clear the opt-in — back to today's static per-host answer
725
726v1 constraint: steering requires the shared pool (--pool "" on the app's
727instances manifest). A pool-scoped route is refused with a 409 naming this —
728pool-scoped steering is a recorded future, not v1.
729
730Signed-in only (carlos auth login): steering is a console-authorized write,
731the same family create/list/delete/set-upstreams belong to.
732
733SINGLE-BOX INERT: setting this field changes nothing on its own. It reaches
734the record and the registry row and stops there until a deployment arms the
735Route53 steering converger (CARLOS_STEERING_ZONE_ID) — this verb states the
736opt-in, it does not turn steering ON for the fleet.
737`
738
739// runSteering is `carlos steering` — the CLI front over
740// handleAPIInstanceUpdate's PATCH steering field (Task 3,
741// multi-region-steering plan). It follows set-upstreams' shape (signed-in
742// only, the same --app/--host/--account/--console flags) but resolves its
743// session through requireSteeringAPISession rather than requireAPISession,
744// since it is a top-level verb, not an "instances" sub-verb — see that
745// function's own comment. The one other difference: the value is a bare
746// trailing word, "latency" or "off", rather than a flag — this door has no
747// "leave it alone" spelling to protect with a flag/no-flag distinction, so a
748// positional argument reads plainer at the terminal than an always-required
749// -steering flag would.
750//
751// The refusal text — the pool 409 or ValidateShape's 400 — is surfaced
752// verbatim, exactly as set-upstreams' refusals are: it names the only thing
753// an operator can act on (the constraint, or the bad value), and paraphrasing
754// it here would throw that away.
755func runSteering(args []string) {
756 fs := flag.NewFlagSet("steering", flag.ExitOnError)
757 app := appFlag(fs, "app the instance belongs to")
758 host := fs.String("host", "", "instance host to steer")
759 consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
760 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
761 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
762 fs.Usage = flagUsage(fs, steeringUsage)
763 fs.Parse(args)
764 if *host == "" || fs.NArg() != 1 {
765 log.Fatal(steeringUsage)
766 }
767 var steering string
768 switch fs.Arg(0) {
769 case "latency":
770 steering = "latency"
771 case "off":
772 steering = ""
773 default:
774 log.Fatal(steeringUsage)
775 }
776 sess := requireSteeringAPISession(*consoleFlag)
777 printUsing(stdout, sess)
778 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
779 app2 := resolvedApp(sess, acct, *app, steeringUsage)
780 rec, err := sess.instanceSetSteering(context.Background(), acct, app2, strings.TrimSpace(*host), steering)
781 if err != nil {
782 log.Fatalf("steering: %v", err)
783 }
784 if steering == "" {
785 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("steering turned off for"), rec.Host)
786 } else {
787 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("steering set to latency for"), rec.Host)
788 }
789 mutedf(stdout, " the record and its registry row update within seconds of the next converge tick;")
790 mutedf(stdout, " the fleet only ACTS on this once a deployment arms its Route53 steering converger")
791}
792
793// runInstancesSetChannel is `carlos instances set-channel` — move one route
794// onto a different release channel, the door that did not exist when
795// hello.bdn landed on stable and could not be moved off it without a raw
796// bucket write.
797//
798// The record is where a route's channel LIVES (the provisioning reconciler
799// converges the registry row from it every pass), which is why this is an
800// instances verb and not `carlos route --channel`: that one edits a row the
801// next reconcile overwrites, and says so.
802func runInstancesSetChannel(args []string) {
803 fs := flag.NewFlagSet("instances set-channel", flag.ExitOnError)
804 app := appFlag(fs, "app the instance belongs to")
805 host := fs.String("host", "", "instance host whose channel to change")
806 channel := channelFlag(fs, "release channel the route should follow")
807 consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
808 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
809 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
810 fs.Usage = flagUsage(fs, instancesRecordsUsage)
811 fs.Parse(args)
812 if *host == "" || *channel == "" {
813 log.Fatal(instancesRecordsUsage)
814 }
815 // Records and routes are keyed lowercase, so a host typed with capitals
816 // would miss both and read as a typo (#264).
817 *host = strings.ToLower(strings.TrimSpace(*host))
818 sess := requireAPISession("set-channel", *consoleFlag)
819 printUsing(stdout, sess)
820 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
821 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
822 if err := setInstanceChannel(context.Background(), sess, acct, app2, *host, *channel); err != nil {
823 log.Fatal(err)
824 }
825}
826
827// setInstanceChannel is the channel move both `carlos instances set-channel`
828// and `carlos instances set --channel` make, through the one door. A gated
829// move is not a failure: the change is recorded and waiting for people, so it
830// returns nil after naming where to finish, the shape `carlos deploy` uses
831// for a proposed promote. Any other refusal comes back as the error to print.
832func setInstanceChannel(ctx context.Context, sess *apiSession, acct apiAcct, app2, host, channel string) error {
833 host, channel = strings.TrimSpace(host), strings.TrimSpace(channel)
834 rec, err := sess.instanceSetChannel(ctx, acct, app2, host, channel)
835 if err != nil {
836 var prop *apiProposedError
837 if errors.As(err, &prop) {
838 fmt.Fprintf(stdout, "moving %s to %s is proposed and needs %d confirmation(s) at %s\n",
839 host, channel, prop.Needed, prop.ConsoleURL)
840 return nil
841 }
842 err = recordNotFound(ctx, sess, acct, app2, host, err, func(h string) string {
843 return fmt.Sprintf("%s is a box route with no instance record, so the console has nothing to move. until it is adopted as an instance, move it on its box: carlos route --host %s --channel %s", h, h, channel)
844 })
845 return fmt.Errorf("instances set-channel: %v", err)
846 }
847 fmt.Fprintf(stdout, "%s %s to %s\n", colorFor(stdout).ok("moved"), rec.Host, rec.Channel)
848 if rec.Kind == "static" {
849 // The one wait worth naming: a static route trusts a resolved
850 // channel pointer for a while before re-reading it, so the URL can
851 // lag the record.
852 mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n",
853 int(staticRecheckWindow.Seconds()))
854 } else {
855 mutedf(stdout, " the change lands on its box within seconds of the next converge tick")
856 }
857 return nil
858}
859
860// recordNotFound narrows the membership 404 a record write answers when its
861// host names no record (#264). The console answers "no such record" and "not
862// your app" with the same bytes on purpose (handleAPIInstanceUpdate), so the
863// write alone cannot tell them apart. The app's route list can: it sits
864// behind the same membership gate, so an answer from it proves the account,
865// app and membership right, and leaves the record as the thing missing.
866//
867// A host the list carries is a box route with no instance record, such as
868// one a tenant's own tooling writes into the box registry. Those are rebound
869// on the box until #261's adoption door gives them a
870// record, and boxRoute says how, except for a platform canary, which
871// `carlos route` refuses on both axes because the domains reconciler
872// rebuilds its row. A host the list does not carry is most likely a typo,
873// but the list is only the console's own box, so the message says no more
874// than that. When the list does not answer either, the account or app
875// really may be wrong, and the membership message stands.
876func recordNotFound(ctx context.Context, sess *apiSession, a apiAcct, app, host string, err error, boxRoute func(host string) string) error {
877 if !errors.Is(err, errMembershipNotFound) {
878 return err
879 }
880 rows, _, lerr := sess.routeList(ctx, a, app)
881 if lerr != nil {
882 return err
883 }
884 for _, r := range rows {
885 if r.Host != host {
886 continue
887 }
888 if r.PlatformCanary {
889 return fmt.Errorf("%s is a canary route the platform mints for %s, and the platform sets its channel and environment. neither can be changed by hand", host, app)
890 }
891 return errors.New(boxRoute(host))
892 }
893 return fmt.Errorf("%s has no instance named %s, and this console's box has no route by that name. your account and app are fine. carlos instances list --app %s shows its instances", app, host, app)
894}
895
896// environmentSuffix renders a record's config binding for a one-line
897// confirmation, and renders NOTHING for the default bundle. The empty case
898// is the common one and naming it ("environment default") on every create
899// would train people to skim the parenthetical that matters.
900func environmentSuffix(env string) string {
901 if strings.TrimSpace(env) == "" {
902 return ""
903 }
904 return ", environment " + env
905}
906
907// runInstancesSetEnvironment is `carlos instances set-environment` — rebind
908// ONE route to a named config bundle.
909//
910// This is the door that did not exist until 2026-09-04, and its absence had
911// a specific shape. Config in CARLOS is per-app, so a second instance
912// standing alongside a live one inherits the default bundle. For any app
913// whose framework keys behaviour off a single origin scalar — the Origin
914// header it compares against, the magic-link base URL, the WebAuthn RP ID,
915// the cookie Secure flag — that bundle names the OTHER host, and every POST
916// to the new one fails closed while every GET looks perfectly healthy.
917//
918// It is an instances verb and not `carlos route --environment` for exactly
919// the reason set-channel is: the record is where a provisioned route's
920// Environment lives, and convergeOwned (internal/provision/reconcile.go)
921// reverts a box-side edit within seconds of the next serial bump — that
922// function's own doc comment names `carlos route -environment` as the write
923// it undoes.
924//
925// An explicit empty --environment is a real request (return this route to
926// the app's default bundle), so the flag's PRESENCE is what is required
927// here, never a non-empty value.
928func runInstancesSetEnvironment(args []string) {
929 fs := flag.NewFlagSet("instances set-environment", flag.ExitOnError)
930 app := appFlag(fs, "app the instance belongs to")
931 host := fs.String("host", "", "instance host whose config binding to change")
932 environment := fs.String("environment", "", "named config bundle to bind to; empty returns the route to the app's default bundle")
933 consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
934 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
935 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
936 fs.Usage = flagUsage(fs, instancesRecordsUsage)
937 fs.Parse(args)
938 if *host == "" || !flagWasSet(fs, "environment") {
939 log.Fatal(instancesRecordsUsage)
940 }
941 // Lowercased for set-channel's reason.
942 *host = strings.ToLower(strings.TrimSpace(*host))
943 env := strings.TrimSpace(*environment)
944 // Checked here as well as at the door, so a typo costs a refusal rather
945 // than a round trip. "default" is not a bundle name — it is the absence
946 // of one, spelled by an empty string (appconfig.ValidEnvironmentName
947 // refuses the literal for exactly this reason).
948 if env != "" && !appconfig.ValidEnvironmentName(env) {
949 log.Fatalf("carlos instances set-environment: %q is not a config environment — want [a-z0-9-]{1,32}; pass an empty --environment to return this route to the app's default bundle", env)
950 }
951 sess := requireAPISession("set-environment", *consoleFlag)
952 printUsing(stdout, sess)
953 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
954 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
955 rec, err := sess.instanceSetEnvironment(context.Background(), acct, app2, strings.TrimSpace(*host), env)
956 if err != nil {
957 var prop *apiProposedError
958 if errors.As(err, &prop) {
959 fmt.Fprintf(stdout, "rebinding %s is proposed and needs %d confirmation(s) at %s\n",
960 strings.TrimSpace(*host), prop.Needed, prop.ConsoleURL)
961 return
962 }
963 err = recordNotFound(context.Background(), sess, acct, app2, strings.TrimSpace(*host), err, func(h string) string {
964 // The box verb spells "back to the default bundle" as "-", where
965 // this one spells it as an empty value.
966 boxEnv := env
967 if boxEnv == "" {
968 boxEnv = "-"
969 }
970 return fmt.Sprintf("%s is a box route with no instance record, so the console has nothing to rebind. until it is adopted as an instance, bind it on its box: carlos route --host %s --environment %s", h, h, boxEnv)
971 })
972 log.Fatalf("instances set-environment: %v", err)
973 }
974 if rec.Environment == "" {
975 fmt.Fprintf(stdout, "%s %s to the app's default bundle\n", colorFor(stdout).ok("bound"), rec.Host)
976 } else {
977 fmt.Fprintf(stdout, "%s %s to environment %s\n", colorFor(stdout).ok("bound"), rec.Host, rec.Environment)
978 }
979 // The config a process was started with is the config it keeps: this
980 // changes which bundle the box DELIVERS, and the running process picks
981 // it up when it is next started. Saying so is the difference between
982 // "it didn't work" and "it needs a restart".
983 mutedf(stdout, " the binding lands within seconds; restart the instance to pick the new values up: carlos restart --app %s\n", app2)
984}
985
986const (
987 instancesRetryDone = "retry requested for %s: its next attempt at %s runs as a canary"
988 instancesRetryOldConsole = "not found: either %s has no instance %s, or this console predates carlos instances retry (deploy a console built from current main)"
989)
990
991type apiRetryResult struct {
992 ID string `json:"id"`
993 Version string `json:"version"`
994 Host string `json:"host"`
995}
996
997// instanceRetry is `carlos instances retry`'s request. A bare 404 is "not
998// yours" or a console from before the door; the message names both.
999func (s *apiSession) instanceRetry(ctx context.Context, a apiAcct, app, host string, messages ...string) (apiRetryResult, error) {
1000 in := struct {
1001 Message string `json:"message,omitempty"`
1002 }{firstMutationMessage(messages)}
1003 status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/retry"), in)
1004 if err != nil {
1005 return apiRetryResult{}, err
1006 }
1007 if status != http.StatusOK {
1008 if status == http.StatusNotFound && !hasJSONError(body) {
1009 return apiRetryResult{}, fmt.Errorf(instancesRetryOldConsole, app, host)
1010 }
1011 return apiRetryResult{}, apiErrorFromBody(status, body)
1012 }
1013 var out apiRetryResult
1014 if err := json.Unmarshal(body, &out); err != nil {
1015 return apiRetryResult{}, fmt.Errorf("instances retry: unreadable response: %w", err)
1016 }
1017 return out, nil
1018}
1019
1020// runInstancesRetry is `carlos instances retry <host>` (spec §8.4, §9.1).
1021// The host may come first, as the spec writes it, or after the flags.
1022func runInstancesRetry(args []string) {
1023 host, args := leadingArg(args)
1024 fs := flag.NewFlagSet("instances retry", flag.ExitOnError)
1025 messageFlags := addMutationMessageFlags(fs)
1026 app := appFlag(fs, "app the instance belongs to")
1027 consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
1028 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
1029 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
1030 fs.Usage = flagUsage(fs, instancesRecordsUsage)
1031 fs.Parse(args)
1032 switch {
1033 case host == "" && fs.NArg() == 1:
1034 host = strings.TrimSpace(fs.Arg(0))
1035 case fs.NArg() != 0:
1036 log.Fatal(instancesRecordsUsage)
1037 }
1038 if host == "" {
1039 log.Fatal(instancesRecordsUsage)
1040 }
1041 host = strings.ToLower(host)
1042 sess := requireAPISession("retry", *consoleFlag)
1043 printUsing(stdout, sess)
1044 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1045 app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
1046 message, err := resolveMutationMessage(fs, messageFlags)
1047 if err != nil {
1048 log.Fatalf("instances retry: %v", err)
1049 }
1050 res, err := sess.instanceRetry(context.Background(), acct, app2, host, message)
1051 if err != nil {
1052 log.Fatalf("instances retry: %v", err)
1053 }
1054 fmt.Fprintf(stdout, instancesRetryDone+"\n", host, res.Version)
1055}
1056