// The `carlos instances` verb family beyond the bare box-side listing: // // - `enable` — the app's opt-in to instance provisioning (spec §3.1). // Two modes, resolved credential-first like ship/promote (apimode.go): // store mode writes apps///instances.json via // provision.PutManifest with every field under the operator's hand; // API mode (issue #66) asks the console, which pins the manifest's // domain to the account's own platform subdomain — so a member can // bring an app live on .. with a login alone, // and custom domains stay an operator act. // - `create` / `list` / `delete` / `set-upstreams` — the CLI front over // the console's instance-record verbs (spec §9's "natural follow-up // slice", built here because the records were otherwise API-only: the // one day-to-day surface with no carlos command). API mode ONLY: a // record is a console-authorized write, and the box-side operator // equivalent has always been `carlos add`. set-upstreams is the CLI // front over the multi-upstream repoint PATCH (HA design, issue #152): // `carlos add` remains the box-local, registry-direct way to place a // route by hand — this is its console-authorized, member-facing // counterpart for repointing an already-provisioned instance's upstream // set without a box shell. package main import ( "context" "encoding/json" "errors" "flag" "fmt" "log" "net/http" "net/url" "os" "strings" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rolloutwords" "github.com/carlosframework/platform/internal/updatewords" ) const instancesEnableUsage = `usage: carlos instances enable --app [flags] Signed-in mode (carlos auth login; no CARLOS_DEPLOYMENT_* env): the console writes the manifest, and pins its domain to your account's own platform subdomain (.). --domain, --pool and --regions are operator, store-mode flags and are refused here. Store mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR set): every field is the operator's call, and --domain is required. ` func runInstancesEnable(args []string) { fs := flag.NewFlagSet("instances enable", flag.ExitOnError) app := appFlag(fs, "app whose instances this enables") domain := fs.String("domain", "", "instance host domain suffix (store mode: required; signed-in mode: refused — the console pins it)") pool := fs.String("pool", "", "pool stamped into every record at create (store mode only)") // Default "" so signed-in mode can tell "unset" from "asked for": an // omitted --channel must preserve whatever the manifest already carries, // not silently reset it to stable. Store mode applies the documented // "stable" default below, so its behaviour is unchanged. channel := channelFlag(fs, "release channel stamped into every record at create (store-mode default stable; signed-in mode: omitted preserves the manifest's)") regions := fs.String("regions", "", "comma-separated region allow-list (store mode only); empty = any shape-valid region") seed := fs.String("seed", "", "argv of the app's first-boot init command, whitespace-split (strings.Fields)") // A bool flag rather than a string, read through flagWasSet for the // same absent-vs-explicit split --channel and --seed use: omitting it // preserves whatever policy the manifest already carries, and // --private-storage=false turns the policy off. 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") consoleFlag := consoleOption(fs, "console to enable through (signed-in mode only; default: the only logged-in one)") acctRaw, acctIDRaw, acctOf := accountFlags(fs) fs.Usage = flagUsage(fs, instancesEnableUsage) fs.Parse(args) if *app != "" && !release.ValidApp(*app) { log.Fatalf("carlos instances enable: %q is not a valid app name", *app) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess != nil { // Signed-in mode. The three operator fields are refused rather than // ignored — a flag that silently did nothing would read as policy // applied. for _, f := range []string{"domain", "pool", "regions"} { if flagWasSet(fs, f) { 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) } } var channelReq *string if flagWasSet(fs, "channel") { channelReq = channel } var privateStorageReq *bool if flagWasSet(fs, "private-storage") { privateStorageReq = privateStorage } var seedReq *[]string if flagWasSet(fs, "seed") { // parseSeed("") is an empty (non-nil after this) argv: an explicit // --seed "" clears the manifest's seed, matching --channel's // explicit-vs-absent split. argv := parseSeed(*seed) if argv == nil { argv = []string{} } seedReq = &argv } printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesEnableUsage) m, err := sess.instancesEnable(context.Background(), acct, app2, channelReq, seedReq, privateStorageReq) if err != nil { log.Fatalf("instances enable: %v", err) } fmt.Fprintf(stdout, "%s for %s under %s\n", colorFor(stdout).ok("enabled instances"), m.App, m.Domain) // The policy IN FORCE, from the door's echo, not the flag that was // typed: an enable that never mentioned storage still prints what // the app does, which is the fact the next create will act on. if m.PrivateStorage { mutedf(stdout, " every instance gets its own config environment and object store\n") } mutedf(stdout, " first instance: carlos instances create --app %s --host --host [--region ] [--channel ] [--kind instance|service|static] [--environment ] [--addr ] [--health-path

] [--health-status ] carlos instances list --app [--match ] [--health ] carlos instances delete --app --host carlos instances set-upstreams --app --host [--addrs ] [--health-path

] [--health-status ] carlos instances set-channel --app --host --channel carlos instances set-environment --app --host --environment carlos instances retry --app ` + updatewords.Text("settings.cli.instances-usage-lines") + ` Signed-in only (carlos auth login): instance records are console-authorized writes — the console checks the host against the app's manifest and the box's reconcile pass materializes the route. create stamps a channel on the record, and prints the one it chose. Named with --channel it is yours; omitted, it is the app's own enable channel, else the channel this app's other instances already follow, else its pipeline entry — where "carlos deploy" lands, so a brand-new app's first instance is deployable without a passkey. Instances that follow SEVERAL channels are refused rather than guessed at: pass --channel. set-channel moves ONE route onto a different release channel. The channel lives in the instance record, which the box converges the route from every pass — so this is the verb that changes it, not "carlos route --channel", which edits a row the next reconcile overwrites. A move that the app's pipeline gates is proposed and finished in the console, like a promote. retry clears the failure hold on one instance: the build that failed on its box is tried again there, as a canary. "carlos promote --retry " does the same on every box. ` + updatewords.Text("settings.cli.instances-usage-para") + ` --environment binds a route to one of the app's named config bundles ("carlos env environments" lists them; "carlos env --environment " writes them). Omitted, a route serves the app's default bundle — which matters most when a route stands ALONGSIDE a live one: config is per-app, so a second instance inherits values naming the first one's host, and anything the app keys off a single origin (the Origin header it checks, magic-link URLs, a WebAuthn RP ID, the cookie Secure flag) fails closed on the new host while GETs look healthy. set-environment rebinds an existing route the same way, and for the same reason set-channel exists rather than "carlos route --environment": the reconciler reverts the box-side edit within seconds. Restart the instance afterwards to pick the new values up. set-upstreams-only flags: --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 to empty (refused with a 400 for a kind:instance record — emptying an addr-backed instance route reverts it to a socket-backed shape, and the DerivesPaths rail refuses that transition in place; delete and re-create instead) --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, even if a prior probe had set it --health-status probe's expected response status; --health-path and --health-status may be given together or alone — either one, given at all, replaces the whole probe (omitting both leaves the existing probe untouched) Operators placing routes by hand on a box use carlos add, not this. ` // requireAPISession resolves signed-in mode for the record verbs, refusing // direct mode by name: there IS no store-mode record write (the console is // the door, spec §9), and the operator equivalent is a different verb. func requireAPISession(verb, consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatalf("carlos instances %s works through a console, and this shell is in direct bucket mode\n"+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Instance records are\n"+ "console-authorized writes; unset the store env and run: carlos auth login --console .\n"+ "To place a route by hand on THIS box, use carlos add instead", verb) } return sess } func runInstancesCreate(args []string) { fs := flag.NewFlagSet("instances create", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "app the instance belongs to") host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain") region := fs.String("region", "", flagRegionInstancesCreate) channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry)") 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") environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them") 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") healthPath := fs.String("health-path", "", "states the whole probe alongside --health-status: request path (default \"/\"); honoured for a single --addr too") healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults") consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") 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") 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") at := fs.Int64("at", 0, "with --from, the transaction id to copy at (default: the latest)") 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") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) if *host == "" { log.Fatal(instancesRecordsUsage) } set := map[string]bool{} fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) addrs := parseAddrList(*addr, set["addr"]) probe := parseProbeFlags(*healthPath, *healthStatus, set["health-path"], set["health-status"]) if set["at"] && (*at <= 0 || *from == "") { log.Fatalf("instances create: %s", "at must be a transaction id, a whole number above 0") } if *from != "" && (*kind != "" || set["addr"] || *role != "" || *replicas != "") { log.Fatalf("instances create: %s", "a seeded instance is an ordinary instance. Drop --kind, --addr and --role.") } sess := requireAPISession("create", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("instances create: %v", merr) } if *from != "" { if err := sess.runSeededCreate(context.Background(), stdout, acct, app2, strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), strings.TrimSpace(*environment), strings.TrimSpace(*from), uint64(*at), message); err != nil { log.Fatalf("instances create: %v", err) } return } rec, err := sess.instanceCreate(context.Background(), acct, app2, strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe, replicaReqFromFlags(*role, *replicas, set["region"]), message) if err != nil { log.Fatalf("instances create: %v", err) } fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("created instance"), instanceCreatedLine(rec, environmentSuffix(rec.Environment))) if rec.PrivateStoreBucket != "" { mutedf(stdout, " private storage: bucket %s\n", rec.PrivateStoreBucket) } mutedf(stdout, " the host comes live on the next reconcile pass on its box") } // seedCopyingLine is what `instances create --from` says when the console // has reserved the record and the copy begins. func seedCopyingLine(source, host string) string { return fmt.Sprintf("copying %s into %s. A large database can take several minutes.", source, host) } // instanceCreatedLine is the text after "created instance" or "canary // host": today's "host (channel X)" byte for byte from an older // console, C14 when the console names the place. func instanceCreatedLine(rec apiInstanceRecord, envSuffix string) string { if rec.RegionName == "" { return fmt.Sprintf("%s (channel %s%s)", rec.Host, rec.Channel, envSuffix) } return fmt.Sprintf("%s (%s%s)", rec.Host, instanceCreatedSuffix(rec.Channel, rec.RegionName), envSuffix) } // replicaReqFromFlags builds the create body's replica slice from the CLI's // --role/--replicas pair, or nil when neither flag was given — a nil request // sends neither key, byte-identical to every create before #162 existed. // The refusals mirror the console door's (checkReplicaCreate): a replica's // placement IS its --region, the placement set is the HOME record's opt-in // and rides a PLAIN create, and a replica never carries a set of its own. // Refusing here names what is missing without a console round trip. func replicaReqFromFlags(role, replicas string, regionGiven bool) *instanceReplicaReq { if role == "" && replicas == "" { return nil } if role != "" && replicas != "" { 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)") } if role == "" { return &instanceReplicaReq{Set: parseAddrList(replicas, true)} } if !regionGiven { log.Fatal("instances create: --role replica needs --region naming the replica's placement") } return &instanceReplicaReq{Role: role} } // parseAddrList splits a comma-separated --addr/--addrs flag into the wire's // []string, trimming whitespace and dropping empty elements the same way // --regions' comma-split does (instances_cli.go's runInstancesEnable). given // is fs.Visit's "was this flag on the command line at all" — an absent flag // must return nil (the wire's "leave/default", not an empty, explicit list), // which a bare `raw == ""` check cannot tell apart from `--addr ""`. func parseAddrList(raw string, given bool) []string { if !given { return nil } var out []string for _, a := range strings.Split(raw, ",") { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out } // parseProbeFlags builds the wire's optional probe block from --health-path/ // --health-status: nil when NEITHER flag was given (leave/default, matching // parseAddrList's given-vs-absent split), populated when either was — a // caller may set only the path, only the status, or both, and this is where // that distinction is made before it reaches the request struct's `omitempty` // pointer. func parseProbeFlags(path string, status int, pathGiven, statusGiven bool) *instanceProbeReq { if !pathGiven && !statusGiven { return nil } return &instanceProbeReq{Path: strings.TrimSpace(path), Status: status} } // upstreamsCell renders the joined per-upstream probe state as "2/3" // (up/total) — "-" when the record carries none, which is the ordinary case // for a single-upstream record (the join is omitempty on the wire, so a // pre-Task-8 console and a plain single-upstream record both decode to a nil // slice here) and for a talking-to-an-old-console read. func upstreamsCell(ups []apiUpstreamState) string { if len(ups) == 0 { return "-" } up := 0 for _, u := range ups { if u.Up { up++ } } return fmt.Sprintf("%d/%d", up, len(ups)) } const ( instancesListHeader = "HOST\tREGION\tCHANNEL\tPOOL\tINSTALLED\tSERVING\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY\tREASON" instancesListRetryHint = "a build that failed is tried again on every box with carlos promote --app %s --retry , or on one instance with carlos instances retry --app %s " ) // installedCell is the build the instance's box has installed: the rollout // report's when there is one, else the staged version VERSION always showed. func installedCell(r apiInstanceRecord) string { if r.Rollout != nil && r.Rollout.Installed != "" { return r.Rollout.Installed } return r.StagedVersion } // servingCell is the build answering requests, or a dash where no box // reports one: an older console, a box with CARLOS_SWAP off, or a box whose // report is unavailable. func servingCell(r apiInstanceRecord) string { if r.Rollout == nil || r.Rollout.Unavailable != "" || r.Rollout.Serving == "" { return "—" } return r.Rollout.Serving } // rolloutReason is the REASON cell (spec §9.1): why the serving build is not // the installed one, or that new config waits for a process (§8.1). A box that // cannot arm its scheduler says why first: its other fields are meaningless. // An asleep instance serves nothing (plan 5 reports Serving "" for it), so its // installed build is compared with Good, the build it last ran. func rolloutReason(r *apiRollout) string { switch { case r == nil: return "" case r.Unavailable != "": return rolloutwords.Unavailable(r.Unavailable) case r.HeldBy != "": return fmt.Sprintf(rolloutwords.StateHeld, r.HeldBy) case !r.Awake && r.Installed != r.Good: return fmt.Sprintf(rolloutwords.StateAsleep, r.Installed) case r.Awake && r.Installed != r.Serving: return rolloutwords.StateRollingOut case r.ConfigPending: return rolloutwords.StateConfigPending } return "" } // updatesReason is the settings half of REASON (spec §5): the EFFECTIVE // reason the instance's box reports (Astra I1 #12), whether the record or the // app's default set it: manual, pinned, held at major N (the number from the // report), and the other reasons root reports, with a request waiting on bake // named beside the reason, never instead of it. A reason word from a newer box // renders nothing: updatewords.Text panics on an id nobody registered. func updatesReason(u *apiUpdatesJoin, settingsDefault bool) string { if u == nil { return "" } if !u.Enforced { if settingsDefault { return "" } return updatewords.Text("settings.cli.not-enforced-reason") } var r string switch u.Reason { case "major": n := 0 if u.Major != nil { n = *u.Major } r = fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail) case "unknown", "new": r = updatewords.Text("settings.box.reason." + u.Reason) case "pin", "request", "manual", "failed": r = fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail) } if u.Baking != "" { b := fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), u.Baking) if r == "" { return b } return r + "; " + b } return r } // reasonCell is the REASON column: a rollout reason first (a failure hold // outranks a preference), else the settings reason. func reasonCell(r apiInstanceRecord) string { if s := rolloutReason(r.Rollout); s != "" { return s } return updatesReason(r.Updates, r.SettingsDefault) } func anyHeld(recs []apiInstanceRecord) bool { for _, r := range recs { if r.Rollout != nil && r.Rollout.HeldBy != "" { return true } } return false } func runInstancesList(args []string) { fs := flag.NewFlagSet("instances list", flag.ExitOnError) app := appFlag(fs, "app whose instance records to list") match := fs.String("match", "", "only hosts containing this substring") health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed") consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) sess := requireAPISession("list", *consoleFlag) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) recs, truncated, total, err := sess.instanceList(context.Background(), acct, app2, *match, *health) if err != nil { log.Fatalf("instances list: %v", err) } w := newTable(stdout) fmt.Fprintln(w, instancesListHeader+"\t"+"SEED") for _, r := range recs { rowHealth := r.Health if rowHealth == "" { rowHealth = "—" // an old console: the field predates it, not "unhealthy" } 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, installedCell(r), servingCell(r), rowHealth, upstreamsCell(r.Upstreams), humanBytes(r.DBBytes), r.CreatedAt, r.CreatedBy, reasonCell(r), seedCell(r)) } w.Flush() // Spec §8.4: a held build is retried everywhere by `promote --retry`, or // on one instance by `instances retry`. Said once, under the table, only // when something is held. if anyHeld(recs) { mutedf(stdout, instancesListRetryHint, app2, app2) } if truncated { mutedf(stdout, "(truncated at 500 records)") } // total is the server's count of every record matching --match/--health, not // just the page returned above — worth a line only when a filter is // active AND the console actually reported one (a pre-Task-9 console // sends no "total" field, which decodes as 0; treated as unknown here // rather than "zero matches", since len(recs) itself already proves that // wrong whenever recs is non-empty). if (*match != "" || *health != "") && total > len(recs) { mutedf(stdout, "(showing %d of %d matches)", len(recs), total) } } func runInstancesDelete(args []string) { fs := flag.NewFlagSet("instances delete", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "app the instance belongs to") host := fs.String("host", "", "instance host whose record to retire") consoleFlag := consoleOption(fs, "console to delete through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) if *host == "" { log.Fatal(instancesRecordsUsage) } sess := requireAPISession("delete", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("instances delete: %v", merr) } if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host), message); err != nil { log.Fatalf("instances delete: %v", err) } fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n", colorFor(stdout).ok("deleted instance"), *host) } // runInstancesSetUpstreams is `carlos instances set-upstreams` — the CLI // front over handleAPIInstanceUpdate's PATCH (Task 7, issue #152): repoint a // live instance's upstream set and/or its probe without a box shell. Unlike // create/list/delete this is an update to an ALREADY-provisioned record, so // there is deliberately no --region/--channel here — those have their own // doors (placement, and there is none for channel via this record; see // spec §9). // // Neither --addrs nor a --health-* flag is required by this parser: the // console's own refusal ("nothing to update — send addrs, probe, steering, // or a combination") is left to fire when a caller gives neither, so that // refusal's wording // reaches the operator verbatim rather than being paraphrased here — the // self-review rule this task carries (ValidateShape's own sentences are // operator guidance, not something to summarize). func runInstancesSetUpstreams(args []string) { fs := flag.NewFlagSet("instances set-upstreams", flag.ExitOnError) app := appFlag(fs, "app the instance belongs to") host := fs.String("host", "", "instance host whose upstream set to repoint") 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)") 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") healthStatus := fs.Int("health-status", 0, "probe's expected response status; either --health-path or --health-status, given at all, replaces the whole probe") consoleFlag := consoleOption(fs, "console to update through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) if *host == "" { log.Fatal(instancesRecordsUsage) } set := map[string]bool{} fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) var addrs *[]string if set["addrs"] { list := parseAddrList(*addrsFlag, true) if list == nil { // --addrs given but empty (or all-whitespace) — the wire's explicit // "clear the set" spelling ("addrs":[]), not "leave it alone" // (which is what a nil *[]string would mean). parseAddrList's // given=true already tells nil from absent for this caller; // this turns that nil into the non-nil empty slice the PATCH // door requires to see a clear rather than a no-op. list = []string{} } addrs = &list } probe := parseProbeFlags(*healthPath, *healthStatus, set["health-path"], set["health-status"]) sess := requireAPISession("set-upstreams", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) rec, err := sess.instanceSetUpstreams(context.Background(), acct, app2, strings.TrimSpace(*host), addrs, probe) if err != nil { log.Fatalf("instances set-upstreams: %v", err) } fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("updated upstreams for"), rec.Host) mutedf(stdout, " the change lands on its box within seconds of the next converge tick") } // requireSteeringAPISession resolves signed-in mode for `carlos steering`, // refusing direct-bucket mode with THIS verb's own name in the message — the // requireRestartAPISession precedent (restart.go), not requireAPISession's: // steering is a top-level verb ("carlos steering", not "carlos instances // steering"), so requireAPISession's hardcoded "carlos instances %s" would // name a command that does not exist. func requireSteeringAPISession(consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("carlos steering works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Steering is a console-authorized\n" + "write, the same family instance records belong to; unset the store env and run:\n" + "carlos auth login --console .") } return sess } const steeringUsage = `usage: carlos steering --app --host latency|off Sets an instance's DNS steering opt-in — a top-level verb, not an "instances" sub-verb, because unlike set-upstreams this is not repointing a route's upstreams: it decides whether the host's DNS answer varies by requester at all. latency opt into a Route53 latency record per armed edge — the nearest edge answers, instead of today's single static answer off clear the opt-in — back to today's static per-host answer v1 constraint: steering requires the shared pool (--pool "" on the app's instances manifest). A pool-scoped route is refused with a 409 naming this — pool-scoped steering is a recorded future, not v1. Signed-in only (carlos auth login): steering is a console-authorized write, the same family create/list/delete/set-upstreams belong to. SINGLE-BOX INERT: setting this field changes nothing on its own. It reaches the record and the registry row and stops there until a deployment arms the Route53 steering converger (CARLOS_STEERING_ZONE_ID) — this verb states the opt-in, it does not turn steering ON for the fleet. ` // runSteering is `carlos steering` — the CLI front over // handleAPIInstanceUpdate's PATCH steering field (Task 3, // multi-region-steering plan). It follows set-upstreams' shape (signed-in // only, the same --app/--host/--account/--console flags) but resolves its // session through requireSteeringAPISession rather than requireAPISession, // since it is a top-level verb, not an "instances" sub-verb — see that // function's own comment. The one other difference: the value is a bare // trailing word, "latency" or "off", rather than a flag — this door has no // "leave it alone" spelling to protect with a flag/no-flag distinction, so a // positional argument reads plainer at the terminal than an always-required // -steering flag would. // // The refusal text — the pool 409 or ValidateShape's 400 — is surfaced // verbatim, exactly as set-upstreams' refusals are: it names the only thing // an operator can act on (the constraint, or the bad value), and paraphrasing // it here would throw that away. func runSteering(args []string) { fs := flag.NewFlagSet("steering", flag.ExitOnError) app := appFlag(fs, "app the instance belongs to") host := fs.String("host", "", "instance host to steer") consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, steeringUsage) fs.Parse(args) if *host == "" || fs.NArg() != 1 { log.Fatal(steeringUsage) } var steering string switch fs.Arg(0) { case "latency": steering = "latency" case "off": steering = "" default: log.Fatal(steeringUsage) } sess := requireSteeringAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, steeringUsage) rec, err := sess.instanceSetSteering(context.Background(), acct, app2, strings.TrimSpace(*host), steering) if err != nil { log.Fatalf("steering: %v", err) } if steering == "" { fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("steering turned off for"), rec.Host) } else { fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("steering set to latency for"), rec.Host) } mutedf(stdout, " the record and its registry row update within seconds of the next converge tick;") mutedf(stdout, " the fleet only ACTS on this once a deployment arms its Route53 steering converger") } // runInstancesSetChannel is `carlos instances set-channel` — move one route // onto a different release channel, the door that did not exist when // hello.bdn landed on stable and could not be moved off it without a raw // bucket write. // // The record is where a route's channel LIVES (the provisioning reconciler // converges the registry row from it every pass), which is why this is an // instances verb and not `carlos route --channel`: that one edits a row the // next reconcile overwrites, and says so. func runInstancesSetChannel(args []string) { fs := flag.NewFlagSet("instances set-channel", flag.ExitOnError) app := appFlag(fs, "app the instance belongs to") host := fs.String("host", "", "instance host whose channel to change") channel := channelFlag(fs, "release channel the route should follow") consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) if *host == "" || *channel == "" { log.Fatal(instancesRecordsUsage) } // Records and routes are keyed lowercase, so a host typed with capitals // would miss both and read as a typo (#264). *host = strings.ToLower(strings.TrimSpace(*host)) sess := requireAPISession("set-channel", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) if err := setInstanceChannel(context.Background(), sess, acct, app2, *host, *channel); err != nil { log.Fatal(err) } } // setInstanceChannel is the channel move both `carlos instances set-channel` // and `carlos instances set --channel` make, through the one door. A gated // move is not a failure: the change is recorded and waiting for people, so it // returns nil after naming where to finish, the shape `carlos deploy` uses // for a proposed promote. Any other refusal comes back as the error to print. func setInstanceChannel(ctx context.Context, sess *apiSession, acct apiAcct, app2, host, channel string) error { host, channel = strings.TrimSpace(host), strings.TrimSpace(channel) rec, err := sess.instanceSetChannel(ctx, acct, app2, host, channel) if err != nil { var prop *apiProposedError if errors.As(err, &prop) { fmt.Fprintf(stdout, "moving %s to %s is proposed and needs %d confirmation(s) at %s\n", host, channel, prop.Needed, prop.ConsoleURL) return nil } err = recordNotFound(ctx, sess, acct, app2, host, err, func(h string) string { 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) }) return fmt.Errorf("instances set-channel: %v", err) } fmt.Fprintf(stdout, "%s %s to %s\n", colorFor(stdout).ok("moved"), rec.Host, rec.Channel) if rec.Kind == "static" { // The one wait worth naming: a static route trusts a resolved // channel pointer for a while before re-reading it, so the URL can // lag the record. mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n", int(staticRecheckWindow.Seconds())) } else { mutedf(stdout, " the change lands on its box within seconds of the next converge tick") } return nil } // recordNotFound narrows the membership 404 a record write answers when its // host names no record (#264). The console answers "no such record" and "not // your app" with the same bytes on purpose (handleAPIInstanceUpdate), so the // write alone cannot tell them apart. The app's route list can: it sits // behind the same membership gate, so an answer from it proves the account, // app and membership right, and leaves the record as the thing missing. // // A host the list carries is a box route with no instance record, such as // one a tenant's own tooling writes into the box registry. Those are rebound // on the box until #261's adoption door gives them a // record, and boxRoute says how, except for a platform canary, which // `carlos route` refuses on both axes because the domains reconciler // rebuilds its row. A host the list does not carry is most likely a typo, // but the list is only the console's own box, so the message says no more // than that. When the list does not answer either, the account or app // really may be wrong, and the membership message stands. func recordNotFound(ctx context.Context, sess *apiSession, a apiAcct, app, host string, err error, boxRoute func(host string) string) error { if !errors.Is(err, errMembershipNotFound) { return err } rows, _, lerr := sess.routeList(ctx, a, app) if lerr != nil { return err } for _, r := range rows { if r.Host != host { continue } if r.PlatformCanary { 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) } return errors.New(boxRoute(host)) } 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) } // environmentSuffix renders a record's config binding for a one-line // confirmation, and renders NOTHING for the default bundle. The empty case // is the common one and naming it ("environment default") on every create // would train people to skim the parenthetical that matters. func environmentSuffix(env string) string { if strings.TrimSpace(env) == "" { return "" } return ", environment " + env } // runInstancesSetEnvironment is `carlos instances set-environment` — rebind // ONE route to a named config bundle. // // This is the door that did not exist until 2026-09-04, and its absence had // a specific shape. Config in CARLOS is per-app, so a second instance // standing alongside a live one inherits the default bundle. For any app // whose framework keys behaviour off a single origin scalar — the Origin // header it compares against, the magic-link base URL, the WebAuthn RP ID, // the cookie Secure flag — that bundle names the OTHER host, and every POST // to the new one fails closed while every GET looks perfectly healthy. // // It is an instances verb and not `carlos route --environment` for exactly // the reason set-channel is: the record is where a provisioned route's // Environment lives, and convergeOwned (internal/provision/reconcile.go) // reverts a box-side edit within seconds of the next serial bump — that // function's own doc comment names `carlos route -environment` as the write // it undoes. // // An explicit empty --environment is a real request (return this route to // the app's default bundle), so the flag's PRESENCE is what is required // here, never a non-empty value. func runInstancesSetEnvironment(args []string) { fs := flag.NewFlagSet("instances set-environment", flag.ExitOnError) app := appFlag(fs, "app the instance belongs to") host := fs.String("host", "", "instance host whose config binding to change") environment := fs.String("environment", "", "named config bundle to bind to; empty returns the route to the app's default bundle") consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) if *host == "" || !flagWasSet(fs, "environment") { log.Fatal(instancesRecordsUsage) } // Lowercased for set-channel's reason. *host = strings.ToLower(strings.TrimSpace(*host)) env := strings.TrimSpace(*environment) // Checked here as well as at the door, so a typo costs a refusal rather // than a round trip. "default" is not a bundle name — it is the absence // of one, spelled by an empty string (appconfig.ValidEnvironmentName // refuses the literal for exactly this reason). if env != "" && !appconfig.ValidEnvironmentName(env) { 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) } sess := requireAPISession("set-environment", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) rec, err := sess.instanceSetEnvironment(context.Background(), acct, app2, strings.TrimSpace(*host), env) if err != nil { var prop *apiProposedError if errors.As(err, &prop) { fmt.Fprintf(stdout, "rebinding %s is proposed and needs %d confirmation(s) at %s\n", strings.TrimSpace(*host), prop.Needed, prop.ConsoleURL) return } err = recordNotFound(context.Background(), sess, acct, app2, strings.TrimSpace(*host), err, func(h string) string { // The box verb spells "back to the default bundle" as "-", where // this one spells it as an empty value. boxEnv := env if boxEnv == "" { boxEnv = "-" } 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) }) log.Fatalf("instances set-environment: %v", err) } if rec.Environment == "" { fmt.Fprintf(stdout, "%s %s to the app's default bundle\n", colorFor(stdout).ok("bound"), rec.Host) } else { fmt.Fprintf(stdout, "%s %s to environment %s\n", colorFor(stdout).ok("bound"), rec.Host, rec.Environment) } // The config a process was started with is the config it keeps: this // changes which bundle the box DELIVERS, and the running process picks // it up when it is next started. Saying so is the difference between // "it didn't work" and "it needs a restart". mutedf(stdout, " the binding lands within seconds; restart the instance to pick the new values up: carlos restart --app %s\n", app2) } const ( instancesRetryDone = "retry requested for %s: its next attempt at %s runs as a canary" instancesRetryOldConsole = "not found: either %s has no instance %s, or this console predates carlos instances retry (deploy a console built from current main)" ) type apiRetryResult struct { ID string `json:"id"` Version string `json:"version"` Host string `json:"host"` } // instanceRetry is `carlos instances retry`'s request. A bare 404 is "not // yours" or a console from before the door; the message names both. func (s *apiSession) instanceRetry(ctx context.Context, a apiAcct, app, host string, messages ...string) (apiRetryResult, error) { in := struct { Message string `json:"message,omitempty"` }{firstMutationMessage(messages)} status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/retry"), in) if err != nil { return apiRetryResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound && !hasJSONError(body) { return apiRetryResult{}, fmt.Errorf(instancesRetryOldConsole, app, host) } return apiRetryResult{}, apiErrorFromBody(status, body) } var out apiRetryResult if err := json.Unmarshal(body, &out); err != nil { return apiRetryResult{}, fmt.Errorf("instances retry: unreadable response: %w", err) } return out, nil } // runInstancesRetry is `carlos instances retry ` (spec §8.4, §9.1). // The host may come first, as the spec writes it, or after the flags. func runInstancesRetry(args []string) { host, args := leadingArg(args) fs := flag.NewFlagSet("instances retry", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "app the instance belongs to") consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, instancesRecordsUsage) fs.Parse(args) switch { case host == "" && fs.NArg() == 1: host = strings.TrimSpace(fs.Arg(0)) case fs.NArg() != 0: log.Fatal(instancesRecordsUsage) } if host == "" { log.Fatal(instancesRecordsUsage) } host = strings.ToLower(host) sess := requireAPISession("retry", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) message, err := resolveMutationMessage(fs, messageFlags) if err != nil { log.Fatalf("instances retry: %v", err) } res, err := sess.instanceRetry(context.Background(), acct, app2, host, message) if err != nil { log.Fatalf("instances retry: %v", err) } fmt.Fprintf(stdout, instancesRetryDone+"\n", host, res.Version) }