| 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. |
| 22 | package main |
| 23 | |
| 24 | import ( |
| 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 | |
| 43 | const instancesEnableUsage = `usage: carlos instances enable --app <a> [flags] |
| 44 | |
| 45 | Signed-in mode (carlos auth login; no CARLOS_DEPLOYMENT_* env): the console |
| 46 | writes the manifest, and pins its domain to your account's own platform |
| 47 | subdomain (<sqid>.<apps-domain>). --domain, --pool and --regions are operator, |
| 48 | store-mode flags and are refused here. |
| 49 | |
| 50 | Store mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR set): every field |
| 51 | is the operator's call, and --domain is required. |
| 52 | |
| 53 | ` |
| 54 | |
| 55 | func 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. |
| 201 | func 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. |
| 209 | func 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 | |
| 213 | var 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 | |
| 225 | Signed-in only (carlos auth login): instance records are console-authorized |
| 226 | writes — the console checks the host against the app's manifest and the |
| 227 | box's reconcile pass materializes the route. |
| 228 | |
| 229 | create stamps a channel on the record, and prints the one it chose. Named |
| 230 | with --channel it is yours; omitted, it is the app's own enable channel, else |
| 231 | the channel this app's other instances already follow, else its pipeline |
| 232 | entry — where "carlos deploy" lands, so a brand-new app's first instance is |
| 233 | deployable without a passkey. Instances that follow SEVERAL channels are |
| 234 | refused rather than guessed at: pass --channel. |
| 235 | |
| 236 | set-channel moves ONE route onto a different release channel. The channel |
| 237 | lives in the instance record, which the box converges the route from every |
| 238 | pass — so this is the verb that changes it, not "carlos route --channel", |
| 239 | which edits a row the next reconcile overwrites. A move that the app's |
| 240 | pipeline gates is proposed and finished in the console, like a promote. |
| 241 | |
| 242 | retry clears the failure hold on one instance: the build that failed on its |
| 243 | box 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 |
| 250 | them). Omitted, a route serves the app's default bundle — which matters most |
| 251 | when a route stands ALONGSIDE a live one: config is per-app, so a second |
| 252 | instance inherits values naming the first one's host, and anything the app |
| 253 | keys off a single origin (the Origin header it checks, magic-link URLs, a |
| 254 | WebAuthn RP ID, the cookie Secure flag) fails closed on the new host while |
| 255 | GETs look healthy. set-environment rebinds an existing route the same way, |
| 256 | and for the same reason set-channel exists rather than "carlos route |
| 257 | --environment": the reconciler reverts the box-side edit within seconds. |
| 258 | Restart the instance afterwards to pick the new values up. |
| 259 | |
| 260 | set-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 | |
| 281 | Operators 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. |
| 287 | func 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 | |
| 301 | func 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. |
| 367 | func 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. |
| 374 | func 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. |
| 388 | func 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 ""`. |
| 410 | func 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. |
| 429 | func 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. |
| 441 | func 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 | |
| 454 | const ( |
| 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. |
| 461 | func 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. |
| 471 | func 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. |
| 483 | func 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. |
| 507 | func 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. |
| 542 | func reasonCell(r apiInstanceRecord) string { |
| 543 | if s := rolloutReason(r.Rollout); s != "" { |
| 544 | return s |
| 545 | } |
| 546 | return updatesReason(r.Updates, r.SettingsDefault) |
| 547 | } |
| 548 | |
| 549 | func 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 | |
| 558 | func 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 | |
| 607 | func 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). |
| 650 | func 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. |
| 701 | func 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 | |
| 715 | const steeringUsage = `usage: carlos steering --app <a> --host <h> latency|off |
| 716 | |
| 717 | Sets 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 |
| 719 | route's upstreams: it decides whether the host's DNS answer varies by |
| 720 | requester 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 | |
| 726 | v1 constraint: steering requires the shared pool (--pool "" on the app's |
| 727 | instances manifest). A pool-scoped route is refused with a 409 naming this — |
| 728 | pool-scoped steering is a recorded future, not v1. |
| 729 | |
| 730 | Signed-in only (carlos auth login): steering is a console-authorized write, |
| 731 | the same family create/list/delete/set-upstreams belong to. |
| 732 | |
| 733 | SINGLE-BOX INERT: setting this field changes nothing on its own. It reaches |
| 734 | the record and the registry row and stops there until a deployment arms the |
| 735 | Route53 steering converger (CARLOS_STEERING_ZONE_ID) — this verb states the |
| 736 | opt-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. |
| 755 | func 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. |
| 802 | func 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. |
| 832 | func 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. |
| 876 | func 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. |
| 900 | func 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. |
| 928 | func 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 | |
| 986 | const ( |
| 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 | |
| 991 | type 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. |
| 999 | func (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. |
| 1022 | func 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 | |