Merge remote-tracking branch 'origin/main' into fix/timer-rearm-completion
55 files changed,
+2390
−288
AGENTS.md+19 −1README.md+7 −0accounts.go+2 −2analytics_cmd.go+1 −1appconfig_cli.go+12 −11apps.go+4 −4apps_buildprobe.go+1 −1auth.go+15 −6canary.go+3 −3cli.go+12 −12cmd/console/main.go+25 −4color.go+5 −1config.go+247 −76config_cli.go+691 −0config_cli_test.go+340 −0config_test.go+1 −0deploy.go+8 −4destinations_test.go+308 −0docs/site/cli.md+82 −1domains_cli.go+4 −4e2e_test.go+7 −0economics.go+2 −2errors_cmd.go+1 −1features_cli.go+2 −2flags.go+31 −2fleets.go+6 −6gate_cli.go+1 −1instances_cli.go+8 −8internal/cliflag/cliflag.go+4 −2internal/console/apidomains.go+84 −46internal/console/apidomains_test.go+57 −13internal/console/domainattach.go+1 −1internal/console/domainattach_test.go+24 −0internal/console/domainpolicy.go+16 −0internal/console/domainpolicy_test.go+17 −7internal/console/server.go+12 −4internal/host/bootstrap.go+55 −8internal/host/host_test.go+81 −0internal/host/systemapply.go+41 −6internal/host/systemapply_test.go+100 −0logs.go+1 −1mail_cli.go+11 −11main.go+4 −0pipeline.go+1 −1relay.go+5 −5restart.go+1 −1retention.go+1 −1routes_cli.go+1 −1scan.go+1 −1schedule.go+4 −4scopedkeys_e2e_test.go+1 −0services.go+4 −4sidecars_cli.go+6 −6store.go+8 −8system.go+4 −4
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index f0e66f53e..542447ec0 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -277,7 +277,8 @@ anything. |
| - Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is |
| content, icons are chrome. |
| - **A test that shells out to the carlos binary must build its env through |
| - `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`) |
| + `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`, |
| + `CARLOS_DESTINATION`) |
| **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for |
| `.carlos/config`. The fallback is the operator's real login and default |
| console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
| @@ -380,3 +381,20 @@ the domain apex and nested account namespaces remain protected. Alias targets |
| still require an existing app route. First-route creation and wildcard or |
| catchall capabilities are refused. Global claims and deployment-route conflict |
| checks remain authoritative. Tito's initial approved account is bap (Pullup). |
| + |
| +The delegation only means something on a deployment that sets |
| +CONSOLE_RESERVE_PLATFORM_DOMAINS=true (below). Without that setting, the |
| +apps domain is not reserved, so every account can already attach names under |
| +it, and the delegated-alias limits do not apply. |
| + |
| +## Reserving platform domains is opt-in (James, 2026-09-17) |
| + |
| +`carlos domains attach` refuses everything under PLATFORM_APPS_DOMAIN and |
| +under the console's own hostnames ONLY when the console sets |
| +CONSOLE_RESERVE_PLATFORM_DOMAINS=true. That rule protects tenants from each |
| +other, so a deployment that hosts apps for other people (the flagship, |
| +carloku, Tito) must set it. The default is off: a self-hosted deployment |
| +usually keeps its console, its apps and its custom domains in one zone that |
| +one person owns, and the rule refused ordinary names there. Every deployment |
| +still refuses the console's own hostnames by exact name, the steering host, |
| +CONSOLE_DENY_DOMAINS, and a hostname another app's route holds. |
diff --git a/README.md b/README.md| index e58ae5774..fb49dafa2 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -340,6 +340,13 @@ publish an inflated instance count. |
| **Unset = the whole feature is inert**: no minting, no manifests read, |
| the enable door answers 503 — the same litestream convention as |
| everything else here. |
| +- `CONSOLE_RESERVE_PLATFORM_DOMAINS` — set to `true` on a deployment that |
| + hosts apps for other people (the flagship, carloku). `carlos domains |
| + attach` then refuses every name under `PLATFORM_APPS_DOMAIN` and under the |
| + console's own hostname. **Unset = false**: a self-hosted deployment can |
| + attach any name except the console's own hostname and |
| + `CONSOLE_DENY_DOMAINS`. A value that is not a boolean stops the console at |
| + boot. |
| - `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks |
| compare against, read by both the agent (checks) and the console |
| (guidance text), so the two can never name different addresses. Default: |
diff --git a/accounts.go b/accounts.go| index 2774d20ac..2da563e7c 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -88,7 +88,7 @@ func runAccountsCreate(args []string) { |
| name := fs.String("name", "", "account name (a-z, 0-9, hyphen; need not be unique)") |
| owner := fs.String("owner", "", "keymail address that owns the account — the box-side/headless path, "+ |
| "and what to use when this terminal is not logged in") |
| - console := fs.String("console", "", "which logged-in console to create it through (only needed with several)") |
| + console := consoleOption(fs, "which logged-in console to create it through (only needed with several)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| *name = strings.TrimSpace(*name) |
| @@ -212,7 +212,7 @@ func createAccountViaConsole(cred *credential, regPath, name string) { |
| func runAccountsList(args []string) { |
| fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)") |
| - consoleFlag := fs.String("console", "", "list through a console instead (default: the box registry when one exists, else your logged-in console)") |
| + consoleFlag := consoleOption(fs, "list through a console instead (default: the box registry when one exists, else your logged-in console)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| // Mode split by what exists, not by resolveMode: this verb predates the |
diff --git a/analytics_cmd.go b/analytics_cmd.go| index 27f781de9..fbbfc8161 100644 |
| --- a/analytics_cmd.go |
| +++ b/analytics_cmd.go |
| @@ -27,7 +27,7 @@ func runAnalytics(args []string) { |
| rangeFlag := fs.String("range", "today", "window: today | 7d | 30d") |
| setCounting := fs.String("set-count", "", "set analytics counting: edge | off") |
| setDoor := fs.String("set-door", "", "set door: on | off") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read 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, analyticsUsage) |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index f331a08b8..07e722bef 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -344,7 +344,7 @@ func runEnvSet(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env set --app <a> [--environment <name>] [--console <origin>] KEY=value [KEY2=value2 ...]" |
| @@ -393,8 +393,9 @@ func runEnvSet(args []string) { |
| // mapKeys is the key set of a KEY=value map, for the writtenKeys argument |
| // AwaitingRestart narrows on. Sorted, so a notice is deterministic across |
| -// runs where Go's map order is not. |
| -func mapKeys(kv map[string]string) []string { |
| +// runs where Go's map order is not. Generic: config.go lists destination |
| +// names with it too. |
| +func mapKeys[V any](kv map[string]V) []string { |
| out := make([]string, 0, len(kv)) |
| for k := range kv { |
| out = append(out, k) |
| @@ -409,7 +410,7 @@ func runEnvUnset(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env unset --app <a> [--environment <name>] [--console <origin>] KEY [KEY2 ...]" |
| @@ -462,7 +463,7 @@ func runEnvList(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env list --app <a> [--environment <name>] [--console <origin>]" |
| @@ -567,7 +568,7 @@ func runEnvExecDelivery(args []string) { |
| environment := environmentFlag(fs, |
| "named environment bundle to set the flag in (omit for the app's default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env exec-delivery --app <a> [--console <origin>] file|off\n" + |
| @@ -669,7 +670,7 @@ func runEnvEnvironments(args []string) { |
| fs := flag.NewFlagSet("env environments", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env environments --app <a> [--console <origin>]" |
| @@ -723,7 +724,7 @@ func runEnvSync(args []string) { |
| fs := flag.NewFlagSet("env sync", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos env sync --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| @@ -852,7 +853,7 @@ func runSecretsSet(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos secrets set --app <a> [--environment <name>] [--console <origin>] KEY=value [KEY2=value2 ...]" |
| @@ -953,7 +954,7 @@ func runSecretsUnset(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos secrets unset --app <a> [--environment <name>] [--console <origin>] KEY [KEY2 ...]" |
| @@ -1012,7 +1013,7 @@ func runSecretsList(args []string) { |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to list (omit for the default bundle)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to work through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos secrets list --app <a> [--environment <name>] [--console <origin>]" |
diff --git a/apps.go b/apps.go| index 558e0f41e..8edeb97d6 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -202,7 +202,7 @@ func appsMode(loggedIn, bucketConfigured bool, accountID string) (mode string, e |
| func runAppsDelete(args []string) { |
| fs := flag.NewFlagSet("apps delete", flag.ExitOnError) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to delete through (signed-in mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| @@ -323,7 +323,7 @@ func printTrashed(app, restorableUntil string) { |
| func runAppsRestore(args []string) { |
| fs := flag.NewFlagSet("apps restore", flag.ExitOnError) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to restore through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to restore through (signed-in mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| @@ -456,7 +456,7 @@ func runAppsCreate(args []string) { |
| // the account against the caller's own memberships. |
| acctRaw := fs.String("account", "", "account (sqid or name) to claim the app in (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account; this is the flag an ambiguity error tells you to use") |
| - consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if strings.TrimSpace(*app) == "" { |
| @@ -571,7 +571,7 @@ func runAppsPlace(args []string) { |
| // console-tracked state, API mode only, same as apps create above. |
| 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; this is the flag an ambiguity error tells you to use") |
| - consoleFlag := fs.String("console", "", "console to place through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to place through (default: the only logged-in one)") |
| const placeUsage = `usage: carlos apps place --app <name> --target fleet/<f>|"" [--account <sqid|name>] ` + |
| "[--account-id <sqid>] [--console <origin>]" |
| fs.Usage = flagUsage(fs, placeUsage) |
diff --git a/apps_buildprobe.go b/apps_buildprobe.go| index 3bd7f9215..ebc1c8ca7 100644 |
| --- a/apps_buildprobe.go |
| +++ b/apps_buildprobe.go |
| @@ -44,7 +44,7 @@ func runAppsBuildProbe(args []string) { |
| app := appFlag(fs, "app whose build probe to set") |
| 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") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, buildProbeUsage) |
| fs.Parse(args) |
| if strings.TrimSpace(*app) == "" || fs.NArg() != 1 { |
diff --git a/auth.go b/auth.go| index 2d5dcedab..3e282cf12 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -60,12 +60,14 @@ const authUsage = `usage: carlos auth <login|whoami|logout|default> |
| command-line access page (default: user@host) |
| whoami [--console <url>] ask the console who this terminal is |
| logout [--console <url>] revoke this terminal's token and forget it |
| - default [--project] [<url>] |
| + default [--project] [-d <destination>] [<url>] |
| show the default console and where it is set, or |
| set it: writes ~/.carlos/config — or, with |
| --project, ./.carlos/config, which overrides the |
| global and is meant to be committed so a checkout |
| - pins its own deployment |
| + pins its own deployment. -d reads or writes that |
| + destination's console ("carlos config |
| + destinations" manages the rest) |
| The token lands in ~/.carlos/credentials (mode 0600), one entry per console |
| origin — a laptop can hold credentials for the flagship and a self-hosted |
| @@ -636,7 +638,7 @@ func loginConsole(flagValue string) (string, error) { |
| func authLogin(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth login", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| + console := consoleOption(fs, "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| label := fs.String("label", "", "what to call this terminal on the console (default: user@host)") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| @@ -745,7 +747,7 @@ func authLogin(out io.Writer, args []string) error { |
| func authWhoami(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth whoami", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", "", "which console (only needed with several)") |
| + console := consoleOption(fs, "which console (only needed with several)") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
| @@ -800,7 +802,7 @@ func authWhoami(out io.Writer, args []string) error { |
| func authLogout(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth logout", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", "", "which console (only needed with several)") |
| + console := consoleOption(fs, "which console (only needed with several)") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
| @@ -847,6 +849,7 @@ func authDefault(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth default", flag.ContinueOnError) |
| fs.SetOutput(out) |
| project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config") |
| + destinationOption(fs) // reads, and writes into, that destination's block |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
| @@ -867,6 +870,9 @@ func authDefault(out io.Writer, args []string) error { |
| fmt.Fprintf(out, "Set one with: carlos auth default <url> (--project pins this directory)\n") |
| return nil |
| } |
| + if d := configDestination(console); d != "" { |
| + src = "the " + d + " destination in " + src |
| + } |
| fmt.Fprintf(out, "%s (from %s)\n", console, src) |
| return nil |
| case 1: |
| @@ -887,10 +893,13 @@ func authDefault(out io.Writer, args []string) error { |
| if err != nil { |
| return err |
| } |
| + if d := selectedDestination(); d != "" { |
| + path = "the " + d + " destination in " + path |
| + } |
| fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path) |
| return nil |
| default: |
| - return errors.New("usage: carlos auth default [--project] [<url>]") |
| + return errors.New("usage: carlos auth default [--project] [-d <destination>] [<url>]") |
| } |
| } |
diff --git a/canary.go b/canary.go| index 8a33eaaa6..30c1309ce 100644 |
| --- a/canary.go |
| +++ b/canary.go |
| @@ -340,7 +340,7 @@ func runCanaryDeploy(args []string) { |
| app := appFlag(fs, "app to canary") |
| name := fs.String("name", "", "canary name (default: the current git branch, slugified)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to deploy through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to deploy through (default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| environment := fs.String("environment", "", "named config bundle the canary binds to (default: the app's default bundle)") |
| ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") |
| @@ -483,7 +483,7 @@ func runCanaryList(args []string) { |
| fs := flag.NewFlagSet("canary ls", flag.ExitOnError) |
| app := appFlag(fs, "app whose canaries to list") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, canaryUsage) |
| fs.Parse(args) |
| @@ -554,7 +554,7 @@ func runCanaryRemove(args []string) { |
| app := appFlag(fs, "app the canary belongs to") |
| name := fs.String("name", "", "canary name to remove") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, canaryUsage) |
| fs.Parse(args) |
diff --git a/cli.go b/cli.go| index cb53a0d3b..ee9f8e20b 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1991,7 +1991,7 @@ func runShip(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to ship through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := versionFlag(fs, "version (git short sha); omit to mint the next iteration under the app's version target") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -2336,7 +2336,7 @@ func runPromote(args []string) { |
| app := appFlag(fs, "") |
| hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to promote through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos promote --app <a> [--hotfix] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <version> [channel]" |
| @@ -2505,7 +2505,7 @@ func runChannels(args []string) { |
| fs := flag.NewFlagSet("channels", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos channels --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| @@ -2598,7 +2598,7 @@ func runVersionTarget(args []string) { |
| fs := flag.NewFlagSet("version target", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read or set through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read or set through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos version target --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [<semver>]" |
| @@ -2760,7 +2760,7 @@ func runReleases(args []string) { |
| fs := flag.NewFlagSet("releases", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos releases --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| @@ -2813,7 +2813,7 @@ func runRollback(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to roll back through (API mode only; default: the only logged-in one)") |
| usage := "usage: carlos rollback --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <channel>" |
| fs.Usage = flagUsage(fs, usage) |
| fs.Parse(args) |
| @@ -2968,7 +2968,7 @@ func runLedgerAppend(args []string) { |
| fs := flag.NewFlagSet("ledger append", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to append through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger append --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <entry.json|->" |
| @@ -3016,7 +3016,7 @@ func runLedgerHead(args []string) { |
| fs := flag.NewFlagSet("ledger head", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger head --app <a> [--account <acct>] [--account-id <sqid>] <ledger>" |
| @@ -3046,7 +3046,7 @@ func runLedgerList(args []string) { |
| fs := flag.NewFlagSet("ledger list", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger list --app <a> [--account <acct>] [--account-id <sqid>]" |
| @@ -3097,7 +3097,7 @@ func runLedgerPublish(args []string) { |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| public := fs.Bool("public", true, "serve these ledgers publicly (--public=false unpublishes)") |
| title := fs.String("title", "", "display title for the published set") |
| - consoleFlag := fs.String("console", "", "console origin to publish through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to publish through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger publish --app <a> [--account <acct>] [--account-id <sqid>] [--public=true|false] [--title <t>] <ledger>..." |
| @@ -3141,7 +3141,7 @@ func runLedgerBlob(args []string) { |
| fs := flag.NewFlagSet("ledger blob", flag.ExitOnError) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to upload through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos ledger blob --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <file>..." |
| @@ -3329,7 +3329,7 @@ func RunStatus(args []string) { |
| liveFlag := fs.Bool("live", false, "read status/boxes/*.json from the status bucket and print per-host metrics") |
| instancesFlag := fs.Bool("instances", false, "one row per awake instance across the fleet, with the account and app it belongs to (operator only, through the console)") |
| acctFilter := fs.String("account", "", "narrow --instances to one account (sqid)") |
| - consoleFlag := fs.String("console", "", "console to ask (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, statusUsage) |
| fs.Parse(args) |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index b8af53cf5..57d6a15b5 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -282,6 +282,7 @@ func serve() { |
| SteeringHost: steeringHost(), |
| RedirectHosts: redirectHosts(), |
| DenyDomains: denyDomains(), |
| + ReservePlatformDomains: reservePlatformDomains(), |
| StoreProvisioner: storeProvisioner, |
| StoreScanner: storeScanner, |
| ScanSeedFromDeprecatedEnv: seedAntivirus, |
| @@ -809,14 +810,34 @@ func redirectHosts() []string { |
| return out |
| } |
| +// reservePlatformDomains parses CONSOLE_RESERVE_PLATFORM_DOMAINS, the opt-in |
| +// for a deployment that hosts apps for other people (the flagship, carloku). |
| +// When it is set, `carlos domains attach` also refuses everything under |
| +// PLATFORM_APPS_DOMAIN and under the console's own hostnames |
| +// (console.Config.ReservePlatformDomains). Unset means false. A value that |
| +// strconv.ParseBool cannot read stops the boot. A typo in a security setting |
| +// must not run the console as if the setting were off. |
| +func reservePlatformDomains() bool { |
| + v := strings.TrimSpace(os.Getenv("CONSOLE_RESERVE_PLATFORM_DOMAINS")) |
| + if v == "" { |
| + return false |
| + } |
| + b, err := strconv.ParseBool(v) |
| + if err != nil { |
| + log.Fatalf("console: CONSOLE_RESERVE_PLATFORM_DOMAINS %q is not a boolean (use true or false)", v) |
| + } |
| + return b |
| +} |
| + |
| // denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and |
| // everything under them, that `carlos domains attach` refuses (issue #151). |
| // This is the operator's list of names the console cannot derive on its own: |
| // retired console origins, any other platform-managed domain. The edge's |
| -// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST; the |
| -// console's own origin host and PLATFORM_APPS_DOMAIN are added by the door |
| -// itself (deniedDomains, internal/console/apidomains.go), so an unset |
| -// variable is a legal — and still safe — deployment. Blank entries are dropped: an empty string in the |
| +// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST. The |
| +// door itself adds the console's own hostnames, and, with |
| +// CONSOLE_RESERVE_PLATFORM_DOMAINS, PLATFORM_APPS_DOMAIN (hostDenied, |
| +// internal/console/apidomains.go). An unset variable is therefore a legal |
| +// deployment. Blank entries are dropped: an empty string in the |
| // list would be a deny entry no hostname could avoid matching. |
| func denyDomains() []string { |
| var out []string |
diff --git a/color.go b/color.go| index 7bd96b738..41abb6bb0 100644 |
| --- a/color.go |
| +++ b/color.go |
| @@ -147,7 +147,11 @@ func warnf(w io.Writer, format string, a ...any) { |
| // under it rather than the answer anybody typed the command to get. |
| func printUsing(w io.Writer, sess *apiSession) { |
| c := colorFor(w) |
| - fmt.Fprintln(w, c.muted(fmt.Sprintf("using %s as %s", sess.console, sess.address))) |
| + line := fmt.Sprintf("using %s as %s", sess.console, sess.address) |
| + if d := configDestination(sess.console); d != "" { |
| + line += " (destination " + d + ")" |
| + } |
| + fmt.Fprintln(w, c.muted(line)) |
| } |
| // printProposed is the one line every guarded write (promote, rollback, a |
diff --git a/config.go b/config.go| index 2718325b9..5dd1c58c1 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -18,6 +18,19 @@ |
| // (docs/superpowers/specs/2026-08-04-console-default-resolution-design.md) |
| // because per-origin credentials mean no token ever crosses consoles, and |
| // every verb prints the console it is talking to. |
| +// |
| +// Destinations: one checkout can deploy to several stacks. A file may hold |
| +// a "destinations" object of named blocks, each shaped like the top level |
| +// (console, account, app, kind, artifact, target), and --destination / -d |
| +// (or CARLOS_DESTINATION) picks one: |
| +// |
| +// {"destinations": {"exciting": {"console": "...", "app": "shop"}, |
| +// "tito": {"console": "...", "app": "shop"}}} |
| +// |
| +// The chosen block is laid over the file's top level, so a key shared by |
| +// every destination (say "artifact") can live once at the top. A |
| +// top-level "default" names the block used when nothing is selected. See |
| +// loadConfig for the resolution rules. |
| package main |
| import ( |
| @@ -56,6 +69,55 @@ type cliConfig struct { |
| // MessagePrompt: "off" suppresses the optional mutation-description |
| // question. Any other value, including absent, leaves it enabled. |
| MessagePrompt string `json:"message_prompt"` |
| + |
| + // Destination is the destinations block this view was resolved from |
| + // ("" when the file has none). Never read from or written to the file |
| + // as a key: it names what loadConfig did, for messages and the walk's |
| + // did-anything-define-it check. |
| + Destination string `json:"-"` |
| +} |
| + |
| +// configFile is the on-disk shape: the top level plus the named blocks. |
| +// Blocks are cliConfig too, so a "destinations" key nested inside one is |
| +// simply ignored rather than recursed into. |
| +type configFile struct { |
| + cliConfig |
| + Default string `json:"default"` |
| + Destinations map[string]cliConfig `json:"destinations"` |
| +} |
| + |
| +// destinationFlag is --destination / -d, bound by consoleOption on every |
| +// verb that talks to a console. A package variable rather than a value |
| +// threaded through: the config is read deep inside session resolution, a |
| +// long way from any flag set, and one process runs one verb. |
| +var destinationFlag string |
| + |
| +// selectedDestination is the destination this run asked for: the flag, |
| +// else CARLOS_DESTINATION, else "" (let the files decide). |
| +func selectedDestination() string { |
| + if d := strings.TrimSpace(destinationFlag); d != "" { |
| + return d |
| + } |
| + return strings.TrimSpace(os.Getenv("CARLOS_DESTINATION")) |
| +} |
| + |
| +// fileDefaultDestination is the block a file resolves to when nothing was |
| +// selected: its "default", else its only block. names is never empty. |
| +func fileDefaultDestination(path, def string, names []string) (string, error) { |
| + if def != "" { |
| + for _, n := range names { |
| + if n == def { |
| + return def, nil |
| + } |
| + } |
| + return "", fmt.Errorf("%s: the default destination %q is not one of its destinations (%s)", |
| + path, def, strings.Join(names, ", ")) |
| + } |
| + if len(names) == 1 { |
| + return names[0], nil |
| + } |
| + return "", fmt.Errorf("%s defines several destinations (%s) and no default — pick one with --destination <name> (-d), set CARLOS_DESTINATION, or add \"default\": \"<name>\"", |
| + path, strings.Join(names, ", ")) |
| } |
| func configMessagePrompt() (string, error) { |
| @@ -74,25 +136,49 @@ func configMessagePrompt() (string, error) { |
| // (auth.go credentialsPath) — for tests, and for anyone keeping their |
| // config somewhere other than $HOME's discovery path. |
| func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { |
| - if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| - cfg, err := loadConfig(p) |
| + cfg, src, defined, err := walkConfigFiles(pick) |
| + if err != nil { |
| + return cliConfig{}, "", err |
| + } |
| + // A named destination that no file defines is a typo or a missing |
| + // block — never "no opinion". Falling through would hand the verb to |
| + // the single-credential convenience: a ship to whatever stack this |
| + // machine happens to be logged in to. Checked only when nothing was |
| + // picked, because only then was every file read: a stack key can only |
| + // be picked from a file that defines the destination anyway (loadConfig |
| + // strips it elsewhere), and a preference picked early says nothing |
| + // about the files past it. |
| + if d := selectedDestination(); d != "" && src == "" && !defined { |
| + return cliConfig{}, "", fmt.Errorf("no .carlos/config defines the destination %q", d) |
| + } |
| + return cfg, src, nil |
| +} |
| + |
| +// walkConfigFiles is walkConfig's discovery, also reporting whether any |
| +// file it read defined the selected destination. |
| +func walkConfigFiles(pick func(cliConfig) bool) (cfg cliConfig, src string, defined bool, err error) { |
| + visit := func(path string) (bool, error) { |
| + c, err := loadConfig(path) |
| if err != nil { |
| - return cliConfig{}, "", err |
| + return false, err |
| + } |
| + if c.Destination != "" { |
| + defined = true |
| } |
| - if !pick(cfg) { |
| - return cliConfig{}, "", nil |
| + if pick(c) { |
| + cfg, src = c, path |
| + return true, nil |
| } |
| - return cfg, p, nil |
| + return false, nil |
| + } |
| + if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| + _, err := visit(p) |
| + return cfg, src, defined, err |
| } |
| if dir, werr := os.Getwd(); werr == nil { |
| for { |
| - path := filepath.Join(dir, ".carlos", "config") |
| - cfg, err := loadConfig(path) |
| - if err != nil { |
| - return cliConfig{}, "", err |
| - } |
| - if pick(cfg) { |
| - return cfg, path, nil |
| + if done, err := visit(filepath.Join(dir, ".carlos", "config")); err != nil || done { |
| + return cfg, src, defined, err |
| } |
| parent := filepath.Dir(dir) |
| if parent == dir { |
| @@ -103,16 +189,10 @@ func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { |
| } |
| path, perr := globalConfigPath() |
| if perr != nil { |
| - return cliConfig{}, "", nil |
| - } |
| - cfg, err := loadConfig(path) |
| - if err != nil { |
| - return cliConfig{}, "", err |
| - } |
| - if !pick(cfg) { |
| - return cliConfig{}, "", nil |
| + return cfg, src, defined, nil |
| } |
| - return cfg, path, nil |
| + _, err = visit(path) |
| + return cfg, src, defined, err |
| } |
| // configApp resolves the configured default APP: the nearest file whose |
| @@ -126,6 +206,17 @@ func configApp() (app, source, fileConsole string, err error) { |
| return cfg.App, src, cfg.Console, nil |
| } |
| +// configDestination names the destination block the configured console |
| +// came from, when the session is using that console ("" otherwise: a |
| +// --console to somewhere else is not "destination X"). |
| +func configDestination(console string) string { |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" }) |
| + if err != nil || src == "" || cfg.Console != console { |
| + return "" |
| + } |
| + return cfg.Destination |
| +} |
| + |
| // configDeploy resolves the project's deploy block: the nearest file that |
| // names an artifact, whole — kind/artifact/target travel together, and |
| // mixing halves of two files would ship one project's bytes with |
| @@ -140,6 +231,21 @@ func configDeploy() (cfg cliConfig, source string, err error) { |
| // config would ship to the wrong console, the exact mistake this file |
| // exists to prevent. The console comes back normalized ("" when unset); |
| // the account comes back trimmed. |
| +// |
| +// A file with a "destinations" block resolves to one of them, laid over |
| +// its top level: |
| +// |
| +// - a destination was selected and the file defines it: that block; |
| +// - a destination was selected and the file does NOT define it: the |
| +// file's preferences only (update_check, message_prompt) — its |
| +// console/app/deploy keys describe some other stack. Same for a file |
| +// with no destinations block at all. walkConfig refuses when no file |
| +// defines the selection; |
| +// - nothing selected: the file's "default" (an error if it names no |
| +// block), else its only block; |
| +// - nothing selected, no default, several blocks: an error naming them. |
| +// Guessing, or quietly using the top level, is the wrong deployment |
| +// again. |
| func loadConfig(path string) (cliConfig, error) { |
| raw, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -148,10 +254,26 @@ func loadConfig(path string) (cliConfig, error) { |
| if err != nil { |
| return cliConfig{}, err |
| } |
| - var cfg cliConfig |
| - if err := json.Unmarshal(raw, &cfg); err != nil { |
| + var file configFile |
| + if err := json.Unmarshal(raw, &file); err != nil { |
| return cliConfig{}, fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| } |
| + cfg := file.cliConfig |
| + if name := selectedDestination(); name != "" { |
| + if block, ok := file.Destinations[name]; ok { |
| + cfg = overlayDestination(cfg, block) |
| + cfg.Destination = name |
| + } else { |
| + cfg = cliConfig{UpdateCheck: cfg.UpdateCheck, MessagePrompt: cfg.MessagePrompt} |
| + } |
| + } else if len(file.Destinations) > 0 { |
| + name, err := fileDefaultDestination(path, strings.TrimSpace(file.Default), mapKeys(file.Destinations)) |
| + if err != nil { |
| + return cliConfig{}, err |
| + } |
| + cfg = overlayDestination(cfg, file.Destinations[name]) |
| + cfg.Destination = name |
| + } |
| if strings.TrimSpace(cfg.Console) == "" { |
| cfg.Console = "" |
| } else { |
| @@ -171,6 +293,28 @@ func loadConfig(path string) (cliConfig, error) { |
| return cfg, nil |
| } |
| +// overlayDestination lays a destination block over the file's top level: |
| +// every key the block sets wins. Preferences (update_check, |
| +// message_prompt) are machine-wide in spirit and stay top-level only. |
| +func overlayDestination(top, block cliConfig) cliConfig { |
| + for _, kv := range []struct { |
| + dst *string |
| + v string |
| + }{ |
| + {&top.Console, block.Console}, |
| + {&top.Account, block.Account}, |
| + {&top.App, block.App}, |
| + {&top.Kind, block.Kind}, |
| + {&top.Artifact, block.Artifact}, |
| + {&top.Target, block.Target}, |
| + } { |
| + if strings.TrimSpace(kv.v) != "" { |
| + *kv.dst = kv.v |
| + } |
| + } |
| + return top |
| +} |
| + |
| // loadConfigFile is loadConfig's console-only view — the shape every |
| // console-default caller predating the account key still reads. |
| func loadConfigFile(path string) (string, error) { |
| @@ -190,50 +334,11 @@ func loadConfigFile(path string) (string, error) { |
| // $HOME entirely. |
| // |
| // CARLOS_CONFIG, when set, names the one file consulted and skips discovery |
| -// entirely — see walkConfig's identical override, which every other |
| -// per-key default (account, app, update_check) already goes through. An |
| -// unset CARLOS_CONFIG leaves this walk exactly as it was. |
| +// entirely — walkConfig's override, since this is walkConfig picking on |
| +// the console key. |
| func configDefault() (console, source string, err error) { |
| - if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| - c, err := loadConfigFile(p) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - if c == "" { |
| - return "", "", nil |
| - } |
| - return c, p, nil |
| - } |
| - if dir, werr := os.Getwd(); werr == nil { |
| - for { |
| - path := filepath.Join(dir, ".carlos", "config") |
| - c, err := loadConfigFile(path) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - if c != "" { |
| - return c, path, nil |
| - } |
| - parent := filepath.Dir(dir) |
| - if parent == dir { |
| - break |
| - } |
| - dir = parent |
| - } |
| - } |
| - path, perr := globalConfigPath() |
| - if perr != nil { |
| - // No home directory means no global config — a state, not an error. |
| - return "", "", nil |
| - } |
| - c, err := loadConfigFile(path) |
| - if err != nil { |
| - return "", "", err |
| - } |
| - if c == "" { |
| - return "", "", nil |
| - } |
| - return c, path, nil |
| + cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" }) |
| + return cfg.Console, src, err |
| } |
| // configAccount resolves the configured default ACCOUNT: the nearest |
| @@ -295,21 +400,52 @@ func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error |
| // every future config key lossy against the binary that predates it. |
| // Temp-file-then-rename like credentialFile.save, so a crash cannot leave |
| // a truncated config — but 0644, not 0600: this file is not a secret. |
| +// |
| +// Keys land where loadConfig would read them back: inside the selected |
| +// destination's block (created if missing), else inside the file's |
| +// default or only block, else at the top level. A file that cannot say |
| +// which block is refused, as it is on read. |
| func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error { |
| - dir := filepath.Dir(path) |
| - if err := os.MkdirAll(dir, dirPerm); err != nil { |
| + all, err := readConfigMap(path) |
| + if err != nil { |
| return err |
| } |
| - all := map[string]any{} |
| - if raw, err := os.ReadFile(path); err == nil { |
| - if err := json.Unmarshal(raw, &all); err != nil { |
| - return fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| - } |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| + into, err := configWriteTarget(path, all) |
| + if err != nil { |
| return err |
| } |
| for k, v := range set { |
| - all[k] = v |
| + into[k] = v |
| + } |
| + return writeConfigMap(path, all, dirPerm) |
| +} |
| + |
| +// readConfigMap reads a config as raw JSON, every key kept — the form a |
| +// rewrite must start from. A missing file is an empty map. |
| +func readConfigMap(path string) (map[string]any, error) { |
| + all := map[string]any{} |
| + raw, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return all, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := json.Unmarshal(raw, &all); err != nil { |
| + return nil, fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| + } |
| + if all == nil { // the file said null |
| + all = map[string]any{} |
| + } |
| + return all, nil |
| +} |
| + |
| +// writeConfigMap writes all to path, temp-file-then-rename (see |
| +// writeConfigKeys). |
| +func writeConfigMap(path string, all map[string]any, dirPerm os.FileMode) error { |
| + dir := filepath.Dir(path) |
| + if err := os.MkdirAll(dir, dirPerm); err != nil { |
| + return err |
| } |
| body, err := json.MarshalIndent(all, "", " ") |
| if err != nil { |
| @@ -334,3 +470,38 @@ func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error |
| } |
| return os.Rename(tmp.Name(), path) |
| } |
| + |
| +// configWriteTarget picks the object inside a parsed config that |
| +// writeConfigKeys should set keys on — see its comment for the rule. |
| +func configWriteTarget(path string, all map[string]any) (map[string]any, error) { |
| + var dests map[string]any |
| + if raw, ok := all["destinations"]; ok { |
| + if dests, ok = raw.(map[string]any); !ok { |
| + return nil, fmt.Errorf("%s: \"destinations\" is not an object", path) |
| + } |
| + } |
| + name := selectedDestination() |
| + if name == "" { |
| + if len(dests) == 0 { |
| + return all, nil |
| + } |
| + def, _ := all["default"].(string) |
| + var err error |
| + if name, err = fileDefaultDestination(path, strings.TrimSpace(def), mapKeys(dests)); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if dests == nil { |
| + dests = map[string]any{} |
| + all["destinations"] = dests |
| + } |
| + block, ok := dests[name].(map[string]any) |
| + if !ok { |
| + if _, exists := dests[name]; exists { |
| + return nil, fmt.Errorf("%s: destination %q is not an object", path, name) |
| + } |
| + block = map[string]any{} |
| + dests[name] = block |
| + } |
| + return block, nil |
| +} |
diff --git a/config_cli.go b/config_cli.go| new file mode 100644 |
| index 000000000..7e11ba5f9 |
| --- /dev/null |
| +++ b/config_cli.go |
| @@ -0,0 +1,691 @@ |
| +// config_cli.go is `carlos config`: commands that edit .carlos/config |
| +// (config.go) so nobody has to hand-edit its JSON. One subcommand so far, |
| +// destinations — the named deploy blocks `--destination` / -d picks from |
| +// (amadan discussion #157). |
| +// |
| +// These verbs work on the raw file (readConfigMap), never through |
| +// loadConfig: a file with several destinations and no default is exactly |
| +// the state loadConfig refuses, and `destinations default <name>` has to be |
| +// able to fix it. |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "text/tabwriter" |
| + "time" |
| +) |
| + |
| +const configUsage = `usage: carlos config <subcommand> |
| + |
| + destinations the named deploy destinations in .carlos/config: |
| + list | add | set | default | remove | rename |
| + |
| +"carlos config destinations help" describes each verb. |
| +` |
| + |
| +const destinationsUsage = `usage: carlos config destinations <verb> [flags] |
| + |
| + list each destination's console, account, app and |
| + artifact; * marks the one used without -d |
| + add <name> --console <url> add one; --account, --app, --kind, --artifact |
| + and --target set its other keys, and |
| + --default makes it the default |
| + set <name> --<key> <value> change a destination's keys; "" removes one |
| + default [<name>] show or set the destination used without -d |
| + remove <name> delete one |
| + rename <old> <new> rename one; the default follows it |
| + |
| +Each verb works on the nearest project .carlos/config, walking up from this |
| +directory (creating ./.carlos/config when there is none); --global works on |
| +~/.carlos/config instead. A key a destination leaves out comes from the top |
| +of the file. |
| +` |
| + |
| +// RunConfig is `carlos config`. |
| +func RunConfig(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, configUsage) |
| + os.Exit(1) |
| + } |
| + var err error |
| + switch args[0] { |
| + case "destinations": |
| + err = configDestinations(os.Stdout, args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, configUsage) |
| + os.Exit(1) |
| + } |
| + if errors.Is(err, flag.ErrHelp) { |
| + return // the flag set already printed its usage |
| + } |
| + if errors.As(err, new(flagParseError)) { |
| + os.Exit(2) // the flag set already printed the error and its usage |
| + } |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos config "+args[0]+":"), err) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// configDestinations dispatches `carlos config destinations <verb>`. No |
| +// verb, or a flag first, is list. |
| +func configDestinations(out io.Writer, args []string) error { |
| + verb := "list" |
| + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { |
| + verb, args = args[0], args[1:] |
| + } |
| + switch verb { |
| + case "list", "ls": |
| + return destinationsList(out, args) |
| + case "add": |
| + return destinationsAdd(out, args) |
| + case "set": |
| + return destinationsSet(out, args) |
| + case "default": |
| + return destinationsDefault(out, args) |
| + case "remove", "rm": |
| + return destinationsRemove(out, args) |
| + case "rename": |
| + return destinationsRename(out, args) |
| + case "help": |
| + fmt.Fprint(out, destinationsUsage) |
| + return nil |
| + } |
| + return fmt.Errorf("no verb %q\n%s", verb, destinationsUsage) |
| +} |
| + |
| +// destinationKeys are the keys a destination block can set — cliConfig's |
| +// per-stack fields — in the order flags and messages present them. |
| +var destinationKeys = []string{"console", "account", "app", "kind", "artifact", "target"} |
| + |
| +// whereKeys are the keys that say WHERE a file deploys. A file that sets |
| +// any of them at the top level, with no destinations yet, must have them |
| +// moved into a named block before a destination is added — otherwise the |
| +// new block would silently become the only one, and so the default. |
| +var whereKeys = []string{"console", "account", "app"} |
| + |
| +var destinationNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) |
| + |
| +// validDestinationName keeps names typeable as `-d <name>` and unambiguous |
| +// in a shell: no spaces, and never a leading "-". |
| +func validDestinationName(n string) error { |
| + if !destinationNameRE.MatchString(n) { |
| + return fmt.Errorf("%q is not a usable destination name: use letters, digits, '.', '_' and '-', starting with a letter or digit", n) |
| + } |
| + return nil |
| +} |
| + |
| +// destinationKeyFlags registers one flag per destination key, --app with |
| +// its claimed -a alias. |
| +func destinationKeyFlags(fs *flag.FlagSet) map[string]*string { |
| + return map[string]*string{ |
| + "console": fs.String("console", "", "the console `url` this destination deploys through"), |
| + "account": fs.String("account", "", "the `account` (sqid or name) the app belongs to"), |
| + "app": appFlag(fs, "app name"), |
| + "kind": fs.String("kind", "", "the `kind` of artifact: binary or static"), |
| + "artifact": fs.String("artifact", "", "the `path` a bare carlos deploy ships: a binary, or a site directory, inside the project"), |
| + "target": fs.String("target", "", "the `os-arch` the binary is built for, like linux-arm64 (binary kind)"), |
| + } |
| +} |
| + |
| +// applyDestinationFlags copies every destination-key flag the user typed |
| +// into block, normalizing the console, and reports how many it changed. |
| +// With clear, an empty value removes the key; without, it is ignored. |
| +func applyDestinationFlags(fs *flag.FlagSet, vals map[string]*string, block map[string]any, clear bool) (int, error) { |
| + n := 0 |
| + for _, k := range destinationKeys { |
| + if !flagWasSet(fs, k) { |
| + continue |
| + } |
| + v := strings.TrimSpace(*vals[k]) |
| + if v == "" { |
| + if clear { |
| + delete(block, k) |
| + n++ |
| + } |
| + continue |
| + } |
| + switch k { |
| + case "console": |
| + c, err := normalizeConsole(v) |
| + if err != nil { |
| + return 0, err |
| + } |
| + v = c |
| + case "kind": |
| + if v != "binary" && v != "static" { |
| + return 0, fmt.Errorf("--kind %q: want binary or static", v) |
| + } |
| + } |
| + block[k] = v |
| + n++ |
| + } |
| + return n, nil |
| +} |
| + |
| +// flagParseError is a flag the flag set refused. It has printed the error |
| +// and its usage already, so RunConfig exits without a second copy. |
| +type flagParseError struct{ error } |
| + |
| +func (e flagParseError) Unwrap() error { return e.error } |
| + |
| +// parseInterspersed parses flags on either side of the positional |
| +// arguments: Go's flag package stops at the first non-flag word, so |
| +// `add tito --console <url>` would otherwise leave --console unparsed. |
| +func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) { |
| + var pos []string |
| + rest := args |
| + for { |
| + if err := fs.Parse(rest); err != nil { |
| + if errors.Is(err, flag.ErrHelp) { |
| + return nil, err |
| + } |
| + return nil, flagParseError{err} |
| + } |
| + rest = fs.Args() |
| + if len(rest) == 0 { |
| + return pos, nil |
| + } |
| + pos = append(pos, rest[0]) |
| + rest = rest[1:] |
| + } |
| +} |
| + |
| +// destinationsFlagSet is the flag set every verb starts from: --global, |
| +// and the usage line it prints for -h. |
| +func destinationsFlagSet(out io.Writer, verb, usage string) (*flag.FlagSet, *bool) { |
| + fs := flag.NewFlagSet("config destinations "+verb, flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + global := fs.Bool("global", false, "work on ~/.carlos/config instead of the project's") |
| + fs.Usage = flagUsage(fs, "usage: carlos config destinations "+usage) |
| + return fs, global |
| +} |
| + |
| +// destinationsFile is the file a verb works on and the mode to create its |
| +// directory with: CARLOS_CONFIG when set (config.go's one-file override); |
| +// else ~/.carlos/config under --global; else the nearest project |
| +// .carlos/config walking up from here, else ./.carlos/config. The walk |
| +// skips ~/.carlos/config, which it meets on the way up through $HOME — |
| +// editing the global file must be asked for. |
| +func destinationsFile(global bool) (string, os.FileMode, error) { |
| + if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| + return p, 0o755, nil |
| + } |
| + gp, gerr := globalConfigPath() |
| + if global { |
| + return gp, 0o700, gerr // ~/.carlos also holds credentials |
| + } |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + return "", 0, err |
| + } |
| + for dir := cwd; ; { |
| + p := filepath.Join(dir, ".carlos", "config") |
| + if p != gp { |
| + if _, err := os.Stat(p); err == nil { |
| + return p, 0o755, nil |
| + } |
| + } |
| + parent := filepath.Dir(dir) |
| + if parent == dir { |
| + break |
| + } |
| + dir = parent |
| + } |
| + p := filepath.Join(cwd, ".carlos", "config") |
| + if p == gp { |
| + return "", 0, errors.New("this directory's .carlos/config is the global ~/.carlos/config — run this in a project, or pass --global") |
| + } |
| + return p, 0o755, nil |
| +} |
| + |
| +// destinationsDoc is one config file opened for editing: every key kept, |
| +// the destinations object pulled out. |
| +type destinationsDoc struct { |
| + path string |
| + all map[string]any |
| + dests map[string]any // nil or empty: the file has no destinations |
| +} |
| + |
| +func openDestinations(path string) (*destinationsDoc, error) { |
| + all, err := readConfigMap(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + d := &destinationsDoc{path: path, all: all, dests: map[string]any{}} |
| + if raw, ok := all["destinations"]; ok && raw != nil { |
| + m, ok := raw.(map[string]any) |
| + if !ok { |
| + return nil, fmt.Errorf("%s: \"destinations\" is not an object", path) |
| + } |
| + d.dests = m |
| + } |
| + return d, nil |
| +} |
| + |
| +func (d *destinationsDoc) names() []string { return mapKeys(d.dests) } |
| + |
| +// block is a destination's object, or an error naming the ones there are. |
| +func (d *destinationsDoc) block(name string) (map[string]any, error) { |
| + raw, ok := d.dests[name] |
| + if !ok { |
| + if len(d.dests) == 0 { |
| + return nil, fmt.Errorf("%s has no destinations", d.path) |
| + } |
| + return nil, fmt.Errorf("%s has no destination %q (it has: %s)", d.path, name, strings.Join(d.names(), ", ")) |
| + } |
| + m, ok := raw.(map[string]any) |
| + if !ok { |
| + return nil, fmt.Errorf("%s: destination %q is not an object", d.path, name) |
| + } |
| + return m, nil |
| +} |
| + |
| +func (d *destinationsDoc) explicitDefault() string { return configString(d.all, "default") } |
| + |
| +// resolvedDefault is what loadConfig uses with no destination selected, |
| +// or the reason it would refuse. |
| +func (d *destinationsDoc) resolvedDefault() (string, error) { |
| + if len(d.dests) == 0 { |
| + return "", nil |
| + } |
| + return fileDefaultDestination(d.path, d.explicitDefault(), d.names()) |
| +} |
| + |
| +// effective is a key's value for a destination as loadConfig resolves it: |
| +// the block's, else the top of the file's. |
| +func (d *destinationsDoc) effective(name, key string) string { |
| + if b, ok := d.dests[name].(map[string]any); ok { |
| + if v := configString(b, key); v != "" { |
| + return v |
| + } |
| + } |
| + return configString(d.all, key) |
| +} |
| + |
| +func (d *destinationsDoc) save(dirPerm os.FileMode) error { |
| + if len(d.dests) == 0 { |
| + delete(d.all, "destinations") |
| + delete(d.all, "default") |
| + } else { |
| + d.all["destinations"] = d.dests |
| + } |
| + return writeConfigMap(d.path, d.all, dirPerm) |
| +} |
| + |
| +func configString(m map[string]any, key string) string { |
| + s, _ := m[key].(string) |
| + return strings.TrimSpace(s) |
| +} |
| + |
| +// warnNotLoggedIn names the login a destination's console still needs. |
| +// Advice only: a destination may be written before its login, or |
| +// committed for colleagues who have their own. |
| +func warnNotLoggedIn(out io.Writer, console string) { |
| + f, err := loadCredentials() |
| + if err != nil { |
| + return |
| + } |
| + switch c := f.find(console); { |
| + case c == nil: |
| + warnf(out, "not logged in to %s yet — run: carlos auth login --console %s", console, console) |
| + case c.Expired(time.Now()): |
| + warnf(out, "your login to %s has expired — run: carlos auth login --console %s", console, console) |
| + } |
| +} |
| + |
| +func destinationsList(out io.Writer, args []string) error { |
| + fs, global := destinationsFlagSet(out, "list", "list [--global]") |
| + pos, err := parseInterspersed(fs, args) |
| + if err != nil { |
| + return err |
| + } |
| + if len(pos) != 0 { |
| + return errors.New("usage: carlos config destinations list [--global]") |
| + } |
| + path, _, err := destinationsFile(*global) |
| + if err != nil { |
| + return err |
| + } |
| + d, err := openDestinations(path) |
| + if err != nil { |
| + return err |
| + } |
| + if len(d.dests) == 0 { |
| + fmt.Fprintf(out, "no destinations in %s\n", path) |
| + if c := configString(d.all, "console"); c != "" { |
| + fmt.Fprintf(out, "without one it deploys to %s\n", c) |
| + } |
| + mutedf(out, "add one with: carlos config destinations add <name> --console <url>") |
| + return nil |
| + } |
| + def, defErr := d.resolvedDefault() |
| + fmt.Fprintf(out, "destinations in %s\n", path) |
| + w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, " NAME\tCONSOLE\tACCOUNT\tAPP\tARTIFACT") |
| + for _, n := range d.names() { |
| + if _, err := d.block(n); err != nil { |
| + return err |
| + } |
| + mark := " " |
| + if n == def { |
| + mark = "*" |
| + } |
| + cell := func(k string) string { |
| + if v := d.effective(n, k); v != "" { |
| + return v |
| + } |
| + return "-" |
| + } |
| + fmt.Fprintf(w, "%s %s\t%s\t%s\t%s\t%s\n", mark, n, cell("console"), cell("account"), cell("app"), cell("artifact")) |
| + } |
| + w.Flush() |
| + if defErr != nil { |
| + warnf(out, "%v", defErr) |
| + } else { |
| + mutedf(out, "* is used when no destination is named (-d, CARLOS_DESTINATION)") |
| + } |
| + return nil |
| +} |
| + |
| +func destinationsAdd(out io.Writer, args []string) error { |
| + fs, global := destinationsFlagSet(out, "add", "add <name> --console <url> [flags]") |
| + vals := destinationKeyFlags(fs) |
| + makeDefault := fs.Bool("default", false, "make it the destination used when none is named") |
| + fromCurrent := fs.String("from-current", "", "first destination only: the `name` to move this file's existing console, account and app into, so a bare command keeps deploying where it did") |
| + pos, err := parseInterspersed(fs, args) |
| + if err != nil { |
| + return err |
| + } |
| + if len(pos) != 1 { |
| + return errors.New("usage: carlos config destinations add <name> --console <url> [--account <a>] [--app <a>] [--kind binary|static] [--artifact <path>] [--target <os-arch>] [--default] [--global]") |
| + } |
| + name := pos[0] |
| + if err := validDestinationName(name); err != nil { |
| + return err |
| + } |
| + path, perm, err := destinationsFile(*global) |
| + if err != nil { |
| + return err |
| + } |
| + d, err := openDestinations(path) |
| + if err != nil { |
| + return err |
| + } |
| + if _, exists := d.dests[name]; exists { |
| + return fmt.Errorf("%s already has a destination %q — change it with: carlos config destinations set %s --<key> <value>", path, name, name) |
| + } |
| + block := map[string]any{} |
| + if _, err := applyDestinationFlags(fs, vals, block, false); err != nil { |
| + return err |
| + } |
| + |
| + var notes []string |
| + current := strings.TrimSpace(*fromCurrent) |
| + var moving []string |
| + for _, k := range whereKeys { |
| + if configString(d.all, k) != "" { |
| + moving = append(moving, k) |
| + } |
| + } |
| + switch { |
| + case len(d.dests) == 0 && len(moving) > 0: |
| + // The file already deploys somewhere. Adding a block without moving |
| + // those keys would make the new block the only one — the default — |
| + // and every bare command here would quietly switch stacks. |
| + if current == "" { |
| + if !interactiveTerminal() { |
| + return fmt.Errorf("%s already deploys to %s without a destination — name that destination too, so it keeps working: add --from-current <name>", |
| + path, describeWhere(d.all)) |
| + } |
| + fmt.Fprintf(out, "%s already deploys to %s without a destination.\n", path, describeWhere(d.all)) |
| + fmt.Fprintln(out, "Those settings move into a destination of their own, which stays the default.") |
| + if current = promptLine(out, "name for it: "); current == "" { |
| + return errors.New("nothing changed") |
| + } |
| + } |
| + if err := validDestinationName(current); err != nil { |
| + return err |
| + } |
| + if current == name { |
| + return fmt.Errorf("the current settings and the new destination need different names, not both %q", name) |
| + } |
| + moved := map[string]any{} |
| + for _, k := range moving { |
| + moved[k] = d.all[k] |
| + delete(d.all, k) |
| + } |
| + d.dests[current] = moved |
| + d.all["default"] = current |
| + notes = append(notes, fmt.Sprintf("moved this file's %s into destination %s, the default", strings.Join(moving, ", "), current)) |
| + case current != "": |
| + return errors.New("--from-current is only for a file that has settings and no destinations yet") |
| + case len(d.dests) == 1 && d.explicitDefault() == "" && !*makeDefault: |
| + // The one block was the default by being alone; a second one would |
| + // leave the file with no default at all, and every bare command here |
| + // refusing. Pin it. |
| + for only := range d.dests { |
| + d.all["default"] = only |
| + notes = append(notes, fmt.Sprintf("%s stays the default", only)) |
| + } |
| + } |
| + d.dests[name] = block |
| + if *makeDefault { |
| + d.all["default"] = name |
| + notes = append(notes, fmt.Sprintf("%s is now the default", name)) |
| + } |
| + console := d.effective(name, "console") |
| + if console == "" { |
| + return fmt.Errorf("destination %s would name no console — pass --console <url>", name) |
| + } |
| + if err := d.save(perm); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(out, "added destination %s to %s\n", name, path) |
| + for _, n := range notes { |
| + mutedf(out, "%s", n) |
| + } |
| + warnNotLoggedIn(out, console) |
| + return nil |
| +} |
| + |
| +// describeWhere is "console (account a, app x)" for a file's top level. |
| +func describeWhere(all map[string]any) string { |
| + s := configString(all, "console") |
| + if s == "" { |
| + s = "its default console" |
| + } |
| + var extra []string |
| + for _, k := range []string{"account", "app"} { |
| + if v := configString(all, k); v != "" { |
| + extra = append(extra, k+" "+v) |
| + } |
| + } |
| + if len(extra) > 0 { |
| + s += " (" + strings.Join(extra, ", ") + ")" |
| + } |
| + return s |
| +} |
| + |
| +func destinationsSet(out io.Writer, args []string) error { |
| + fs, global := destinationsFlagSet(out, "set", "set <name> --<key> <value> [flags]") |
| + vals := destinationKeyFlags(fs) |
| + pos, err := parseInterspersed(fs, args) |
| + if err != nil { |
| + return err |
| + } |
| + if len(pos) != 1 { |
| + return errors.New("usage: carlos config destinations set <name> [--console <url>] [--account <a>] [--app <a>] [--kind binary|static] [--artifact <path>] [--target <os-arch>] [--global]") |
| + } |
| + name := pos[0] |
| + path, perm, err := destinationsFile(*global) |
| + if err != nil { |
| + return err |
| + } |
| + d, err := openDestinations(path) |
| + if err != nil { |
| + return err |
| + } |
| + block, err := d.block(name) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := applyDestinationFlags(fs, vals, block, true) |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return errors.New("nothing to change — pass one or more of --console, --account, --app, --kind, --artifact, --target (\"\" removes a key)") |
| + } |
| + console := d.effective(name, "console") |
| + if console == "" { |
| + return fmt.Errorf("destination %s would name no console — it needs --console <url>", name) |
| + } |
| + if err := d.save(perm); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(out, "updated destination %s in %s\n", name, path) |
| + if flagWasSet(fs, "console") { |
| + warnNotLoggedIn(out, console) |
| + } |
| + return nil |
| +} |
| + |
| +func destinationsDefault(out io.Writer, args []string) error { |
| + fs, global := destinationsFlagSet(out, "default", "default [<name>] [--global]") |
| + pos, err := parseInterspersed(fs, args) |
| + if err != nil { |
| + return err |
| + } |
| + path, perm, err := destinationsFile(*global) |
| + if err != nil { |
| + return err |
| + } |
| + d, err := openDestinations(path) |
| + if err != nil { |
| + return err |
| + } |
| + switch len(pos) { |
| + case 0: |
| + if len(d.dests) == 0 { |
| + fmt.Fprintf(out, "no destinations in %s\n", path) |
| + return nil |
| + } |
| + def, err := d.resolvedDefault() |
| + if err != nil { |
| + return err |
| + } |
| + if d.explicitDefault() == "" { |
| + fmt.Fprintf(out, "%s (the only destination in %s)\n", def, path) |
| + } else { |
| + fmt.Fprintf(out, "%s (from %s)\n", def, path) |
| + } |
| + return nil |
| + case 1: |
| + if _, err := d.block(pos[0]); err != nil { |
| + return err |
| + } |
| + d.all["default"] = pos[0] |
| + if err := d.save(perm); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(out, "default destination: %s (written to %s)\n", pos[0], path) |
| + return nil |
| + } |
| + return errors.New("usage: carlos config destinations default [<name>] [--global]") |
| +} |
| + |
| +func destinationsRemove(out io.Writer, args []string) error { |
| + fs, global := destinationsFlagSet(out, "remove", "remove <name> [--global]") |
| + pos, err := parseInterspersed(fs, args) |
| + if err != nil { |
| + return err |
| + } |
| + if len(pos) != 1 { |
| + return errors.New("usage: carlos config destinations remove <name> [--global]") |
| + } |
| + name := pos[0] |
| + path, perm, err := destinationsFile(*global) |
| + if err != nil { |
| + return err |
| + } |
| + d, err := openDestinations(path) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := d.block(name); err != nil { |
| + return err |
| + } |
| + wasDefault := d.explicitDefault() == name |
| + if wasDefault && len(d.dests) > 2 { |
| + return fmt.Errorf("%s is the default destination — choose another first: carlos config destinations default <name>", name) |
| + } |
| + delete(d.dests, name) |
| + var note string |
| + switch { |
| + case len(d.dests) == 0: |
| + if c := configString(d.all, "console"); c != "" { |
| + note = "no destinations left: this file deploys to " + c + " again" |
| + } else { |
| + note = "no destinations left, and nothing else in this file names a console" |
| + } |
| + case wasDefault: |
| + delete(d.all, "default") |
| + note = d.names()[0] + " is now the default, as the only destination left" |
| + } |
| + if err := d.save(perm); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(out, "removed destination %s from %s\n", name, path) |
| + if note != "" { |
| + mutedf(out, "%s", note) |
| + } |
| + return nil |
| +} |
| + |
| +func destinationsRename(out io.Writer, args []string) error { |
| + fs, global := destinationsFlagSet(out, "rename", "rename <old> <new> [--global]") |
| + pos, err := parseInterspersed(fs, args) |
| + if err != nil { |
| + return err |
| + } |
| + if len(pos) != 2 { |
| + return errors.New("usage: carlos config destinations rename <old> <new> [--global]") |
| + } |
| + from, to := pos[0], pos[1] |
| + if err := validDestinationName(to); err != nil { |
| + return err |
| + } |
| + path, perm, err := destinationsFile(*global) |
| + if err != nil { |
| + return err |
| + } |
| + d, err := openDestinations(path) |
| + if err != nil { |
| + return err |
| + } |
| + block, err := d.block(from) |
| + if err != nil { |
| + return err |
| + } |
| + if _, exists := d.dests[to]; exists { |
| + return fmt.Errorf("%s already has a destination %q", path, to) |
| + } |
| + d.dests[to] = block |
| + delete(d.dests, from) |
| + if d.explicitDefault() == from { |
| + d.all["default"] = to |
| + } |
| + if err := d.save(perm); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(out, "renamed destination %s to %s in %s\n", from, to, path) |
| + return nil |
| +} |
diff --git a/config_cli_test.go b/config_cli_test.go| new file mode 100644 |
| index 000000000..68cab204f |
| --- /dev/null |
| +++ b/config_cli_test.go |
| @@ -0,0 +1,340 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// destinationsEnv is a scratch project with no config anywhere, no |
| +// credentials, no destination selected and no terminal. It returns the |
| +// project directory, which is the working directory. |
| +func destinationsEnv(t *testing.T) string { |
| + t.Helper() |
| + useTempCredentials(t) |
| + t.Setenv("CARLOS_CONFIG", "") |
| + t.Setenv("CARLOS_DESTINATION", "") |
| + selectDestination(t, "") |
| + forceTerminal(t, false) |
| + project := t.TempDir() |
| + t.Chdir(project) |
| + return project |
| +} |
| + |
| +func runDestinations(args ...string) (string, error) { |
| + var b bytes.Buffer |
| + err := configDestinations(&b, args) |
| + return b.String(), err |
| +} |
| + |
| +func mustDestinations(t *testing.T, args ...string) string { |
| + t.Helper() |
| + out, err := runDestinations(args...) |
| + if err != nil { |
| + t.Fatalf("config destinations %v: %v\n%s", args, err, out) |
| + } |
| + return out |
| +} |
| + |
| +func readConfigJSON(t *testing.T, path string) map[string]any { |
| + t.Helper() |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m map[string]any |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + return m |
| +} |
| + |
| +func destBlock(t *testing.T, m map[string]any, name string) map[string]any { |
| + t.Helper() |
| + b, ok := m["destinations"].(map[string]any)[name].(map[string]any) |
| + if !ok { |
| + t.Fatalf("no destination %q in %v", name, m) |
| + } |
| + return b |
| +} |
| + |
| +// Adding to a file that already deploys somewhere must not silently move |
| +// it: without a terminal the add is refused until the current settings |
| +// are named, and --from-current moves exactly the where-keys into a block |
| +// that stays the default. Shared build keys stay at the top. |
| +func TestDestinationsAddConvertsAFlatFile(t *testing.T) { |
| + project := destinationsEnv(t) |
| + path := writeConfig(t, project, `{"console":"https://a.example.com","account":"acme","app":"shop","artifact":"./bin/server","update_check":"off"}`) |
| + |
| + if _, err := runDestinations("add", "tito", "--console", "https://tito.example.com"); err == nil || |
| + !strings.Contains(err.Error(), "--from-current") { |
| + t.Fatalf("flat-file add without --from-current = %v, want a refusal naming the flag", err) |
| + } |
| + if m := readConfigJSON(t, path); m["destinations"] != nil { |
| + t.Fatalf("a refused add wrote the file: %v", m) |
| + } |
| + |
| + mustDestinations(t, "add", "tito", "--console", "https://tito.example.com", "--from-current", "exciting") |
| + m := readConfigJSON(t, path) |
| + ex := destBlock(t, m, "exciting") |
| + if ex["console"] != "https://a.example.com" || ex["account"] != "acme" || ex["app"] != "shop" { |
| + t.Fatalf("moved block = %v", ex) |
| + } |
| + if m["console"] != nil || m["account"] != nil || m["app"] != nil { |
| + t.Fatalf("where-keys left at the top: %v", m) |
| + } |
| + if m["artifact"] != "./bin/server" || m["update_check"] != "off" || m["default"] != "exciting" { |
| + t.Fatalf("shared keys or default wrong: %v", m) |
| + } |
| + |
| + // What loadConfig makes of it: bare → exciting as before; -d tito → |
| + // tito's console, inheriting the shared artifact. |
| + if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://a.example.com" || cfg.App != "shop" { |
| + t.Fatalf("bare read after convert = %+v, %v", cfg, err) |
| + } |
| + selectDestination(t, "tito") |
| + if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://tito.example.com" || cfg.Artifact != "./bin/server" { |
| + t.Fatalf("tito read = %+v, %v", cfg, err) |
| + } |
| +} |
| + |
| +// At a terminal the name for the current settings is asked for instead. |
| +func TestDestinationsAddConvertsAtATerminal(t *testing.T) { |
| + project := destinationsEnv(t) |
| + forceTerminal(t, true) |
| + old := chooserIn |
| + chooserIn = strings.NewReader("exciting\n") |
| + t.Cleanup(func() { chooserIn = old }) |
| + path := writeConfig(t, project, `{"console":"https://a.example.com"}`) |
| + out := mustDestinations(t, "add", "tito", "--console", "https://tito.example.com") |
| + if !strings.Contains(out, "name for it:") { |
| + t.Fatalf("no prompt:\n%s", out) |
| + } |
| + if m := readConfigJSON(t, path); destBlock(t, m, "exciting")["console"] != "https://a.example.com" || m["default"] != "exciting" { |
| + t.Fatalf("terminal convert = %v", m) |
| + } |
| +} |
| + |
| +func TestDestinationsAdd(t *testing.T) { |
| + project := destinationsEnv(t) |
| + path := filepath.Join(project, ".carlos", "config") |
| + |
| + // Flags on either side of the name; the console is normalized; the |
| + // file and its directory are created. |
| + out := mustDestinations(t, "add", "--console", "https://a.example.com/", "prod", "-a", "shop", "--kind", "binary") |
| + if !strings.Contains(out, "not logged in to https://a.example.com") { |
| + t.Fatalf("no login warning:\n%s", out) |
| + } |
| + m := readConfigJSON(t, path) |
| + if p := destBlock(t, m, "prod"); p["console"] != "https://a.example.com" || p["app"] != "shop" || p["kind"] != "binary" { |
| + t.Fatalf("prod = %v", p) |
| + } |
| + if m["default"] != nil { |
| + t.Fatalf("a lone destination needs no default: %v", m) |
| + } |
| + |
| + // The second add pins the first as the default, so bare commands do |
| + // not start refusing. |
| + out = mustDestinations(t, "add", "staging", "--console", "https://a.example.com", "--app", "shop-staging") |
| + if !strings.Contains(out, "prod stays the default") { |
| + t.Fatalf("no default note:\n%s", out) |
| + } |
| + if m = readConfigJSON(t, path); m["default"] != "prod" { |
| + t.Fatalf("default = %v", m["default"]) |
| + } |
| + // --default moves it. |
| + mustDestinations(t, "add", "eu", "--console", "https://eu.example.com", "--default") |
| + if m = readConfigJSON(t, path); m["default"] != "eu" { |
| + t.Fatalf("--default = %v", m["default"]) |
| + } |
| + |
| + for _, c := range []struct { |
| + args []string |
| + want string |
| + }{ |
| + {[]string{"add", "prod", "--console", "https://x.example.com"}, "already has"}, |
| + {[]string{"add", "noconsole", "--app", "x"}, "no console"}, |
| + {[]string{"add", "bad name", "--console", "https://x.example.com"}, "not a usable destination name"}, |
| + {[]string{"add", "k", "--console", "https://x.example.com", "--kind", "docker"}, "binary or static"}, |
| + {[]string{"add", "u", "--console", "ftp://x"}, ""}, |
| + {[]string{"add", "f", "--console", "https://x.example.com", "--from-current", "g"}, "--from-current is only for"}, |
| + {[]string{"add"}, "usage:"}, |
| + } { |
| + if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) { |
| + t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want) |
| + } |
| + } |
| + if m2 := readConfigJSON(t, path); len(m2["destinations"].(map[string]any)) != 3 { |
| + t.Fatalf("a refused add changed the file: %v", m2) |
| + } |
| +} |
| + |
| +// A console this machine is logged in to draws no warning. |
| +func TestDestinationsAddLoggedInIsQuiet(t *testing.T) { |
| + destinationsEnv(t) |
| + f := &credentialFile{Credentials: []credential{{Console: "https://a.example.com", Address: "me@example.com", Token: "t"}}} |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out := mustDestinations(t, "add", "prod", "--console", "https://a.example.com"); strings.Contains(out, "not logged in") { |
| + t.Fatalf("warned despite a credential:\n%s", out) |
| + } |
| +} |
| + |
| +func TestDestinationsSet(t *testing.T) { |
| + project := destinationsEnv(t) |
| + path := writeConfig(t, project, `{"destinations":{"prod":{"console":"https://a.example.com","app":"shop","kind":"binary"}}}`) |
| + |
| + mustDestinations(t, "set", "prod", "--app", "", "--kind", "static", "--artifact", "./dist") |
| + p := destBlock(t, readConfigJSON(t, path), "prod") |
| + if _, has := p["app"]; has || p["kind"] != "static" || p["artifact"] != "./dist" || p["console"] != "https://a.example.com" { |
| + t.Fatalf("after set = %v", p) |
| + } |
| + for _, c := range []struct { |
| + args []string |
| + want string |
| + }{ |
| + {[]string{"set", "prod"}, "nothing to change"}, |
| + {[]string{"set", "nope", "--app", "x"}, `no destination "nope"`}, |
| + {[]string{"set", "prod", "--console", ""}, "no console"}, |
| + {[]string{"set", "prod", "--kind", "nope"}, "binary or static"}, |
| + } { |
| + if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) { |
| + t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestDestinationsListAndDefault(t *testing.T) { |
| + project := destinationsEnv(t) |
| + if out := mustDestinations(t); !strings.Contains(out, "no destinations in") { |
| + t.Fatalf("empty list = %q", out) |
| + } |
| + path := writeConfig(t, project, `{"artifact":"./bin/server","destinations":{ |
| + "prod":{"console":"https://a.example.com","app":"shop"}, |
| + "eu":{"console":"https://eu.example.com","app":"shop-eu"}}}`) |
| + |
| + // Several and no default: list still works (it is how you find out), |
| + // and says what is wrong; default without a name refuses the same way. |
| + out := mustDestinations(t, "list") |
| + for _, want := range []string{"prod", "https://eu.example.com", "shop-eu", "./bin/server", "no default"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("list missing %q:\n%s", want, out) |
| + } |
| + } |
| + if _, err := runDestinations("default"); err == nil { |
| + t.Fatal("default with several and none set must refuse") |
| + } |
| + |
| + mustDestinations(t, "default", "eu") |
| + if m := readConfigJSON(t, path); m["default"] != "eu" { |
| + t.Fatalf("default = %v", m["default"]) |
| + } |
| + if out = mustDestinations(t, "default"); !strings.HasPrefix(out, "eu (from ") { |
| + t.Fatalf("show default = %q", out) |
| + } |
| + if out = mustDestinations(t); !strings.Contains(out, "* eu") || strings.Contains(out, "* prod") { |
| + t.Fatalf("list default mark:\n%s", out) |
| + } |
| + if _, err := runDestinations("default", "nope"); err == nil { |
| + t.Fatal("default naming no destination must refuse") |
| + } |
| +} |
| + |
| +func TestDestinationsRemoveAndRename(t *testing.T) { |
| + project := destinationsEnv(t) |
| + path := writeConfig(t, project, `{"default":"prod","destinations":{ |
| + "prod":{"console":"https://a.example.com"}, |
| + "eu":{"console":"https://eu.example.com"}, |
| + "staging":{"console":"https://a.example.com"}}}`) |
| + |
| + // Rename carries the default with it. |
| + mustDestinations(t, "rename", "prod", "production") |
| + m := readConfigJSON(t, path) |
| + if m["default"] != "production" || destBlock(t, m, "production")["console"] != "https://a.example.com" { |
| + t.Fatalf("after rename = %v", m) |
| + } |
| + if _, err := runDestinations("rename", "eu", "staging"); err == nil { |
| + t.Fatal("rename onto an existing name must refuse") |
| + } |
| + |
| + // The default cannot be removed while two others would be left. |
| + if _, err := runDestinations("remove", "production"); err == nil || !strings.Contains(err.Error(), "is the default") { |
| + t.Fatalf("remove default = %v", err) |
| + } |
| + mustDestinations(t, "remove", "staging") |
| + // Now one would be left: removing the default promotes it. |
| + out := mustDestinations(t, "remove", "production") |
| + if !strings.Contains(out, "eu is now the default") { |
| + t.Fatalf("promotion note missing:\n%s", out) |
| + } |
| + if m = readConfigJSON(t, path); m["default"] != nil { |
| + t.Fatalf("stale default left: %v", m) |
| + } |
| + // Removing the last clears both keys. |
| + mustDestinations(t, "remove", "eu") |
| + if m = readConfigJSON(t, path); m["destinations"] != nil || m["default"] != nil { |
| + t.Fatalf("after last remove = %v", m) |
| + } |
| +} |
| + |
| +// The file a verb edits: the nearest project file walking up, never the |
| +// global one met on the way up through $HOME; --global asks for that. |
| +func TestDestinationsFile(t *testing.T) { |
| + destinationsEnv(t) |
| + home := os.Getenv("HOME") |
| + project := filepath.Join(home, "code", "shop") |
| + deep := filepath.Join(project, "cmd", "server") |
| + if err := os.MkdirAll(deep, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + global := writeConfig(t, home, `{"console":"https://global.example.com"}`) |
| + t.Chdir(deep) |
| + |
| + // Nothing in the project: a new file right here, not the global. |
| + p, _, err := destinationsFile(false) |
| + if err != nil || p != filepath.Join(deep, ".carlos", "config") { |
| + t.Fatalf("no project file = %q %v", p, err) |
| + } |
| + // A project file further up is the one edited. |
| + want := writeConfig(t, project, `{}`) |
| + if p, _, err = destinationsFile(false); err != nil || p != want { |
| + t.Fatalf("nearest = %q %v, want %q", p, err, want) |
| + } |
| + if p, _, err = destinationsFile(true); err != nil || p != global { |
| + t.Fatalf("--global = %q %v", p, err) |
| + } |
| + mustDestinations(t, "add", "g", "--console", "https://g.example.com", "--global", "--from-current", "home") |
| + if destBlock(t, readConfigJSON(t, global), "g")["console"] != "https://g.example.com" { |
| + t.Fatal("--global add missed the global file") |
| + } |
| + // In $HOME itself, ./.carlos/config IS the global file: refuse rather |
| + // than edit it by accident. |
| + t.Chdir(home) |
| + if _, _, err = destinationsFile(false); err == nil { |
| + t.Fatal("editing ~/.carlos/config without --global must refuse") |
| + } |
| +} |
| + |
| +// auth default -d reads and writes one destination's console. |
| +func TestAuthDefaultDestination(t *testing.T) { |
| + project := destinationsEnv(t) |
| + path := writeConfig(t, project, `{"default":"prod","destinations":{"prod":{"console":"https://a.example.com"}}}`) |
| + var b bytes.Buffer |
| + if err := authDefault(&b, []string{"--project", "-d", "tito", "https://tito.example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(b.String(), "the tito destination in") { |
| + t.Fatalf("write echo = %q", b.String()) |
| + } |
| + if destBlock(t, readConfigJSON(t, path), "tito")["console"] != "https://tito.example.com" { |
| + t.Fatal("auth default -d did not write the tito block") |
| + } |
| + b.Reset() |
| + if err := authDefault(&b, []string{"-d", "tito"}); err != nil || !strings.Contains(b.String(), "https://tito.example.com (from the tito destination in") { |
| + t.Fatalf("show = %q %v", b.String(), err) |
| + } |
| +} |
diff --git a/config_test.go b/config_test.go| index 1089631c0..0dc9e251a 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -49,6 +49,7 @@ func useIsolatedConfig(t *testing.T) { |
| t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config")) |
| t.Setenv("CARLOS_CONSOLE", "") |
| t.Setenv("CARLOS_CREDENTIALS", "") |
| + t.Setenv("CARLOS_DESTINATION", "") |
| } |
| func TestLoadConfigFile(t *testing.T) { |
diff --git a/deploy.go b/deploy.go| index fd6e090b4..c13a0ace3 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -38,7 +38,7 @@ func runDeploy(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to deploy through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to deploy through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -59,9 +59,9 @@ func runDeploy(args []string) { |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos deploy --app <a> [--version <sha>] [--kind binary|static] [--target linux-arm64] " + |
| - "[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] " + |
| + "[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " + |
| "[--channel <ch>] [--host <host>] [--label <text>] [--notes <text>] [--timeout 120s] <files-or-site-dir>\n" + |
| - "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy" |
| + "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]" |
| if *ver == "" { |
| *ver = gitShortHead() |
| } |
| @@ -149,7 +149,11 @@ func runDeploy(args []string) { |
| if !flagWasSet(fs, "target") && dcfg.Target != "" { |
| *target = dcfg.Target |
| } |
| - mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, dsrc) |
| + from := dsrc |
| + if dcfg.Destination != "" { |
| + from = "the " + dcfg.Destination + " destination in " + dsrc |
| + } |
| + mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, from) |
| } else if interactiveTerminal() { |
| var k, tgt string |
| artifact, k, tgt, err = deployArtifactWizard() |
diff --git a/destinations_test.go b/destinations_test.go| new file mode 100644 |
| index 000000000..14cbfa5c8 |
| --- /dev/null |
| +++ b/destinations_test.go |
| @@ -0,0 +1,308 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// selectDestination sets --destination for one test, and puts it back. |
| +func selectDestination(t *testing.T, name string) { |
| + t.Helper() |
| + prev := destinationFlag |
| + destinationFlag = name |
| + t.Cleanup(func() { destinationFlag = prev }) |
| +} |
| + |
| +const twoDestinations = `{ |
| + "artifact": "./bin/server", |
| + "update_check": "off", |
| + "destinations": { |
| + "exciting": {"console": "https://carlos.exciting.example/", "app": "shop"}, |
| + "tito": {"console": "https://tito.example", "app": "shop-tito", "artifact": "./dist"} |
| + } |
| +}` |
| + |
| +// Selection, the file's default, the only block, and the refusals — each |
| +// resolved through loadConfig, which every per-key default reads. |
| +func TestLoadConfigDestinations(t *testing.T) { |
| + useIsolatedConfig(t) |
| + dir := t.TempDir() |
| + p := writeConfig(t, dir, twoDestinations) |
| + |
| + // Several blocks, nothing selected, no default: refused, naming them. |
| + if _, err := loadConfig(p); err == nil || !strings.Contains(err.Error(), "exciting, tito") { |
| + t.Fatalf("ambiguous file = %v, want a refusal naming both destinations", err) |
| + } |
| + |
| + // Selected: the block wins, the top level fills what it leaves out, |
| + // and preferences stay top-level. |
| + selectDestination(t, "exciting") |
| + cfg, err := loadConfig(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if cfg.Console != "https://carlos.exciting.example" || cfg.App != "shop" || |
| + cfg.Artifact != "./bin/server" || cfg.UpdateCheck != "off" || cfg.Destination != "exciting" { |
| + t.Fatalf("exciting = %+v", cfg) |
| + } |
| + selectDestination(t, "tito") |
| + if cfg, _ = loadConfig(p); cfg.Artifact != "./dist" || cfg.App != "shop-tito" { |
| + t.Fatalf("tito's own artifact must beat the top level: %+v", cfg) |
| + } |
| + |
| + // Selected but not defined here: this file describes other stacks, so |
| + // only its preferences survive. |
| + selectDestination(t, "staging") |
| + if cfg, err = loadConfig(p); err != nil || cfg.Console != "" || cfg.App != "" || |
| + cfg.Artifact != "" || cfg.UpdateCheck != "off" || cfg.Destination != "" { |
| + t.Fatalf("undefined here = %+v, %v; want preferences only", cfg, err) |
| + } |
| + // Same for a flat file with no destinations at all. |
| + flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example", "app": "x"}`) |
| + if cfg, err = loadConfig(flat); err != nil || cfg.Console != "" || cfg.App != "" { |
| + t.Fatalf("flat file under a selection = %+v, %v; want nothing", cfg, err) |
| + } |
| + |
| + // Nothing selected: the file's default. |
| + selectDestination(t, "") |
| + var withDefault map[string]any |
| + json.Unmarshal([]byte(twoDestinations), &withDefault) |
| + withDefault["default"] = "tito" |
| + body, _ := json.Marshal(withDefault) |
| + os.WriteFile(p, body, 0o644) |
| + if cfg, err = loadConfig(p); err != nil || cfg.Destination != "tito" || cfg.Console != "https://tito.example" { |
| + t.Fatalf("default = %+v, %v", cfg, err) |
| + } |
| + // ...which the flag overrides. |
| + selectDestination(t, "exciting") |
| + if cfg, _ = loadConfig(p); cfg.Destination != "exciting" { |
| + t.Fatalf("flag over default = %+v", cfg) |
| + } |
| + // A default naming no block is an error, not a fall-through. |
| + selectDestination(t, "") |
| + withDefault["default"] = "tit" |
| + body, _ = json.Marshal(withDefault) |
| + os.WriteFile(p, body, 0o644) |
| + if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), `"tit"`) { |
| + t.Fatalf("bad default = %v, want an error naming it", err) |
| + } |
| + |
| + // Exactly one block needs no default. |
| + os.WriteFile(p, []byte(`{"destinations": {"only": {"console": "https://only.example"}}}`), 0o644) |
| + if cfg, err = loadConfig(p); err != nil || cfg.Destination != "only" || cfg.Console != "https://only.example" { |
| + t.Fatalf("single block = %+v, %v", cfg, err) |
| + } |
| + |
| + // A block's console is validated like a top-level one. |
| + os.WriteFile(p, []byte(`{"destinations": {"bad": {"console": "ftp://nope"}}}`), 0o644) |
| + if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), p) { |
| + t.Fatalf("bad block console = %v, want an error naming %s", err, p) |
| + } |
| +} |
| + |
| +// A selected destination that no file defines is refused by the walk — |
| +// never "no default", which would fall through to whatever single |
| +// credential this machine holds. The walk still finds a destination |
| +// defined only in the global file past a project file that lacks it. |
| +func TestWalkRefusesUndefinedDestination(t *testing.T) { |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONFIG", "") |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_DESTINATION", "") |
| + project := filepath.Join(home, "project") |
| + writeConfig(t, project, `{"destinations": {"tito": {"console": "https://tito.example"}}}`) |
| + t.Chdir(project) |
| + |
| + selectDestination(t, "titi") |
| + if _, _, err := configDefault(); err == nil || !strings.Contains(err.Error(), `"titi"`) { |
| + t.Fatalf("typo'd destination = %v, want a refusal naming it", err) |
| + } |
| + // The environment variable selects too. |
| + selectDestination(t, "") |
| + t.Setenv("CARLOS_DESTINATION", "titi") |
| + if _, _, err := configDefault(); err == nil { |
| + t.Fatal("CARLOS_DESTINATION typo must be refused too") |
| + } |
| + |
| + writeConfig(t, home, `{"destinations": {"titi": {"console": "https://global.example"}}}`) |
| + if c, _, err := configDefault(); err != nil || c != "https://global.example" { |
| + t.Fatalf("global-only destination = %q %v", c, err) |
| + } |
| + t.Setenv("CARLOS_DESTINATION", "") |
| + selectDestination(t, "tito") |
| + if c, _, err := configDefault(); err != nil || c != "https://tito.example" { |
| + t.Fatalf("project destination = %q %v", c, err) |
| + } |
| +} |
| + |
| +// Saves land where the read resolved: the selected block, else the |
| +// default/only block, else the top level. |
| +func TestWriteConfigKeysIntoDestination(t *testing.T) { |
| + useIsolatedConfig(t) |
| + p := writeConfig(t, t.TempDir(), twoDestinations) |
| + read := func() map[string]any { |
| + t.Helper() |
| + raw, _ := os.ReadFile(p) |
| + var m map[string]any |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + return m |
| + } |
| + block := func(m map[string]any, name string) map[string]any { |
| + return m["destinations"].(map[string]any)[name].(map[string]any) |
| + } |
| + |
| + if err := writeConfigKeys(p, map[string]any{"account": "a"}, 0o755); err == nil { |
| + t.Fatal("several blocks and no selection must refuse the write") |
| + } |
| + |
| + selectDestination(t, "tito") |
| + if err := saveConfigAccount(p, "https://tito.example", "tito-acct", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := read() |
| + if block(m, "tito")["account"] != "tito-acct" || m["account"] != nil { |
| + t.Fatalf("selected write landed wrong: %v", m) |
| + } |
| + if block(m, "exciting")["app"] != "shop" || m["update_check"] != "off" { |
| + t.Fatalf("unrelated keys lost: %v", m) |
| + } |
| + |
| + // A new name creates its block. |
| + selectDestination(t, "staging") |
| + if err := writeConfigKeys(p, map[string]any{"app": "shop-staging"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if block(read(), "staging")["app"] != "shop-staging" { |
| + t.Fatal("new destination block not created") |
| + } |
| + |
| + // No selection, a default: into the default. |
| + selectDestination(t, "") |
| + m = read() |
| + m["default"] = "exciting" |
| + body, _ := json.Marshal(m) |
| + os.WriteFile(p, body, 0o644) |
| + if err := writeConfigKeys(p, map[string]any{"account": "ex"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if block(read(), "exciting")["account"] != "ex" { |
| + t.Fatal("default-block write landed wrong") |
| + } |
| + |
| + // A flat file stays flat. |
| + flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example"}`) |
| + if err := writeConfigKeys(flat, map[string]any{"app": "x"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := os.ReadFile(flat) |
| + if strings.Contains(string(raw), "destinations") || !strings.Contains(string(raw), `"app": "x"`) { |
| + t.Fatalf("flat write = %s", raw) |
| + } |
| +} |
| + |
| +// -d and --destination both select, and a second flag set built later in |
| +// the same run (deploy re-running auth login) does not clear the choice. |
| +func TestDestinationFlag(t *testing.T) { |
| + for _, args := range [][]string{{"-d", "tito"}, {"--destination", "tito"}, {"-destination=tito"}, {"--d=tito"}} { |
| + selectDestination(t, "") |
| + fs := quietFlagSet("t") |
| + consoleOption(fs, "") |
| + if err := fs.Parse(args); err != nil { |
| + t.Fatalf("%v: %v", args, err) |
| + } |
| + if selectedDestination() != "tito" { |
| + t.Fatalf("%v: selected %q", args, selectedDestination()) |
| + } |
| + consoleOption(quietFlagSet("again"), "") |
| + if selectedDestination() != "tito" { |
| + t.Fatalf("%v: a later flag set cleared the destination", args) |
| + } |
| + } |
| +} |
| + |
| +// End to end through the real binary: two consoles, one checkout. -d picks |
| +// the console, the default answers a bare run, and the "using" line names |
| +// the destination. Each console refuses the deploy at its instance list |
| +// (none), which is enough to prove which one was asked. |
| +func TestDeployDestinationsSubprocess(t *testing.T) { |
| + bin := buildCarlos(t) |
| + servers := map[string]string{} |
| + for _, name := range []string{"exciting", "tito"} { |
| + servers[name] = zeroInstanceConsole(t, "acct-"+name, "app-"+name) |
| + } |
| + project := t.TempDir() |
| + cfg := map[string]any{ |
| + "default": "exciting", |
| + "kind": "binary", |
| + "artifact": "./bin/server", |
| + "destinations": map[string]any{ |
| + "exciting": map[string]any{"console": servers["exciting"], "account": "acct-exciting", "app": "app-exciting"}, |
| + "tito": map[string]any{"console": servers["tito"], "account": "acct-tito", "app": "app-tito"}, |
| + }, |
| + } |
| + body, _ := json.Marshal(cfg) |
| + writeConfig(t, project, string(body)) |
| + os.MkdirAll(filepath.Join(project, "bin"), 0o755) |
| + os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755) |
| + credPath := filepath.Join(t.TempDir(), "credentials") |
| + creds := `{"version":1,"credentials":[` + |
| + `{"console":"` + servers["exciting"] + `","address":"me@exciting","token":"t1"},` + |
| + `{"console":"` + servers["tito"] + `","address":"me@tito","token":"t2"}]}` |
| + os.WriteFile(credPath, []byte(creds), 0o600) |
| + |
| + run := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...) |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) |
| + cmd.Dir = project |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("%v: no-instances fixture must refuse, got success:\n%s", args, out) |
| + } |
| + return string(out) |
| + } |
| + for _, c := range []struct { |
| + args []string |
| + want string |
| + }{ |
| + {nil, "exciting"}, |
| + {[]string{"-d", "tito"}, "tito"}, |
| + {[]string{"--destination", "exciting"}, "exciting"}, |
| + } { |
| + out := run(c.args...) |
| + if !strings.Contains(out, "using "+servers[c.want]+" as me@"+c.want+" (destination "+c.want+")") || |
| + !strings.Contains(out, "the "+c.want+" destination in") || |
| + !strings.Contains(out, "no instances") { |
| + t.Fatalf("%v: want destination %s, got:\n%s", c.args, c.want, out) |
| + } |
| + } |
| + if out := run("-d", "staging"); !strings.Contains(out, `destination "staging"`) { |
| + t.Fatalf("undefined destination = %s", out) |
| + } |
| +} |
| + |
| +// zeroInstanceConsole is a fake console that knows one account owning one |
| +// app with no instances — deploy's first refusal after resolution. |
| +func zeroInstanceConsole(t *testing.T, account, app string) string { |
| + t.Helper() |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| + fmt.Fprintf(w, `{"accounts":[{"sqid":"s-%s","name":%q,"apps":[%q]}]}`, account, account, app) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(`{"instances":[]}`)) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + return ts.URL |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index dba6553c5..8abcac2a7 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -69,7 +69,45 @@ watching; pass `--owner <address>` to |
| [carlos accounts](/docs/cli#carlos-accounts) instead. `carlos auth default` |
| shows the console this machine talks to by default, and sets it when you give |
| it a URL. With `--project` it writes `./.carlos/config`, which you can commit |
| -so a checkout pins its own deployment. |
| +so a checkout pins its own deployment. With `-d <name>` it shows or sets the |
| +console of one [destination](/docs/cli#deploying-one-checkout-to-several-places). |
| + |
| +### carlos config |
| + |
| +Edits `.carlos/config` for you, so you never have to hand-edit its JSON. |
| +`carlos config destinations` manages the named places that |
| +[carlos deploy](/docs/cli#carlos-deploy) and every other command reach with |
| +`-d <name>`: |
| + |
| +```sh |
| +carlos config destinations add production --console https://carlos.example.com --app shop |
| +carlos config destinations add partner --console https://carlos.example.org --app shop |
| +carlos config destinations |
| +carlos config destinations default partner |
| +carlos config destinations set partner --account me@example.org |
| +carlos config destinations rename partner eu |
| +carlos config destinations remove eu |
| +``` |
| + |
| +`add` takes `--console`, `--account`, `--app`, `--kind`, `--artifact` and |
| +`--target`, and `--default` makes the new destination the default. `set` |
| +changes the same keys on an existing destination, and an empty value |
| +(`--app ""`) removes a key. `list`, which is also what the bare command does, |
| +marks the destination used when none is named with `*`. |
| + |
| +Each verb edits the nearest `.carlos/config` in this directory or above it, |
| +and creates `./.carlos/config` when there is none. `--global` edits |
| +`~/.carlos/config` instead. `add` warns when this machine is not logged in to |
| +the destination's console, and still writes the destination, so you can set |
| +up a console before you log in to it. |
| + |
| +These commands never let a file quietly start deploying somewhere new. If the |
| +file already names a console, account or app at the top, adding the first |
| +destination moves those keys into a destination of their own, which becomes |
| +the default. At a terminal, `add` asks what to call it; elsewhere you name it |
| +with `--from-current <name>`. When a second destination is added, the first |
| +one is recorded as the default. Removing the default is refused while more |
| +than one other destination remains. |
| ### carlos apps |
| @@ -206,6 +244,41 @@ having no instances to resolve a channel from. |
| carlos deploy --app website --kind static --host www.example.com ./dist |
| ``` |
| +#### Deploying one checkout to several places |
| + |
| +When one project deploys to more than one console, or as more than one app, |
| +name each place in `.carlos/config` under `destinations` and pick one with |
| +`--destination` (`-d` for short): |
| + |
| +```json |
| +{ |
| + "default": "production", |
| + "artifact": "./bin/server", |
| + "destinations": { |
| + "production": {"console": "https://carlos.example.com", "account": "me@example.com", "app": "shop"}, |
| + "partner": {"console": "https://carlos.example.org", "app": "shop", "target": "linux-amd64"} |
| + } |
| +} |
| +``` |
| + |
| +```sh |
| +carlos deploy -d partner |
| +carlos logs -d partner |
| +``` |
| + |
| +Each destination takes the same keys as the top of the file, and any key it |
| +leaves out comes from the top of the file. Here both destinations ship |
| +`./bin/server`. Every command that takes `--console` also takes |
| +`--destination`, and `CARLOS_DESTINATION` does the same job for a whole |
| +shell. With no destination named, the file's `default` applies. A file with |
| +only one destination needs no default. A file with several destinations and |
| +no default refuses to guess, and so does a destination name that no config |
| +file defines. Saved answers go into the destination you are using. |
| +[carlos config destinations](/docs/cli#carlos-config) adds, changes and |
| +removes destinations, so you don't need to edit the file yourself. |
| +`--target` is unrelated: it names the platform (GOOS-GOARCH) a binary was |
| +built for. |
| + |
| ### carlos canary |
| Deploy onto a host and a channel belonging to this branch alone, so nothing |
| @@ -612,6 +685,14 @@ that route at its next domains pass, certificate and all. The `list` verb |
| joins the claims to the fleet's own readings sweep, so DNS state, certificate |
| expiry and delegation are readable without leaving the terminal. |
| +The console always refuses its own hostname. By default it accepts every |
| +other name, including names under the console's domain and under the apps |
| +domain. That fits a self-hosted deployment, where one person owns the whole |
| +zone. A deployment that hosts apps for other people sets |
| +`CONSOLE_RESERVE_PLATFORM_DOMAINS=true` on the console. Then the console also |
| +refuses every name under the apps domain and under its own hostname, because |
| +those names belong to the operator and not to a tenant. |
| + |
| ### carlos features |
| Sets or lists an app's own feature flags — switches the app defines, the |
diff --git a/domains_cli.go b/domains_cli.go| index 04b130ea1..569514ea0 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -87,7 +87,7 @@ func runDomainsAttach(args []string) { |
| route := fs.String("route", "", "route hostname to attach to") |
| wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)") |
| catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs --wildcard)") |
| - consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to attach 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, domainsAttachUsage) |
| @@ -181,7 +181,7 @@ func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app |
| func runDomainsDetach(args []string) { |
| fs := flag.NewFlagSet("domains detach", flag.ExitOnError) |
| app := appFlag(fs, "app the hostname belongs to") |
| - consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to detach 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, domainsDetachUsage) |
| @@ -208,7 +208,7 @@ func runDomainsDetach(args []string) { |
| func runDomainsList(args []string) { |
| fs := flag.NewFlagSet("domains list", flag.ExitOnError) |
| app := appFlag(fs, "app whose hostname claims to list") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + 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, domainsListUsage) |
| @@ -291,7 +291,7 @@ func runDomainsList(args []string) { |
| func runDomainsPaths(args []string) { |
| fs := flag.NewFlagSet("domains paths", flag.ExitOnError) |
| app := appFlag(fs, "app the hostname belongs to") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read 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, domainsPathsUsage) |
diff --git a/e2e_test.go b/e2e_test.go| index 1a30a9d32..abe518b90 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -86,6 +86,7 @@ func carlosTestEnv(t *testing.T, extra ...string) []string { |
| "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| "HOME="+scratch, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| return append(env, extra...) |
| } |
| @@ -945,6 +946,7 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -1101,6 +1103,7 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -1615,6 +1618,7 @@ func TestAccountsMigrateLegacyIntoOps(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -2243,6 +2247,7 @@ func TestRouteCLI(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| @@ -2569,6 +2574,7 @@ func (f *routeTestFixture) fullEnv() []string { |
| "CARLOS_RELEASE_PUBKEY="+f.pubB64, |
| "HOME="+f.tmpDir, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| } |
| @@ -3556,6 +3562,7 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) { |
| "CARLOS_RELEASE_PUBKEY="+pubB64, |
| "HOME="+tmp, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
diff --git a/economics.go b/economics.go| index e80a62ba5..2129431bb 100644 |
| --- a/economics.go |
| +++ b/economics.go |
| @@ -190,7 +190,7 @@ func (s *apiSession) economicsBackhaul(ctx context.Context, period string) (back |
| func runEconomicsBackhaul(args []string) { |
| fs := flag.NewFlagSet("economics backhaul", flag.ExitOnError) |
| period := fs.String("period", "", "mtd (default), last, or 7d") |
| - consoleFlag := fs.String("console", "", "console to ask (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, economicsBackhaulUsage) |
| fs.Parse(args) |
| sess, err := resolveMode(*consoleFlag) |
| @@ -278,7 +278,7 @@ func runEconomicsBill(args []string) { |
| total := fs.Float64("total", 0, "invoice total in US dollars (required)") |
| lines := serviceLines{} |
| fs.Var(lines, "line", "one line item, repeatable: Service=usd") |
| - consoleFlag := fs.String("console", "", "console to record through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, economicsUsage) |
| fs.Parse(args) |
diff --git a/errors_cmd.go b/errors_cmd.go| index 904f031dd..3b8991c08 100644 |
| --- a/errors_cmd.go |
| +++ b/errors_cmd.go |
| @@ -36,7 +36,7 @@ func runErrors(args []string) { |
| rel := fs.String("release", "", "only one release") |
| fp := fs.String("fp", "", "expand one group: its occurrences, oldest first") |
| setLevel := fs.String("set-level", "", "set the app's log level for errors: error | warn") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read 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, errorsUsage) |
diff --git a/features_cli.go b/features_cli.go| index 5b9d9e133..b38115af1 100644 |
| --- a/features_cli.go |
| +++ b/features_cli.go |
| @@ -95,7 +95,7 @@ func runFeaturesSet(args []string) { |
| fs := flag.NewFlagSet("features set", flag.ExitOnError) |
| messageFlags := addMutationMessageFlags(fs) |
| app := fs.String("app", "", "app whose feature flags to set") |
| - consoleFlag := fs.String("console", "", "console to set through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to set 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.Parse(args) |
| @@ -129,7 +129,7 @@ func runFeaturesSet(args []string) { |
| func runFeaturesList(args []string) { |
| fs := flag.NewFlagSet("features list", flag.ExitOnError) |
| app := fs.String("app", "", "app whose feature flags to list") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + 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.Parse(args) |
diff --git a/flags.go b/flags.go| index 146f6bd26..ae9f0d64d 100644 |
| --- a/flags.go |
| +++ b/flags.go |
| @@ -7,8 +7,8 @@ import ( |
| ) |
| // flags.go is the carlos CLI's face of internal/cliflag (the spec's |
| -// claimed short-flag namespace: -a app, -c channel, -e environment, |
| -// -v version). The four helpers below are the only way the aliased |
| +// claimed short-flag namespace: -a app, -c channel, -d destination, |
| +// -e environment, -v version). The four helpers below are the only way the aliased |
| // flags are registered in this package; sites with something bespoke to |
| // say pass it through, and an empty usage takes the flag's common |
| // wording. The table itself, the registrar, and the usage printer live |
| @@ -31,6 +31,35 @@ func flagUsage(fs *flag.FlagSet, header string) func() { |
| return cliflag.Usage(fs, header) |
| } |
| +// consoleOption registers --console, and with it --destination / -d: |
| +// every verb that can be pointed at a console can equally be pointed at a |
| +// named destination in .carlos/config, which is where its console comes |
| +// from. The destination lands in the package-level destinationFlag that |
| +// loadConfig reads (config.go). |
| +func consoleOption(fs *flag.FlagSet, usage string) *string { |
| + c := fs.String("console", "", usage) |
| + destinationOption(fs) |
| + return c |
| +} |
| + |
| +// destinationOption registers --destination / -d alone, for the verbs that |
| +// read or write .carlos/config without taking --console (auth default). |
| +func destinationOption(fs *flag.FlagSet) { |
| + fs.Var(destinationValue{}, "destination", |
| + "use the `name`d block from .carlos/config's destinations (default: CARLOS_DESTINATION, else the file's default or only one)") |
| + fs.Var(destinationValue{}, cliAliases["destination"], "alias for --destination") |
| +} |
| + |
| +// destinationValue writes straight to destinationFlag. A flag.Value rather |
| +// than fs.StringVar because StringVar assigns the default at registration: |
| +// a verb that builds a second flag set mid-run (deploy re-running auth |
| +// login on a moved console) would otherwise wipe the destination the |
| +// operator chose. |
| +type destinationValue struct{} |
| + |
| +func (destinationValue) String() string { return destinationFlag } |
| +func (destinationValue) Set(v string) error { destinationFlag = v; return nil } |
| + |
| func appFlag(fs *flag.FlagSet, usage string) *string { |
| if usage == "" { |
| usage = "app name" |
diff --git a/fleets.go b/fleets.go| index b3e354928..aaa7c2423 100644 |
| --- a/fleets.go |
| +++ b/fleets.go |
| @@ -158,7 +158,7 @@ func runFleetsCreate(args []string) { |
| bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)") |
| acctRaw := fs.String("account", "", "account (sqid or name) to own the fleet (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 || strings.TrimSpace(*bucket) == "" { |
| @@ -186,7 +186,7 @@ func runFleetsAddBox(args []string) { |
| fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to add the box through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to add the box through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 2 { |
| @@ -215,7 +215,7 @@ func runFleetsRotateToken(args []string) { |
| fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 2 { |
| @@ -279,7 +279,7 @@ func runFleetsTagBox(args []string) { |
| fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to tag through (default: the only logged-in one)") |
| tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`) |
| const tagBoxUsage = "usage: carlos fleets tag-box --tags <tags> [--account <sqid|name>] [--account-id <sqid>] " + |
| "[--console <origin>] <fleet> <label>" |
| @@ -326,7 +326,7 @@ func runFleetsDetachBox(args []string) { |
| fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the fleet 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") |
| - consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to detach through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 2 { |
| @@ -354,7 +354,7 @@ func runFleetsList(args []string) { |
| fs := flag.NewFlagSet("fleets ls", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) to list fleets for (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
diff --git a/gate_cli.go b/gate_cli.go| index 612baed23..6179c6f26 100644 |
| --- a/gate_cli.go |
| +++ b/gate_cli.go |
| @@ -108,7 +108,7 @@ func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, |
| fs := flag.NewFlagSet("gate "+name, flag.ExitOnError) |
| app := appFlag(fs, "app whose instances to gate") |
| host := fs.String("host", "", "one instance host (default: every instance of the app)") |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + 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") |
| return fs, app, host, consoleFlag, acctRaw, acctIDRaw |
diff --git a/instances_cli.go b/instances_cli.go| index 80707979c..ad4fdc5f9 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -59,7 +59,7 @@ func runInstancesEnable(args []string) { |
| 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)") |
| - consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)") |
| + 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) |
| @@ -271,7 +271,7 @@ func runInstancesCreate(args []string) { |
| 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 := fs.String("console", "", "console to create through (default: the only logged-in one)") |
| + 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") |
| fs.Usage = flagUsage(fs, instancesRecordsUsage) |
| @@ -357,7 +357,7 @@ func runInstancesList(args []string) { |
| 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 := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + 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) |
| @@ -398,7 +398,7 @@ func runInstancesDelete(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host whose record to retire") |
| - consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| + 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) |
| @@ -443,7 +443,7 @@ func runInstancesSetUpstreams(args []string) { |
| 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 := fs.String("console", "", "console to update through (default: the only logged-in one)") |
| + 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) |
| @@ -545,7 +545,7 @@ 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 := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + 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) |
| @@ -593,7 +593,7 @@ func runInstancesSetChannel(args []string) { |
| 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 := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + 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) |
| @@ -668,7 +668,7 @@ func runInstancesSetEnvironment(args []string) { |
| 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 := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + 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) |
diff --git a/internal/cliflag/cliflag.go b/internal/cliflag/cliflag.go| index ccb98279f..43b019388 100644 |
| --- a/internal/cliflag/cliflag.go |
| +++ b/internal/cliflag/cliflag.go |
| @@ -13,14 +13,16 @@ import ( |
| "strings" |
| ) |
| -// Aliases maps a primary flag name to its short alias. Four letters, |
| -// and nothing else: -vv is reserved for a future verbose mode so it can |
| +// Aliases maps a primary flag name to its short alias. Five letters, |
| +// and nothing else (-d for --destination joined the original four |
| +// in 2026-09): -vv is reserved for a future verbose mode so it can |
| // never quietly claim -v and collide with --version; -console stays |
| // long-only by ruling; -f, -n, -y are claimed by logs/update as |
| // primaries and are not aliases. |
| var Aliases = map[string]string{ |
| "app": "a", |
| "channel": "c", |
| + "destination": "d", |
| "environment": "e", |
| "version": "v", |
| } |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 6334642dc..decd1f942 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -41,6 +41,7 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + "slices" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -54,43 +55,94 @@ import ( |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| -// deniedDomains is the deny list every attach is checked against: the |
| -// operator's CONSOLE_DENY_DOMAINS entries (cmd/console folds the tunnel |
| -// hostname into those), plus the names this console can derive without |
| -// being told (spec §5: "all derived from |
| -// console/deployment config, not hardcoded"). |
| +// consoleHosts are the hostnames this console itself answers on: the ORIGIN |
| +// host, plus the REDIRECT HOSTS (CONSOLE_REDIRECT_HOSTS: retired console |
| +// origins such as tito's carlos.tito.io, which this console still answers on |
| +// and 301s to Origin). Every deployment refuses these whether or not it |
| +// reserves platform domains (domainHostDenied). The reason is self-harm, not |
| +// tenant isolation: an alias on one of these names points the console's own |
| +// address at an app. Route-wins ordering on a box does not prevent that. It |
| +// is per-box, and it stops protecting the name when a second box |
| +// materializes the alias. |
| // |
| -// The derived three are not optional and not the operator's to forget: |
| +// Only the names themselves are refused on every deployment. Everything |
| +// under them is refused only with ReservePlatformDomains (see |
| +// deniedDomains). A self-hosted console at carlos.example.com whose apps |
| +// live at <app>.carlos.example.com must be able to attach |
| +// blog.carlos.example.com. |
| // |
| -// - the ORIGIN host is the console itself — an alias there would point the |
| -// door's own hostname at a tenant's route; |
| -// - the APPS DOMAIN is the whole platform apex namespace |
| -// (<app>.<sqid>.<apps-domain>): every app's public URL lives under it, |
| -// and a claim there would be one tenant claiming another tenant's |
| -// address — the sibling-apex hazard handleAPIInstanceCreate refuses |
| -// within an account, refused here across the deployment. Hosts under the |
| -// apps domain are what `carlos instances` exists for; this door is for |
| -// hostnames the caller's own DNS controls; |
| -// - the REDIRECT HOSTS are retired console origins (CONSOLE_REDIRECT_HOSTS |
| -// — tito's carlos.tito.io), which this console still answers on and |
| -// 301s to Origin. They are guaranteed distinct from the origin host |
| -// (NewServer refuses a self-redirect), so nothing else in this list |
| -// covers them, and a tenant claiming one would be claiming a console |
| -// address that operators, bookmarks and old CLI credentials still reach. |
| -// Route-wins ordering on a box is not the answer: it is per-box, and |
| -// stops being protection the moment a second box materializes the alias. |
| +// The redirect entries go through the same normalizeHost that ServeHTTP's |
| +// retired-host check uses, so the two readings of CONSOLE_REDIRECT_HOSTS |
| +// cannot disagree. NewServer refuses a redirect host equal to the origin. |
| +func (s *Server) consoleHosts() []string { |
| + hosts := make([]string, 0, len(s.cfg.RedirectHosts)+1) |
| + if u, err := url.Parse(s.cfg.Origin); err == nil { |
| + if h := strings.ToLower(u.Hostname()); h != "" { |
| + hosts = append(hosts, h) |
| + } |
| + } |
| + for _, h := range s.cfg.RedirectHosts { |
| + if h = normalizeHost(h); h != "" { |
| + hosts = append(hosts, h) |
| + } |
| + } |
| + return hosts |
| +} |
| + |
| +// deniedDomains are the entries whose whole subtree the /domains door |
| +// refuses. Every deployment gets two kinds of entry: |
| +// |
| +// - the operator's CONSOLE_DENY_DOMAINS entries (cmd/console adds the |
| +// tunnel hostname to those). An operator who lists a name has chosen to |
| +// refuse it; |
| +// - the STEERING HOST (default edge.<apps-domain>), which is platform |
| +// infrastructure and never a tenant's name. |
| +// |
| +// With ReservePlatformDomains set (CONSOLE_RESERVE_PLATFORM_DOMAINS) the |
| +// console also adds the names it can derive from its own config (spec §5: |
| +// "all derived from console/deployment config, not hardcoded"). These are |
| +// the rules for a platform that hosts apps for OTHER people, such as the |
| +// flagship or carloku: |
| +// |
| +// - the APPS DOMAIN is the whole platform namespace |
| +// (<app>.<sqid>.<apps-domain>). Every app's public URL is under it, so a |
| +// claim there is one tenant taking another tenant's address. This is the |
| +// sibling-apex hazard that handleAPIInstanceCreate refuses within an |
| +// account, refused here across the deployment. Hosts under the apps |
| +// domain are what `carlos instances` is for; |
| +// - everything UNDER the console's own hostnames (consoleHosts). On a |
| +// shared platform those names belong to the operator, not to a tenant. |
| // |
| -// Derived per call rather than cached at boot — one url.Parse and a short |
| -// copy on a door that then does bucket I/O — so it stays honest for an |
| -// embedder (or a test) that sets any of these after NewServer. The redirect |
| -// entries go through the same normalizeHost ServeHTTP's retired-host check |
| -// uses, so the two readings of CONSOLE_REDIRECT_HOSTS cannot disagree. |
| +// Without the setting, neither is added. That is the default because a |
| +// self-hosted deployment usually puts its console, its apps domain and its |
| +// custom domains in ONE zone that ONE person owns, and the reserved rules |
| +// refused ordinary names there. Existing routes are still safe without it: |
| +// deploymentRouteTaken refuses a hostname that another app's route already |
| +// uses. |
| +// |
| +// exemptAppsDomain is the CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS delegation |
| +// (domainHostDenied). It drops the apps domain, and every entry that is the |
| +// apps domain or an ancestor of it, so that a delegated one-label alias is |
| +// not refused by a blanket entry above it. The console's own hostnames stay |
| +// refused by exact name in domainHostDenied. |
| +// |
| +// Derived per call rather than cached at boot: one url.Parse and a short |
| +// copy, on a door that then does bucket I/O. This keeps it correct for an |
| +// embedder (or a test) that changes the config after NewServer. |
| func (s *Server) deniedDomains(exemptAppsDomain bool) []string { |
| - deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2) |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| - for _, entry := range s.cfg.DenyDomains { |
| + covers := func(entry string) bool { |
| + return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry)) |
| + } |
| + entries := slices.Clone(s.cfg.DenyDomains) |
| + if s.cfg.ReservePlatformDomains { |
| + entries = append(entries, domain) |
| + entries = append(entries, s.consoleHosts()...) |
| + } |
| + deny := make([]string, 0, len(entries)+1) |
| + for _, entry := range entries { |
| entry = strings.ToLower(strings.TrimSpace(entry)) |
| - if exemptAppsDomain && (entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry))) { |
| + if entry == "" || (exemptAppsDomain && covers(entry)) { |
| continue |
| } |
| deny = append(deny, entry) |
| @@ -99,21 +151,7 @@ func (s *Server) deniedDomains(exemptAppsDomain bool) []string { |
| if steeringHost == "" && domain != "" { |
| steeringHost = "edge." + domain |
| } |
| - deny = append(deny, steeringHost) |
| - if u, err := url.Parse(s.cfg.Origin); err == nil { |
| - if h := strings.ToLower(u.Hostname()); h != "" { |
| - deny = append(deny, h) |
| - } |
| - } |
| - if d := domain; d != "" && !exemptAppsDomain { |
| - deny = append(deny, d) |
| - } |
| - for _, h := range s.cfg.RedirectHosts { |
| - if h = normalizeHost(h); h != "" { |
| - deny = append(deny, h) |
| - } |
| - } |
| - return deny |
| + return append(deny, steeringHost) |
| } |
| // resolveAliasRoute decides which of the app's routes a claim points at. |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 909d1f34b..a8088c186 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -248,29 +248,34 @@ func TestAPIDomainAttachConflictNeutral(t *testing.T) { |
| } |
| } |
| -// TestAPIDomainAttachValidation: every hostname this door refuses to take, |
| -// each with nothing written behind it — malformed, bare-label, under a |
| -// configured deny domain, under the deployment's apps domain, the console's |
| -// own origin host, and a RETIRED console origin (CONSOLE_REDIRECT_HOSTS, |
| -// which this console still answers on and 301s away — tito's carlos.tito.io). |
| +// TestAPIDomainAttachValidation: every hostname this door refuses on a |
| +// deployment that reserves platform domains (the flagship, carloku), each |
| +// with nothing written behind it: malformed, bare-label, under a configured |
| +// deny domain, under the deployment's apps domain, the console's own origin |
| +// host and a name under it, and a RETIRED console origin |
| +// (CONSOLE_REDIRECT_HOSTS, which this console still answers on and 301s |
| +// away, such as tito's carlos.tito.io). |
| func TestAPIDomainAttachValidation(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-validate") |
| fx.srv.cfg.DenyDomains = []string{"", "denied.test"} |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| fx.srv.cfg.RedirectHosts = []string{"", "Carlos.Tito.Test:8443"} |
| + fx.srv.cfg.ReservePlatformDomains = true |
| origin, err := url.Parse(fx.srv.cfg.Origin) |
| if err != nil { |
| t.Fatal(err) |
| } |
| for _, host := range []string{ |
| - "Bad_Host!", // not a hostname at all |
| - "nodots", // a bare label is never a public hostname |
| - "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| - "denied.test", // the deny entry itself |
| - "one.bab.oncarlos.test", // platform apex space (spec §5) |
| - origin.Hostname(), // the console's own origin host |
| - "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it |
| - "old.carlos.tito.test", // and anything under it |
| + "Bad_Host!", // not a hostname at all |
| + "nodots", // a bare label is never a public hostname |
| + "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| + "denied.test", // the deny entry itself |
| + "one.bab.oncarlos.test", // platform apex space (spec §5) |
| + "oncarlos.test", // the apps domain itself |
| + origin.Hostname(), // the console's own origin host |
| + "x." + origin.Hostname(), // and anything under it |
| + "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it |
| + "old.carlos.tito.test", // and anything under it |
| } { |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| attachBody(host, "go.tito.io")) |
| @@ -286,6 +291,45 @@ func TestAPIDomainAttachValidation(t *testing.T) { |
| } |
| } |
| +// TestAPIDomainAttachUnreservedDefault: ReservePlatformDomains is off by |
| +// default, which is the self-hosted shape. There the console, the apps |
| +// domain and the custom domains usually share one zone that one person |
| +// owns. The console's own hostnames and the operator's CONSOLE_DENY_DOMAINS |
| +// are still refused. The apps domain, and names under the console's |
| +// hostnames, are not. |
| +func TestAPIDomainAttachUnreservedDefault(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-unreserved") |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.DenyDomains = []string{"denied.test"} |
| + fx.srv.cfg.AppsDomain = origin.Hostname() // the console sits at the apps-domain apex |
| + fx.srv.cfg.RedirectHosts = []string{"Carlos.Tito.Test:8443"} |
| + |
| + for _, host := range []string{ |
| + "x.denied.test", // the operator's own list still applies |
| + origin.Hostname(), // the console's own origin host |
| + "carlos.tito.test", // a retired console origin |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody(host, "go.tito.io")) |
| + if code != http.StatusBadRequest { |
| + t.Errorf("attach %q = %d %s, want 400", host, code, out) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| + for _, host := range []string{ |
| + "blog." + origin.Hostname(), // under the console host AND the apps domain |
| + "one.bab." + origin.Hostname(), // platform-shaped, but no route holds it |
| + "old.carlos.tito.test", // under a retired console origin |
| + } { |
| + if got := attachDomain(t, fx, member, host, "go.tito.io"); !got.Created { |
| + t.Errorf("attach %q unreserved = %+v, want created true", host, got) |
| + } |
| + } |
| +} |
| + |
| // TestAPIDomainAttachRouteDefault: the route may be omitted when there is |
| // exactly one thing it could mean. Two instance records make it ambiguous, |
| // and ambiguity names the flag that resolves it rather than guessing. |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 2f4aff914..59d6df883 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -104,7 +104,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| } |
| // Policy first: shape and platform space are decided before any write, |
| // so a refused host never reaches a bucket key. Both checks are #151's |
| - // exported validation surface (internal/domains + deniedDomains), the |
| + // exported validation surface (internal/domains + domainHostDenied), the |
| // same pair the CLI API door runs — one validation spot, two doors. |
| host, ok := domains.NormalizeAliasHost(host) |
| if !ok { |
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go| index 6ea586e6d..f76774433 100644 |
| --- a/internal/console/domainattach_test.go |
| +++ b/internal/console/domainattach_test.go |
| @@ -16,6 +16,7 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -613,6 +614,7 @@ func TestAttachHeldHostIsNeutral409(t *testing.T) { |
| func TestAttachPlatformSpaceRefused(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| shipVersion(t, fx, "story", "v1") |
| seedChannel(t, fx, "story", "canary/live", "v1") |
| @@ -632,6 +634,28 @@ func TestAttachPlatformSpaceRefused(t *testing.T) { |
| } |
| } |
| +// TestAttachPlatformSpaceOpenByDefault: without ReservePlatformDomains, the |
| +// form door (attachDomain) accepts a name under the apps domain, even as |
| +// the app's first route. This is the self-hosted case. Only the console's |
| +// own hostname is still refused. |
| +func TestAttachPlatformSpaceOpenByDefault(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + |
| + const host = "blog.oncarlos.com" |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("attach %q under the apps domain, unreserved: refused %+v", host, ref) |
| + } |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", origin.Hostname(), "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { |
| + t.Fatalf("attach the console's own origin host: refusal = %+v, want 422", ref) |
| + } |
| +} |
| + |
| // TestAttachRefusesReservedSidecarSuffix: the attach door must refuse a |
| // custom domain under provision.SidecarSuffix — never a panic, the family's |
| // ordinary 422 (the same status HostDenied's platform-space refusal above |
diff --git a/internal/console/domainpolicy.go b/internal/console/domainpolicy.go| index 6d2d2b295..7147577ae 100644 |
| --- a/internal/console/domainpolicy.go |
| +++ b/internal/console/domainpolicy.go |
| @@ -7,7 +7,15 @@ import ( |
| "github.com/carlosframework/platform/internal/domains" |
| ) |
| +// directAppsDomainHost reports whether host is one label under the apps |
| +// domain on a deployment that reserves platform domains. Those names get the |
| +// delegated-alias limits: no wildcard, no catchall, no first route. Without |
| +// ReservePlatformDomains the apps domain is not reserved, so the answer is |
| +// false and the names are ordinary custom domains. |
| func (s *Server) directAppsDomainHost(host string) bool { |
| + if !s.cfg.ReservePlatformDomains { |
| + return false |
| + } |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| if domain == "" || !strings.HasSuffix(host, "."+domain) { |
| return false |
| @@ -16,7 +24,15 @@ func (s *Server) directAppsDomainHost(host string) bool { |
| return label != "" && !strings.Contains(label, ".") |
| } |
| +// domainHostDenied reports whether an attach of host by account is refused |
| +// as platform-managed. Both /domains doors (the CLI API door and the console |
| +// form's attachDomain) ask this one question, so the two cannot drift. The |
| +// console's own hostnames are refused by exact name on every deployment |
| +// (consoleHosts); deniedDomains refuses whole subtrees. |
| func (s *Server) domainHostDenied(host, account string) bool { |
| + if slices.Contains(s.consoleHosts(), host) { |
| + return true |
| + } |
| allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) |
| if allowed { |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
diff --git a/internal/console/domainpolicy_test.go b/internal/console/domainpolicy_test.go| index 3fa13e27e..23753ed45 100644 |
| --- a/internal/console/domainpolicy_test.go |
| +++ b/internal/console/domainpolicy_test.go |
| @@ -29,7 +29,7 @@ func TestDirectAppsDomainHost(t *testing.T) { |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
| - s := &Server{cfg: Config{AppsDomain: c.appsDomain}} |
| + s := &Server{cfg: Config{AppsDomain: c.appsDomain, ReservePlatformDomains: true}} |
| if got := s.directAppsDomainHost(c.host); got != c.want { |
| t.Errorf("directAppsDomainHost(%q) with AppsDomain=%q = %v, want %v", c.host, c.appsDomain, got, c.want) |
| } |
| @@ -42,10 +42,11 @@ func TestDomainHostDeniedPolicyMatrix(t *testing.T) { |
| const alias = "widget." + appsDomain |
| base := func() Config { |
| return Config{ |
| - AppsDomain: appsDomain, |
| - Origin: "https://origin." + appsDomain, |
| - DenyDomains: []string{"blocked." + appsDomain}, |
| - RedirectHosts: []string{"retired." + appsDomain}, |
| + AppsDomain: appsDomain, |
| + ReservePlatformDomains: true, |
| + Origin: "https://origin." + appsDomain, |
| + DenyDomains: []string{"blocked." + appsDomain}, |
| + RedirectHosts: []string{"retired." + appsDomain}, |
| } |
| } |
| cases := []struct { |
| @@ -86,6 +87,7 @@ func TestDomainHostDeniedPolicyMatrix(t *testing.T) { |
| func TestAPIDomainAttachDeniesAppsDomainAliasByDefault(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-default") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| attachBody("widget.oncarlos.test", "go.tito.io")) |
| if code != http.StatusBadRequest { |
| @@ -97,6 +99,7 @@ func TestAPIDomainAttachDeniesAppsDomainAliasByDefault(t *testing.T) { |
| func TestAPIDomainAttachAllowsAppsDomainAlias(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-allow") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| if got := attachDomain(t, fx, member, "widget.oncarlos.test", "go.tito.io"); !got.Created { |
| @@ -119,6 +122,7 @@ func TestAPIDomainAttachAllowsAppsDomainAlias(t *testing.T) { |
| func TestAPIDomainAttachAppsDomainAliasRejectsWildcardAndCatchall(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-wild") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| const host = "widget.oncarlos.test" |
| @@ -140,6 +144,7 @@ func TestAPIDomainAttachAppsDomainAliasRejectsWildcardAndCatchall(t *testing.T) |
| func TestAPIDomainAttachAppsDomainAliasStillRefusesForeignClaim(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-conflict") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| const host = "widget.oncarlos.test" |
| if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{ |
| @@ -162,6 +167,7 @@ func TestAPIDomainAttachAppsDomainAliasStillRefusesDeploymentRoute(t *testing.T) |
| ctx := context.Background() |
| fx, member := instanceFixture(t, "code-dom-alias-route") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| const host = "widget.oncarlos.test" |
| if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), |
| @@ -181,6 +187,7 @@ func TestAPIDomainAttachAppsDomainAliasStillRefusesDeploymentRoute(t *testing.T) |
| func TestAttachDomainCoreAppsDomainAlias(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.Registry = attachRegistry(t, |
| registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| @@ -210,6 +217,7 @@ func TestAttachDomainCoreAppsDomainAlias(t *testing.T) { |
| func TestAttachDomainCoreAppsDomainAliasStillRefusesHeldHost(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| fx.srv.cfg.Registry = attachRegistry(t, |
| registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| @@ -227,8 +235,9 @@ func TestAttachDomainCoreAppsDomainAliasStillRefusesHeldHost(t *testing.T) { |
| func TestAppsDomainAliasAccountAuthority(t *testing.T) { |
| s := &Server{cfg: Config{ |
| AppsDomain: "platform.example.test", AppsDomainAliasAccounts: []string{"bap"}, |
| - Origin: "https://console.platform.example.test", |
| - DenyDomains: []string{"example.test", "platform.example.test", "blocked.platform.example.test"}, |
| + ReservePlatformDomains: true, |
| + Origin: "https://console.platform.example.test", |
| + DenyDomains: []string{"example.test", "platform.example.test", "blocked.platform.example.test"}, |
| }} |
| for _, tc := range []struct { |
| host, account string |
| @@ -258,6 +267,7 @@ func TestAppsDomainAliasAccountAuthority(t *testing.T) { |
| func TestAppsDomainAliasCannotMintFirstRoute(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| fx.srv.cfg.Registry = attachRegistry(t) |
| for _, kind := range []string{"", "static"} { |
diff --git a/internal/console/server.go b/internal/console/server.go| index a922475b6..f0fd18ef3 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -526,11 +526,19 @@ type Config struct { |
| // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The |
| // deployment's own names, in other words: the edge's reserved tunnel |
| // hostname, retired console origins, anything platform-managed that is |
| - // not already derivable from this config. The two the console DOES |
| - // derive itself — Origin's hostname and AppsDomain — are added by |
| - // deniedDomains() (apidomains.go), so a deployment cannot forget them. |
| - // Empty is legal and means "only the derived two". |
| + // not already derivable from this config. Empty is legal. The console's |
| + // own hostnames are refused regardless (consoleHosts, apidomains.go). |
| DenyDomains []string |
| + // ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) is the |
| + // opt-in for a deployment that hosts apps for other people, such as the |
| + // flagship or carloku. With it set, the /domains door also refuses |
| + // everything under AppsDomain and under the console's own hostnames, |
| + // because on a shared platform those names belong to the operator and |
| + // not to a tenant. It is off by default. A self-hosted deployment |
| + // usually keeps its console, its apps and its custom domains in one |
| + // zone that one person owns, and the reserved rules refused ordinary |
| + // names there. See deniedDomains. |
| + ReservePlatformDomains bool |
| // StoreProvisioner is the backend the object-storage doors provision |
| // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| // ordinary deployment shape, not a broken one: members may still DECLARE |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 39aead0ff..89d232ffc 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -74,6 +74,15 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + |
| // line just set. Both are 0700 so re-runs converge instead of racing. |
| "d /data/instances 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| + // The relay's one writable state dir (carlos-relay.service's |
| + // ReadWritePaths). It is in bootstrap's dirs list too, but that list |
| + // only ever runs against a FRESH box or a staging root — a self-update |
| + // apply creates no directories, it re-runs tmpfiles. Without this line |
| + // a box bootstrapped before the relay existed gets the unit, the |
| + // binary and the master file and still fails to start with "Failed to |
| + // set up mount namespacing: /data/relay: No such file or directory" |
| + // (discussion #118, hit on the Ireland box 2026-09-07). |
| + "d /data/relay 0750 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| "d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| // The readiness probe's own instance directory (design §8.5): a name |
| @@ -161,13 +170,50 @@ var inertUnits = []string{"carlos-restore-verify.timer", "carlos-hibernate-verif |
| // bootstrapPlan is what `bootstrap -root <dir> -plan-json` prints: the |
| // arming list of THIS binary, so a self-update apply arms what the new |
| // binary's bootstrap would arm and never a list an older binary remembers. |
| +// |
| +// Armed is the other half of that promise, and it was missing until |
| +// discussion #118. Enabled alone says which units to `enable`; WHICH of them |
| +// also get a `start` was decided by the applier's own systemctlPlan — code |
| +// from the OLD binary, which cannot know about a unit the new build just |
| +// introduced. That is how the relay landed on the Ireland box enabled but |
| +// never started on 2026-09-07: the applying build predated |
| +// carlos-relay.service, so its systemctlPlan had no reason to start it. |
| +// Carrying the list the TARGET build would start closes that gap for every |
| +// future unit, not just the relay. |
| +// |
| +// Additive on purpose: a plan.json written by a build older than this field |
| +// (a rollback stages carlos.prev's plan) carries no "armed" key, and arm() |
| +// then decides locally exactly as it always did. The key is NOT omitempty: |
| +// nil ("this build is too old to say") and empty ("start nothing") are |
| +// different answers, and omitempty would collapse them into the same absent |
| +// key, making "start nothing" impossible for a build to express. |
| type bootstrapPlan struct { |
| Enabled []string `json:"enabled"` |
| Inert []string `json:"inert"` |
| + Armed []string `json:"armed"` |
| } |
| func currentBootstrapPlan() bootstrapPlan { |
| - return bootstrapPlan{Enabled: append([]string(nil), enabledUnits...), Inert: append([]string(nil), inertUnits...)} |
| + return bootstrapPlan{ |
| + Enabled: append([]string(nil), enabledUnits...), |
| + Inert: append([]string(nil), inertUnits...), |
| + Armed: armedUnits(enabledUnits), |
| + } |
| +} |
| + |
| +// armedUnits is the "and start these too" rule in one place: the unit types |
| +// whose enable-without-start is a silent no-op until the next boot, plus |
| +// carlos-relay.service, whose ConditionPathExists makes starting it safe on a |
| +// box with no region master. See systemctlPlan for the long version. |
| +func armedUnits(units []string) []string { |
| + var armed []string |
| + for _, u := range units { |
| + if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || |
| + strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName { |
| + armed = append(armed, u) |
| + } |
| + } |
| + return armed |
| } |
| // systemctlPlan is the exact systemctl sequence a live bootstrap runs — and |
| @@ -206,17 +252,18 @@ func currentBootstrapPlan() bootstrapPlan { |
| // tree with no protection, until the next reboot. Starting either is |
| // idempotent. |
| func systemctlPlan(units []string) [][]string { |
| + return systemctlPlanArming(units, armedUnits(units)) |
| +} |
| + |
| +// systemctlPlanArming is systemctlPlan with the start list supplied rather |
| +// than derived — the shape a self-update apply needs, where the units come |
| +// from the target build's plan.json and so does the decision about which of |
| +// them to start (discussion #118). |
| +func systemctlPlanArming(units, armed []string) [][]string { |
| plan := [][]string{ |
| {"daemon-reload"}, |
| append([]string{"enable"}, units...), |
| } |
| - var armed []string |
| - for _, u := range units { |
| - if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || |
| - strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName { |
| - armed = append(armed, u) |
| - } |
| - } |
| if len(armed) > 0 { |
| plan = append(plan, append([]string{"start"}, armed...)) |
| } |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 38b6ebd3e..77d9f305d 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/ed25519" |
| "encoding/base64" |
| "encoding/json" |
| + "fmt" |
| "log" |
| "net" |
| "os" |
| @@ -1675,3 +1676,83 @@ func TestBootstrapWritesJournaldCap(t *testing.T) { |
| t.Errorf("drop-in gone after a re-run: %v", err) |
| } |
| } |
| + |
| +// TestTmpfilesCarriesRelayStateDir is discussion #118 as a test. The relay's |
| +// unit declares ReadWritePaths=/data/relay, and systemd refuses to start a |
| +// unit whose ReadWritePaths names a directory that is not there — "Failed to |
| +// set up mount namespacing: /data/relay: No such file or directory", which is |
| +// what the Ireland box answered on 2026-09-07. |
| +// |
| +// bootstrap's own dirs list creates it, and that is not enough: the dirs list |
| +// runs on a FRESH box or against a staging root, never against the live root |
| +// of a self-update apply. What an apply re-runs is systemd-tmpfiles. So the |
| +// directory has to be in tmpfilesConf, and this test is what keeps a future |
| +// unit's writable state dir from being added to one list and not the other. |
| +func TestTmpfilesCarriesRelayStateDir(t *testing.T) { |
| + b, err := unitsFS.ReadFile("units/" + relayUnitName) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rw string |
| + for _, line := range strings.Split(string(b), "\n") { |
| + if v, ok := strings.CutPrefix(line, "ReadWritePaths="); ok { |
| + rw = v |
| + break |
| + } |
| + } |
| + if rw == "" { |
| + t.Fatal("carlos-relay.service declares no ReadWritePaths") |
| + } |
| + for _, p := range strings.Fields(rw) { |
| + if !strings.HasPrefix(p, "/data/") { |
| + continue // /run/carlos is tmpfs and already has its own line |
| + } |
| + want := "d " + p + " " |
| + if !strings.Contains(tmpfilesConf, want) { |
| + t.Errorf("tmpfilesConf has no %q line, so an apply onto a box that predates the unit leaves it unable to start:\n%s", want, tmpfilesConf) |
| + } |
| + } |
| + // The mode must match bootstrap's dirs list, or a re-run of one flips |
| + // what the other just set (the /data/instances lesson, one line up). |
| + // Read the mode bootstrap actually uses rather than restating it: a |
| + // hardcoded "0750" here passes happily while the two lists disagree. |
| + root := t.TempDir() |
| + if err := bootstrapWith(root, nil, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + fi, err := os.Stat(filepath.Join(root, "data", "relay")) |
| + if err != nil { |
| + t.Fatalf("bootstrap did not create /data/relay: %v", err) |
| + } |
| + want := fmt.Sprintf("d /data/relay %04o ", fi.Mode().Perm()) |
| + if !strings.Contains(tmpfilesConf, want) { |
| + t.Errorf("tmpfilesConf must carry %q to match the mode bootstrap's dirs list creates:\n%s", want, tmpfilesConf) |
| + } |
| +} |
| + |
| +// TestPlanCarriesArmedList pins the other half of discussion #118: the plan a |
| +// build prints says which units to START, not just which to enable, so an |
| +// applier running an OLDER binary arms what the new build wants rather than |
| +// what its own systemctlPlan happens to know about. |
| +func TestPlanCarriesArmedList(t *testing.T) { |
| + plan := currentBootstrapPlan() |
| + if len(plan.Armed) == 0 { |
| + t.Fatal("plan carries no armed list") |
| + } |
| + if !slices.Contains(plan.Armed, relayUnitName) { |
| + t.Errorf("armed list omits the relay: %v", plan.Armed) |
| + } |
| + if slices.Contains(plan.Armed, "carlos-edge.service") { |
| + t.Errorf("edge must stay start-by-hand: %v", plan.Armed) |
| + } |
| + for _, u := range plan.Armed { |
| + if !slices.Contains(plan.Enabled, u) { |
| + t.Errorf("armed unit %s is not in the enabled list", u) |
| + } |
| + } |
| + // The armed list and the local rule must agree for THIS build; they |
| + // diverge only across builds, which is the point of carrying it. |
| + if got, want := plan.Armed, armedUnits(enabledUnits); !slices.Equal(got, want) { |
| + t.Errorf("plan.Armed = %v, systemctlPlan would start %v", got, want) |
| + } |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 5ad62425e..198a3a074 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -710,13 +710,48 @@ func (a *systemApplier) gateOnce(ctx context.Context, target string) error { |
| return nil |
| } |
| -// arm is systemctlPlan verbatim: enable all, start timers and paths, and of |
| -// the services only carlos-relay, whose ConditionPathExists makes the start |
| -// a no-op on a box with no region master (relay spec §3). It starts nothing |
| -// else — in particular not the edge, which is running this very code — which |
| -// is why apply can call it from inside itself. |
| +// arm enables every unit in the plan and starts the ones the plan says to |
| +// start: the unit types whose enable-without-start is a silent no-op until |
| +// the next boot, and of the services only carlos-relay, whose |
| +// ConditionPathExists makes the start a no-op on a box with no region master |
| +// (relay spec §3). |
| +// |
| +// It never starts the edge, which is running this very code — that is why |
| +// apply can call it from inside itself. Before #118 that was structural: the |
| +// applier derived the start list itself and could not name a service other |
| +// than the relay. Now the list comes from the target build, so the guarantee |
| +// is enforced here instead, explicitly. |
| func (a *systemApplier) arm(plan bootstrapPlan) error { |
| - for _, args := range systemctlPlan(plan.Enabled) { |
| + // plan.Armed is the TARGET build's answer to "and which of these get a |
| + // start" (discussion #118). Preferring it is what lets a build introduce |
| + // a unit that must be started at apply time rather than at the next |
| + // boot; falling back to the local rule keeps a plan.json from an older |
| + // build — which is what a rollback stages — working unchanged. |
| + armed := plan.Armed |
| + if armed == nil { |
| + armed = armedUnits(plan.Enabled) |
| + } |
| + // Two guards on a list that now comes from another build. The edge is |
| + // never started here, whatever the plan says. And a unit that is not in |
| + // Enabled is dropped rather than passed to systemctl: `start` is ONE |
| + // batched call, so a single name this box has no unit file for would |
| + // fail the whole call and leave nothing armed at all. |
| + enabled := make(map[string]bool, len(plan.Enabled)) |
| + for _, u := range plan.Enabled { |
| + enabled[u] = true |
| + } |
| + kept := armed[:0:0] |
| + for _, u := range armed { |
| + switch { |
| + case u == systemEdgeUnit || u == systemEdgeUnit+".service": |
| + a.ops.logf("system apply: arm: refusing to start %s, which is running this code", u) |
| + case !enabled[u]: |
| + a.ops.logf("system apply: arm: %s is armed by the plan but not enabled by it; skipping", u) |
| + default: |
| + kept = append(kept, u) |
| + } |
| + } |
| + for _, args := range systemctlPlanArming(plan.Enabled, kept) { |
| if _, err := a.ops.systemctl(args...); err != nil { |
| return fmt.Errorf("systemctl %s: %w", strings.Join(args, " "), err) |
| } |
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go| index bf642a8a0..baf7b426a 100644 |
| --- a/internal/host/systemapply_test.go |
| +++ b/internal/host/systemapply_test.go |
| @@ -5,9 +5,11 @@ import ( |
| "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -1253,3 +1255,101 @@ func TestRecoveryStillHandlesALegacyBackingUpJournal(t *testing.T) { |
| t.Fatalf("result = %+v, want the run to carry on to its request", r) |
| } |
| } |
| + |
| +// TestApplyArmsTheTargetBuildsList is the forward-compatibility half of |
| +// discussion #118. The applier is the OLD binary: when the target build |
| +// introduces a unit that must be started at apply time rather than at the |
| +// next boot, only the target build's own plan knows that. So arm() starts |
| +// what plan.Armed names, even a unit this binary's own rule would skip. |
| +// |
| +// Without this, a unit new to the box is enabled and left inactive — which |
| +// is exactly how carlos-relay.service reached the Ireland box on 2026-09-07 |
| +// enabled, gated, armed with a masters file, and not running. |
| +func TestApplyArmsTheTargetBuildsList(t *testing.T) { |
| + _, started := armCalls(t, bootstrapPlan{ |
| + Enabled: []string{"carlos-edge.service", "carlos-future.service"}, |
| + // A service this binary's armedUnits would never pick. |
| + Armed: []string{"carlos-future.service"}, |
| + }) |
| + if !slices.Contains(started, "carlos-future.service") { |
| + t.Errorf("apply did not start the unit the target build asked for: started %v", started) |
| + } |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Errorf("apply started the edge, which runs this very code: started %v", started) |
| + } |
| +} |
| + |
| +// The plan's armed list comes from another build, so arm() does not take it |
| +// on trust: the edge is never started whatever the plan says, and a unit the |
| +// plan arms but does not enable is dropped rather than passed to systemctl, |
| +// because one bad name in the single batched `start` would lose every unit |
| +// in it. |
| +func TestApplyRefusesToArmTheEdgeOrAnUnenabledUnit(t *testing.T) { |
| + _, started := armCalls(t, bootstrapPlan{ |
| + Enabled: []string{"carlos-adopt.timer", "carlos-edge.service"}, |
| + Armed: []string{"carlos-adopt.timer", "carlos-edge.service", "carlos-never-installed.service"}, |
| + }) |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Errorf("the edge was started from the plan's armed list: started %v", started) |
| + } |
| + if slices.Contains(started, "carlos-never-installed.service") { |
| + t.Errorf("a unit the plan never enabled was handed to systemctl: started %v", started) |
| + } |
| + if !slices.Contains(started, "carlos-adopt.timer") { |
| + t.Errorf("the good unit was lost along with the bad ones: started %v", started) |
| + } |
| +} |
| + |
| +// A plan.json from a build older than the Armed field has no armed key. That |
| +// is what a rollback stages (carlos.prev's plan), so the fallback is not a |
| +// theoretical path — arm() must decide locally exactly as it did before. |
| +func TestApplyArmsLocallyWhenPlanPredatesArmed(t *testing.T) { |
| + var plan bootstrapPlan |
| + if err := json.Unmarshal([]byte(`{"enabled":["carlos-adopt.timer","carlos-edge.service","carlos-relay.service"]}`), &plan); err != nil { |
| + t.Fatal(err) |
| + } |
| + if plan.Armed != nil { |
| + t.Fatalf("armed should be absent, got %v", plan.Armed) |
| + } |
| + _, started := armCalls(t, plan) |
| + // Membership, not substring: the relay is batched onto the same start |
| + // line as the timer, and its bare name also appears on the enable line. |
| + // A substring check is how the first version of this test passed |
| + // against a fallback changed to drop the relay — the exact #118 failure |
| + // mode it exists to catch. |
| + for _, want := range []string{"carlos-adopt.timer", "carlos-relay.service"} { |
| + if !slices.Contains(started, want) { |
| + t.Errorf("fallback did not arm %s: started %v", want, started) |
| + } |
| + } |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Errorf("fallback started the edge: started %v", started) |
| + } |
| +} |
| + |
| +// armCalls runs arm(plan) against a recording systemd and returns the units |
| +// it enabled and the units it started. Both are read as SETS out of the two |
| +// batched systemctl lines: arm puts every unit on one `enable` and every |
| +// started unit on one `start`, so a substring check over the joined log |
| +// cannot tell "start carlos-relay.service" from the same name appearing in |
| +// the enable line, and cannot see a name that is merely batched beside |
| +// another. The first version of these tests got both wrong. |
| +func armCalls(t *testing.T, plan bootstrapPlan) (enabled, started []string) { |
| + t.Helper() |
| + a := &systemApplier{ops: sysOps{ |
| + systemctl: func(args ...string) (string, error) { |
| + switch args[0] { |
| + case "enable": |
| + enabled = append(enabled, args[1:]...) |
| + case "start": |
| + started = append(started, args[1:]...) |
| + } |
| + return "", nil |
| + }, |
| + logf: func(string, ...any) {}, |
| + }} |
| + if err := a.arm(plan); err != nil { |
| + t.Fatal(err) |
| + } |
| + return enabled, started |
| +} |
diff --git a/logs.go b/logs.go| index ce149e6e4..6d62489de 100644 |
| --- a/logs.go |
| +++ b/logs.go |
| @@ -48,7 +48,7 @@ func runLogs(args []string) { |
| fs.Var(&fields, "field", "only structured lines with this k=v field (repeatable)") |
| n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)") |
| follow := fs.Bool("f", false, "follow: poll for new lines until interrupted") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read 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, logsUsage) |
diff --git a/mail_cli.go b/mail_cli.go| index 2f9110ff2..7cc800ae1 100644 |
| --- a/mail_cli.go |
| +++ b/mail_cli.go |
| @@ -620,7 +620,7 @@ func runEmailEnable(args []string) { |
| domain := fs.String("domain", "", "sending domain to ensure and verify (default: the domain half of --from)") |
| timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for domain verification before giving up") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to enable through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to enable through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -733,7 +733,7 @@ func runEmailDomainsAdd(args []string) { |
| app := appFlag(fs, "app to add the domain for") |
| timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for verification before giving up") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos email domains add --app <a> [--timeout <dur>] [--account <sqid-or-name>] " + |
| @@ -795,7 +795,7 @@ func runEmailStatus(args []string) { |
| fs := flag.NewFlagSet("email status", flag.ExitOnError) |
| app := appFlag(fs, "app whose email to show") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -890,7 +890,7 @@ func runEmailEvents(args []string) { |
| to := fs.String("to", "", "only events about this recipient address") |
| n := fs.Int("n", 0, "at most this many events (default 100, max 1000)") |
| asJSON := fs.Bool("json", false, "print the events as JSON, one array, for a script") |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -999,7 +999,7 @@ func runEmailEventsPush(args []string) { |
| path := fs.String("path", "", "the path on the app's own host to POST each event to, e.g. /mail/events") |
| off := fs.Bool("off", false, "stop telling the app") |
| asJSON := fs.Bool("json", false, "print the reply as JSON") |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1263,7 +1263,7 @@ func runEmailTest(args []string) { |
| app := appFlag(fs, "app to send a test message through") |
| to := fs.String("to", "", "address to send the test message to") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1469,7 +1469,7 @@ func runEmailCredentialsCreate(args []string) { |
| app := appFlag(fs, "app to mint a standalone credential for") |
| credName := fs.String("name", "", "credential name (lowercase, digits, hyphens)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos email credentials create --app <a> --name <n> [--account <sqid-or-name>] " + |
| @@ -1514,7 +1514,7 @@ func runEmailCredentialsList(args []string) { |
| fs := flag.NewFlagSet("email credentials list", flag.ExitOnError) |
| app := appFlag(fs, "app whose standalone credentials to list") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1557,7 +1557,7 @@ func runEmailCredentialsRevoke(args []string) { |
| app := appFlag(fs, "app whose standalone credential to revoke") |
| credName := fs.String("name", "", "credential name to revoke") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos email credentials revoke --app <a> --name <n> [--account <sqid-or-name>] " + |
| @@ -1591,7 +1591,7 @@ func runEmailRotate(args []string) { |
| app := appFlag(fs, "app whose delivered email credential to rotate") |
| finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -1637,7 +1637,7 @@ func runEmailPause(args []string, paused bool) { |
| fs := flag.NewFlagSet("email "+verb, flag.ExitOnError) |
| app := appFlag(fs, "app to "+verb) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
diff --git a/main.go b/main.go| index cb75ccf2d..ebec0f470 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -112,6 +112,8 @@ func main() { |
| RunLedger(args) |
| case "auth": |
| RunAuth(args) |
| + case "config": |
| + RunConfig(args) |
| case "accounts": |
| RunAccounts(args) |
| case "apps": |
| @@ -177,6 +179,8 @@ flags; --app, the flag on nearly every app verb, shortens to -a. |
| Your apps — ship and run software on a CARLOS deployment |
| auth log in, see who this terminal is, log out |
| + config edit .carlos/config: "config destinations" lists, adds, |
| + changes and removes the named places -d deploys to |
| apps create | place | delete | restore an app name — --place |
| fleet/<f> claims and places in one step, and "apps place" |
| repoints an app's routes and instances onto a customer |
diff --git a/pipeline.go b/pipeline.go| index 8598d33e3..88889ea30 100644 |
| --- a/pipeline.go |
| +++ b/pipeline.go |
| @@ -120,7 +120,7 @@ func pipelineFlagSet(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, co |
| // API mode never opens the local registry — the third return |
| // (accountFlags' direct-mode resolver) has no use here and is dropped. |
| acctRaw, acctIDRaw, _ = accountFlags(fs) |
| - consoleFlag = fs.String("console", "", "console origin to work through (default: the only logged-in one)") |
| + consoleFlag = consoleOption(fs, "console origin to work through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, pipelineUsage) |
| return fs, app, acctRaw, acctIDRaw, consoleFlag |
| } |
diff --git a/relay.go b/relay.go| index a9b69efe8..f678ea8ca 100644 |
| --- a/relay.go |
| +++ b/relay.go |
| @@ -190,7 +190,7 @@ func runRelayEnable(args []string) { |
| app := appFlag(fs, "app to enable the relay for") |
| regions := fs.String("regions", "", "comma-separated regions to enable (default: every region this deployment arms)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to enable through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to enable through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -222,7 +222,7 @@ func runRelayStatus(args []string) { |
| fs := flag.NewFlagSet("relay status", flag.ExitOnError) |
| app := appFlag(fs, "app whose relay to show") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -248,7 +248,7 @@ func runRelayDisable(args []string) { |
| fs := flag.NewFlagSet("relay disable", flag.ExitOnError) |
| app := appFlag(fs, "app to disable the relay for") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to disable through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to disable through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -275,7 +275,7 @@ func runRelayAdopted(args []string) { |
| app := appFlag(fs, "app the route belongs to") |
| host := fs.String("host", "", "the route that now runs the issued relay key") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to acknowledge through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to acknowledge through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -309,7 +309,7 @@ func runRelayAdopted(args []string) { |
| func runRelayRotate(args []string) { |
| fs := flag.NewFlagSet("relay rotate", flag.ExitOnError) |
| region := fs.String("region", "", "region whose issuer pointer to move onto the newly staged master") |
| - consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if strings.TrimSpace(*region) == "" { |
diff --git a/restart.go b/restart.go| index bb5f439a9..5e8744ec7 100644 |
| --- a/restart.go |
| +++ b/restart.go |
| @@ -55,7 +55,7 @@ func runRestart(args []string) { |
| environment := environmentFlag(fs, "cycle every route reading this named config bundle, instead of every instance of the app") |
| dryRun := fs.Bool("dry-run", false, "name the routes this would cycle and stop; nothing is restarted") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to restart through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to restart through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, restartUsage) |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
diff --git a/retention.go b/retention.go| index aec80e2a0..4b8920184 100644 |
| --- a/retention.go |
| +++ b/retention.go |
| @@ -26,7 +26,7 @@ func runReleasesRetention(args []string) { |
| off := fs.Bool("off", false, "clear the retention policy — keep releases forever again") |
| dryRun := fs.Bool("dry-run", false, "with --keep: list what would be pruned, write nothing") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
diff --git a/routes_cli.go b/routes_cli.go| index 6e5a3a9b7..e6ed64469 100644 |
| --- a/routes_cli.go |
| +++ b/routes_cli.go |
| @@ -44,7 +44,7 @@ func runRoutes(args []string) { |
| fs := flag.NewFlagSet("routes", flag.ExitOnError) |
| app := appFlag(fs, "app whose routes to list") |
| match := fs.String("match", "", "only hosts containing this substring") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + 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, routesUsage) |
diff --git a/scan.go b/scan.go| index 61bfd65e6..e938cec45 100644 |
| --- a/scan.go |
| +++ b/scan.go |
| @@ -83,7 +83,7 @@ func runStoreScan(args []string) { |
| func scanTarget(fs *flag.FlagSet, args []string, verb, what string) (*apiSession, apiAcct, string) { |
| app := appFlag(fs, what) |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to act through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to act through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
diff --git a/schedule.go b/schedule.go| index 8d4af1f44..2c5ebcb30 100644 |
| --- a/schedule.go |
| +++ b/schedule.go |
| @@ -107,7 +107,7 @@ func runScheduleLs(args []string) { |
| fs := flag.NewFlagSet("schedule ls", flag.ExitOnError) |
| app := appFlag(fs, "app whose schedules to list") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleLsUsage) |
| fs.Parse(args) |
| @@ -130,7 +130,7 @@ func runScheduleSet(args []string) { |
| cron := fs.String("cron", "", `fire on a 5-field cron expression ("min hour dom mon dow") — give this or --every, not both`) |
| path := fs.String("path", "", "absolute path on the app's own instance to POST at each fire") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to record through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleSetUsage) |
| fs.Parse(args) |
| @@ -176,7 +176,7 @@ func runScheduleRm(args []string) { |
| app := appFlag(fs, "app the schedule belongs to") |
| name := fs.String("name", "", "schedule name to remove") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleRmUsage) |
| fs.Parse(args) |
| scheduleName := strings.TrimSpace(*name) |
| @@ -200,7 +200,7 @@ func runScheduleRun(args []string) { |
| app := appFlag(fs, "app the schedule belongs to") |
| name := fs.String("name", "", "schedule name to run now") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console to run through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to run through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, scheduleRunUsage) |
| fs.Parse(args) |
| scheduleName := strings.TrimSpace(*name) |
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go| index 56f27be90..eb7df661b 100644 |
| --- a/scopedkeys_e2e_test.go |
| +++ b/scopedkeys_e2e_test.go |
| @@ -33,6 +33,7 @@ func carlosTestEnv(t *testing.T, extra ...string) []string { |
| "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| "HOME="+scratch, |
| "CARLOS_CONSOLE=", |
| + "CARLOS_DESTINATION=", |
| ) |
| return append(env, extra...) |
| } |
diff --git a/services.go b/services.go| index 2b47884cb..a059d1b1e 100644 |
| --- a/services.go |
| +++ b/services.go |
| @@ -158,7 +158,7 @@ func runServicesCreate(args []string) { |
| app := appFlag(fs, "bind the credential to one app (default: account-wide)") |
| acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| const usageMsg = "usage: carlos services create --role <publish|instance|operate|service|admin> [--app <app>] " + |
| @@ -273,7 +273,7 @@ func runServicesList(args []string) { |
| fs := flag.NewFlagSet("services ls", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) to list credentials for (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| @@ -321,7 +321,7 @@ func runServicesRotate(args []string) { |
| fs := flag.NewFlagSet("services rotate", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the credential 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") |
| - consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| @@ -370,7 +370,7 @@ func runServicesRevoke(args []string) { |
| fs := flag.NewFlagSet("services revoke", flag.ExitOnError) |
| acctRaw := fs.String("account", "", "account (sqid or name) the credential 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") |
| - consoleFlag := fs.String("console", "", "console to revoke through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to revoke through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
diff --git a/sidecars_cli.go b/sidecars_cli.go| index bc3d97ccd..ec7dc5e0c 100644 |
| --- a/sidecars_cli.go |
| +++ b/sidecars_cli.go |
| @@ -111,7 +111,7 @@ func runSidecarsEnable(args []string) { |
| idle := fs.String("idle", "", "idle timeout before a hibernating sidecar sleeps (default 5m; Go duration)") |
| maxRun := fs.String("max-run", "", "max run duration before the sweeper stops a sidecar (default 1h; Go duration)") |
| noHibernate := fs.Bool("no-hibernate", false, "this app's sidecars never hibernate (default: they do)") |
| - consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to enable through (signed-in mode only; default: the only logged-in one)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app != "" && !release.ValidApp(*app) { |
| @@ -341,7 +341,7 @@ func runSidecarsCreate(args []string) { |
| app := fs.String("app", "", "app the sidecar belongs to") |
| region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list, or the app's own placement)") |
| channel := fs.String("channel", "", "release channel the sidecar follows (default: the manifest's, then stable)") |
| - consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)") |
| + 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") |
| fs.Parse(args) |
| @@ -394,7 +394,7 @@ func runSidecarsList(args []string) { |
| fs := flag.NewFlagSet("sidecars ls", flag.ExitOnError) |
| app := fs.String("app", "", "app whose sidecar records to list") |
| long := fs.Bool("long", false, "print the synthetic host instead of the short name") |
| - consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + 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.Parse(args) |
| @@ -437,7 +437,7 @@ func runSidecarsList(args []string) { |
| func runSidecarsWake(args []string) { |
| fs := flag.NewFlagSet("sidecars wake", flag.ExitOnError) |
| app := fs.String("app", "", "app the sidecar belongs to") |
| - consoleFlag := fs.String("console", "", "console to wake through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to wake 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.Parse(args) |
| @@ -466,7 +466,7 @@ func runSidecarsWake(args []string) { |
| func runSidecarsRm(args []string) { |
| fs := flag.NewFlagSet("sidecars rm", flag.ExitOnError) |
| app := fs.String("app", "", "app the sidecar belongs to") |
| - consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| + 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.Parse(args) |
| @@ -493,7 +493,7 @@ func runSidecarsRm(args []string) { |
| func runSidecarsLogs(args []string) { |
| fs := flag.NewFlagSet("sidecars logs", flag.ExitOnError) |
| app := fs.String("app", "", "app the sidecar belongs to") |
| - consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to read 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.Parse(args) |
diff --git a/store.go b/store.go| index 7f09b4da0..0db093234 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -165,7 +165,7 @@ func runStoreCreate(args []string) { |
| app := appFlag(fs, "app to declare a store for") |
| envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to declare through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to declare through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -209,7 +209,7 @@ func runStoreStatus(args []string) { |
| fs := flag.NewFlagSet("store status", flag.ExitOnError) |
| app := appFlag(fs, "app whose store to show") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -236,7 +236,7 @@ func runStoreGrant(args []string) { |
| app := appFlag(fs, "app to grant a store for") |
| bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to grant through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to grant through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -263,7 +263,7 @@ func runStoreRotate(args []string) { |
| app := appFlag(fs, "app whose store credential to rotate") |
| finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| @@ -289,7 +289,7 @@ func runStoreApply(args []string) { |
| fs := flag.NewFlagSet("store apply", flag.ExitOnError) |
| note := fs.String("note", "", "what you plan to use object storage for") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| - consoleFlag := fs.String("console", "", "console origin to apply through") |
| + consoleFlag := consoleOption(fs, "console origin to apply through") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 0 { |
| @@ -307,7 +307,7 @@ func runStoreApply(args []string) { |
| func runStoreApprovals(args []string) { |
| fs := flag.NewFlagSet("store approvals", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin to read through") |
| + consoleFlag := consoleOption(fs, "console origin to read through") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 0 { |
| @@ -346,7 +346,7 @@ func runStoreApproveWithSession(ctx context.Context, sess *apiSession, args []st |
| fs := flag.NewFlagSet("store approve", flag.ContinueOnError) |
| fs.SetOutput(out) |
| accountFlag := fs.String("account", "", "account sqid to approve") |
| - consoleFlag := fs.String("console", "", "console origin to approve through") |
| + consoleFlag := consoleOption(fs, "console origin to approve through") |
| fs.Usage = flagUsage(fs, "") |
| if err := fs.Parse(args); err != nil { |
| return err |
| @@ -374,7 +374,7 @@ func runStoreDecline(args []string) { |
| fs := flag.NewFlagSet("store decline", flag.ExitOnError) |
| accountFlag := fs.String("account", "", "account sqid to decline") |
| reason := fs.String("reason", "", "reason for declining object storage") |
| - consoleFlag := fs.String("console", "", "console origin to decline through") |
| + consoleFlag := consoleOption(fs, "console origin to decline through") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| if fs.NArg() != 0 { |
diff --git a/system.go b/system.go| index e2bad288c..1eb6ca91c 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -175,7 +175,7 @@ type systemStatusResponse struct { |
| func runSystemUpdate(args []string) { |
| fs := flag.NewFlagSet("system update", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "stable", "canary or stable") |
| version := fs.String("version", "", "release tag to promote (default: the latest public release); with --file, the version the binary reports") |
| file := fs.String("file", "", "a locally built binary instead of a public release (dev and pre-release)") |
| @@ -361,7 +361,7 @@ func (s *apiSession) uploadSystemBinary(ctx context.Context, path, version, plat |
| func runSystemStatus(args []string) { |
| fs := flag.NewFlagSet("system status", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "", "only boxes on this channel") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| @@ -446,7 +446,7 @@ func printSystemStatus(w io.Writer, resp systemStatusResponse, channel string) { |
| func runSystemRollback(args []string) { |
| fs := flag.NewFlagSet("system rollback", flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "", "canary or stable (required)") |
| yes := fs.Bool("yes", false, "do not ask") |
| fs.Usage = flagUsage(fs, "") |
| @@ -500,7 +500,7 @@ func runSystemAntivirusVerb(rest []string) { |
| func runSystemAntivirus(args []string, verb string) { |
| fs := flag.NewFlagSet("system antivirus "+verb, flag.ExitOnError) |
| - consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |