cli/e2e: carlos env exec-delivery — issue #18's opt-in verb
Its own verb surface, not carlos env set -app <a> EXEC_DELIVERY=...: exec_delivery isn't a config var (never rendered into an EnvironmentFile, never listed alongside KEY/VALUE pairs), it's a manifest-level flag with its own validation and its own app-level-only restriction, so it gets its own parsing instead of a special-cased key name inside env set's KV parser. `carlos env exec-delivery -app <a> file|off` accepts -environment purely so a mistaken attempt gets a specific refusal (explaining exec_delivery is app-level only, read from the default bundle alone) rather than a generic "unknown flag" — there is no environment-scoped path to route it to, since appconfig.SetExecDelivery has no environment parameter at all. A plain `env list` (no -environment) now prints an "exec-delivery: file" line when the field is set; a named environment's listing never mentions it, matching where it can ever actually be written. TestExecDeliveryCLI follows TestEnvCLI's build-a-real-binary pattern: round trip (on, mentioned in list, off, mention gone, existing vars untouched throughout), unknown-value refusal, named-environment-placement refusal (and confirms no environment bundle got written), and the never-configured-app "off" refusal mirroring `env unset`'s existing idiom.
2 files changed,
+176
−4
appconfig_cli.go+71 −4e2e_test.go+105 −0
diff --git a/appconfig_cli.go b/appconfig_cli.go| index a00157af2..0aaeec0f3 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -21,15 +21,25 @@ import ( |
| const envUsage = `usage: carlos env <command> |
| commands: |
| - set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] set one or more plain config vars |
| - unset -app <a> [-environment <name>] KEY [KEY2 ...] remove config vars |
| - list -app <a> [-environment <name>] list an app's config vars |
| - environments -app <a> list an app's named environment bundles |
| + set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] set one or more plain config vars |
| + unset -app <a> [-environment <name>] KEY [KEY2 ...] remove config vars |
| + list -app <a> [-environment <name>] list an app's config vars |
| + environments -app <a> list an app's named environment bundles |
| + exec-delivery -app <a> file|off opt an app's TCP-addr exec routes into config delivery (issue #18) |
| -environment names a bundle at apps/<acct>/<app>/environments/<name>.json, |
| layered on top of the default bundle (config.json) at materialization time — |
| see LoadMerged. Omit -environment for the default bundle itself. The bundle |
| is created implicitly on its first "set", same as config.json today. |
| + |
| +exec-delivery is APP-LEVEL ONLY (never -environment — see runEnvExecDelivery): |
| +it sets/clears "exec_delivery" in the default bundle alone. "file" makes |
| +SyncExec write /data/<host>.config.env for the app's TCP-addr exec routes |
| +(routes carlos proxies to but does not spawn — e.g. titogo's router fleet) |
| +exactly as it already does for socket-exec routes, but those routes are |
| +NEVER bounced: carlos does not own the process, so the app's own supervisor |
| +owns the restart. "off" clears the field; a plain "env list" mentions it |
| +when set. |
| ` |
| // RunEnv is the `carlos env` verb. |
| @@ -47,6 +57,8 @@ func RunEnv(args []string) { |
| runEnvList(args[1:]) |
| case "environments": |
| runEnvEnvironments(args[1:]) |
| + case "exec-delivery": |
| + runEnvExecDelivery(args[1:]) |
| default: |
| fmt.Fprint(os.Stderr, envUsage) |
| os.Exit(1) |
| @@ -213,6 +225,14 @@ func runEnvList(args []string) { |
| log.Fatal(err) |
| } |
| fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment)) |
| + // exec_delivery is app-level only (read from the default bundle |
| + // alone — see appconfig.Manifest.ExecDelivery) and never printed for |
| + // a named environment's listing, matching where it can ever actually |
| + // be SET (runEnvExecDelivery refuses -environment before it reaches |
| + // appconfig at all). |
| + if *environment == "" && m.ExecDelivery != "" { |
| + fmt.Printf("exec-delivery: %s\n", m.ExecDelivery) |
| + } |
| var keys []string |
| for k, v := range m.Vars { |
| if v.Kind == "env" { |
| @@ -228,6 +248,53 @@ func runEnvList(args []string) { |
| w.Flush() |
| } |
| +// runEnvExecDelivery is `carlos env exec-delivery` — issue #18's opt-in |
| +// verb, deliberately NOT `env set -app <a> EXEC_DELIVERY=...`: the field |
| +// is not a config var (it's never rendered into an EnvironmentFile, never |
| +// listed alongside KEY/VALUE pairs), it's a manifest-level flag with its |
| +// own validation and its own restriction to the default bundle, so it |
| +// gets its own verb surface instead of overloading env set's KEY=value |
| +// parsing with a special-cased key name. |
| +func runEnvExecDelivery(args []string) { |
| + fs := flag.NewFlagSet("env exec-delivery", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + // -environment is accepted (matching every other env subcommand's |
| + // flag set) purely so a mistaken attempt gets a clear, specific |
| + // refusal here rather than a generic "unknown flag" — see the check |
| + // below. There is no environment-scoped variant to route it to: |
| + // appconfig.SetExecDelivery has no environment parameter at all. |
| + environment := fs.String("environment", "", "refused: exec_delivery is app-level only (default bundle), never a named environment") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() != 1 { |
| + log.Fatal("usage: carlos env exec-delivery -app <a> file|off") |
| + } |
| + if *environment != "" { |
| + log.Fatal(`carlos env exec-delivery: -environment is refused — exec_delivery is APP-LEVEL ONLY, read from the ` + |
| + "default bundle (config.json) alone. A named environment's manifest is never consulted for it (SyncExec " + |
| + "ignores it there with a log, per the issue #18 design comment's \"config-that-lies\" rationale: the reader " + |
| + "is the app's own supervisor, one program per app, not per-environment) — so setting it on one would " + |
| + "silently do nothing useful. Omit -environment.") |
| + } |
| + var value string |
| + switch fs.Arg(0) { |
| + case appconfig.ExecDeliveryFile: |
| + value = appconfig.ExecDeliveryFile |
| + case "off": |
| + value = "" |
| + default: |
| + log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or \"off\"", fs.Arg(0), appconfig.ExecDeliveryFile) |
| + } |
| + if err := appconfig.SetExecDelivery(context.Background(), openStore(), acctOf(), *app, value); err != nil { |
| + log.Fatal(err) |
| + } |
| + if value == "" { |
| + fmt.Printf("exec-delivery off for %s\n", *app) |
| + } else { |
| + fmt.Printf("exec-delivery %s for %s\n", value, *app) |
| + } |
| +} |
| + |
| func runEnvEnvironments(args []string) { |
| fs := flag.NewFlagSet("env environments", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
diff --git a/e2e_test.go b/e2e_test.go| index c49e0670f..ec7c817f9 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -1606,6 +1606,111 @@ func TestEnvironmentBundlesCLI(t *testing.T) { |
| } |
| } |
| +// TestExecDeliveryCLI exercises `carlos env exec-delivery` (issue #18): |
| +// the app-level opt-in that lets SyncExec materialize |
| +// /data/<host>.config.env for TCP-addr exec routes — round-trip, unknown |
| +// value refusal, and named-environment placement refusal, following |
| +// TestEnvCLI's build-a-real-binary pattern. |
| +func TestExecDeliveryCLI(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + run := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + return string(out) |
| + } |
| + runFails := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out) |
| + } |
| + return string(out) |
| + } |
| + |
| + // Round trip: off by default (not mentioned in a plain list), "file" |
| + // turns it on and a plain `env list` mentions it, "off" clears it |
| + // again and the mention disappears. |
| + run("env", "set", "-account", "bab", "-app", "titogo", "K=1") |
| + out := run("env", "list", "-account", "bab", "-app", "titogo") |
| + if strings.Contains(out, "exec-delivery") { |
| + t.Fatalf("env list before opt-in = %q, must not mention exec-delivery", out) |
| + } |
| + |
| + out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "file") |
| + if !strings.Contains(out, "titogo") || !strings.Contains(out, "file") { |
| + t.Fatalf("env exec-delivery file = %q, want a confirmation naming the app and the value", out) |
| + } |
| + out = run("env", "list", "-account", "bab", "-app", "titogo") |
| + if !regexp.MustCompile(`(?m)^exec-delivery:\s*file$`).MatchString(out) { |
| + t.Fatalf("env list after opt-in = %q, want an exec-delivery: file line", out) |
| + } |
| + // The var set earlier survives — exec-delivery is a field write, not a |
| + // bundle replace. |
| + if !regexp.MustCompile(`(?m)^K\s+1$`).MatchString(out) { |
| + t.Fatalf("env list after opt-in = %q, lost K=1 (exec-delivery must not clobber existing vars)", out) |
| + } |
| + |
| + out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "off") |
| + if !strings.Contains(out, "off") { |
| + t.Fatalf("env exec-delivery off = %q, want a confirmation", out) |
| + } |
| + out = run("env", "list", "-account", "bab", "-app", "titogo") |
| + if strings.Contains(out, "exec-delivery") { |
| + t.Fatalf("env list after off = %q, must no longer mention exec-delivery", out) |
| + } |
| + |
| + // Unknown values are refused at set time, naming the bad value. |
| + msg := runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "grpc") |
| + if !strings.Contains(msg, "grpc") { |
| + t.Fatalf("env exec-delivery grpc = %q, want a refusal naming the bad value", msg) |
| + } |
| + msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "on") // not the spelling |
| + if !strings.Contains(msg, "on") { |
| + t.Fatalf("env exec-delivery on = %q, want a refusal naming the bad value", msg) |
| + } |
| + |
| + // Named-environment placement is refused before it ever reaches the |
| + // store: -environment is app-level only for this verb. |
| + msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "file") |
| + if !strings.Contains(strings.ToLower(msg), "app-level") { |
| + t.Fatalf("env exec-delivery -environment demo file = %q, want a refusal explaining it's app-level only", msg) |
| + } |
| + store := release.NewDirStore(storeDir) |
| + if _, _, err := store.Get(context.Background(), "apps/bab/titogo/environments/demo.json"); err == nil { |
| + t.Fatal("env exec-delivery -environment demo wrote a named environment bundle despite the refusal") |
| + } |
| + |
| + // Turning off against an app that was never configured at all is |
| + // refused, not a silent create — same idiom as `env unset` on a |
| + // nonexistent bundle. |
| + msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo-never-configured", "off") |
| + if !strings.Contains(msg, "nothing to turn off") { |
| + t.Fatalf("env exec-delivery off on a never-configured app = %q, want a refusal", msg) |
| + } |
| + if _, _, err := store.Get(context.Background(), "apps/bab/titogo-never-configured/config.json"); err == nil { |
| + t.Fatal("env exec-delivery off on a never-configured app created its config.json") |
| + } |
| +} |
| + |
| // TestEnvironmentMergeVisibleFromCLI is the merge-visible check the plan |
| // asks for: set the default bundle plus a named override through the CLI |
| // exactly as an operator would, then verify — via the library's LoadMerged, |