carlos / platform Public

Merge remote-tracking branch 'origin/main' into fix/timer-rearm-completion

Paul Campbell pushed by paul@keymail.dev 976df5cf58a493124651caece0aa745d10822761 parent ccda4f1parent 1643418
55 files changed, +2390 −288
  • AGENTS.md +19 −1
  • README.md +7 −0
  • accounts.go +2 −2
  • analytics_cmd.go +1 −1
  • appconfig_cli.go +12 −11
  • apps.go +4 −4
  • apps_buildprobe.go +1 −1
  • auth.go +15 −6
  • canary.go +3 −3
  • cli.go +12 −12
  • cmd/console/main.go +25 −4
  • color.go +5 −1
  • config.go +247 −76
  • config_cli.go +691 −0
  • config_cli_test.go +340 −0
  • config_test.go +1 −0
  • deploy.go +8 −4
  • destinations_test.go +308 −0
  • docs/site/cli.md +82 −1
  • domains_cli.go +4 −4
  • e2e_test.go +7 −0
  • economics.go +2 −2
  • errors_cmd.go +1 −1
  • features_cli.go +2 −2
  • flags.go +31 −2
  • fleets.go +6 −6
  • gate_cli.go +1 −1
  • instances_cli.go +8 −8
  • internal/cliflag/cliflag.go +4 −2
  • internal/console/apidomains.go +84 −46
  • internal/console/apidomains_test.go +57 −13
  • internal/console/domainattach.go +1 −1
  • internal/console/domainattach_test.go +24 −0
  • internal/console/domainpolicy.go +16 −0
  • internal/console/domainpolicy_test.go +17 −7
  • internal/console/server.go +12 −4
  • internal/host/bootstrap.go +55 −8
  • internal/host/host_test.go +81 −0
  • internal/host/systemapply.go +41 −6
  • internal/host/systemapply_test.go +100 −0
  • logs.go +1 −1
  • mail_cli.go +11 −11
  • main.go +4 −0
  • pipeline.go +1 −1
  • relay.go +5 −5
  • restart.go +1 −1
  • retention.go +1 −1
  • routes_cli.go +1 −1
  • scan.go +1 −1
  • schedule.go +4 −4
  • scopedkeys_e2e_test.go +1 −0
  • services.go +4 −4
  • sidecars_cli.go +6 −6
  • store.go +8 −8
  • system.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)