cli+console: carlos env exec-delivery -environment (#195)
The vocabulary changes with the scope, deliberately: at app scope the clearing word is "off", in a named bundle it is "inherit", and each scope refuses the other's by name. Spelling a bundle clear "off" would read as "stop delivering" while doing the opposite whenever the app default is on. env list now reports whichever bundle it was asked for, and stays silent about an inherited value rather than rendering it as a statement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue
4 files changed,
+274
−91
appconfig_cli.go+90 −50e2e_test.go+50 −12internal/console/apiconfig.go+48 −24internal/console/apiconfig_test.go+86 −5
diff --git a/appconfig_cli.go b/appconfig_cli.go| index 4dc3facc0..9ad738c13 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -24,7 +24,7 @@ commands: |
| 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) |
| + exec-delivery -app <a> [-environment <name>] file|off|inherit opt one bundle's TCP-addr exec routes into config delivery |
| sync -app <a> request immediate config convergence (issue #44) |
| -environment names a bundle at apps/<acct>/<app>/environments/<name>.json, |
| @@ -32,14 +32,24 @@ 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. |
| +exec-delivery sets "exec_delivery" in ONE bundle. "file" makes SyncExec |
| +write /data/<host>.config.env for the TCP-addr exec routes that resolve |
| +through that bundle (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. |
| + |
| +Without -environment it is the app's default bundle, and "off" clears it. |
| +With -environment it is that bundle alone, and "inherit" clears it — a |
| +bundle that says nothing takes the app default's answer, so clearing there |
| +is NOT the same as turning delivery off. To deliver to one service only: |
| +turn the app default off, then set "file" on the bundle its route is bound |
| +to (carlos route -host <h> -environment <name> binds it). |
| + |
| +Turning the app default off does NOT delete files already written for |
| +routes that lose the opt-in: they hold decrypted secrets, and carlos cannot |
| +know when the app's own supervisor has stopped reading them. It names each |
| +one in its log every pass until you delete it by hand. |
| sync touches apps/<acct>/<app>/config-gen.json — the agent's fast poll loop |
| (host package) notices within ~3s and re-renders exec-backed instances; |
| @@ -329,17 +339,19 @@ func envVarsFromAPI(vars []apiConfigVar) map[string]string { |
| } |
| // printEnvList renders `env list`'s output — header line, an exec-delivery |
| -// line when set and only for the default bundle (app-level only — see |
| -// appconfig.Manifest.ExecDelivery), then a sorted KEY/VALUE tabwriter — |
| +// line when the listed bundle sets one (see appconfig.Manifest.ExecDelivery), |
| +// then a sorted KEY/VALUE tabwriter — |
| // byte-identical between the direct and API branches of runEnvList, which |
| // differ only in HOW they fetch execDelivery/vars. |
| func printEnvList(app, environment, execDelivery string, vars map[string]string) { |
| fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment)))) |
| - // exec_delivery is app-level only 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 == "" && execDelivery != "" { |
| + // exec_delivery is printed for whichever bundle is being listed — since |
| + // issue #195 it can be set in a named one, where it means "routes bound |
| + // to this environment get files". A bundle SILENT on it prints nothing |
| + // rather than the value it inherits: the listing shows what this bundle |
| + // holds, and rendering an inherited value would make the bundle look |
| + // like it states something it does not. |
| + if execDelivery != "" { |
| fmt.Printf("exec-delivery: %s\n", execDelivery) |
| } |
| var keys []string |
| @@ -359,41 +371,56 @@ func printEnvList(app, environment, execDelivery string, vars map[string]string) |
| // 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. |
| +// own validation and its own bundle, so it gets its own verb surface |
| +// instead of overloading env set's KEY=value parsing with a special-cased |
| +// key name. |
| +// |
| +// -environment (issue #195) writes the flag into that bundle instead of |
| +// the app default. The VOCABULARY changes with the scope, and that is the |
| +// point: at app scope the second word is "off", because clearing the |
| +// default bundle's field really does stop delivery; in a named bundle it |
| +// is "inherit", because clearing it there hands the question back to the |
| +// app default. Spelling the second case "off" would read as "stop |
| +// delivering" while doing the opposite whenever the app default is on — |
| +// exactly the kind of quiet lie this feature exists to avoid — so "off" |
| +// is refused there, by name, rather than accepted as a synonym. |
| 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") |
| + environment := fs.String("environment", "", |
| + "named environment bundle to set the flag in (omit for the app's 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)") |
| fs.Parse(args) |
| - usage := "usage: carlos env exec-delivery -app <a> [-console <origin>] file|off" |
| + usage := "usage: carlos env exec-delivery -app <a> [-console <origin>] file|off\n" + |
| + " or: carlos env exec-delivery -app <a> -environment <name> [-console <origin>] file|inherit" |
| if fs.NArg() != 1 { |
| log.Fatal(usage) |
| } |
| - 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. LoadMerged never carries this field from a named environment's " + |
| - "manifest into anything the sync path reads — it builds its merged result from Vars alone — so it is not " + |
| - "merely ignored there, it is structurally impossible for it to take effect there at all, 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. Omit -environment.") |
| - } |
| - var value string |
| - switch fs.Arg(0) { |
| - case appconfig.ExecDeliveryFile: |
| + checkEnvironmentFlag(*environment) |
| + // One stored value either way — "" clears the field. What differs is |
| + // which SECOND WORD spells that clear, and which bundle it clears in. |
| + // cleared carries the operator's own word through to the wire and to |
| + // the confirmation line, so the two never paraphrase each other. |
| + var value, cleared string |
| + switch { |
| + case fs.Arg(0) == appconfig.ExecDeliveryFile: |
| value = appconfig.ExecDeliveryFile |
| - case "off": |
| - value = "" |
| + case *environment == "" && fs.Arg(0) == "off": |
| + value, cleared = "", "off" |
| + case *environment != "" && fs.Arg(0) == "inherit": |
| + value, cleared = "", "inherit" |
| + case *environment != "" && fs.Arg(0) == "off": |
| + log.Fatalf(`carlos env exec-delivery: "off" is not a value for a named environment — say "inherit" instead. `+ |
| + `Clearing exec_delivery in a bundle does not turn delivery off, it hands the question back to the app's `+ |
| + `default bundle (config.json), so a route bound to %q keeps getting files whenever the app-level flag is `+ |
| + `on. To stop delivery for the whole app: carlos env exec-delivery -app %s off`, *environment, bundleAppName(*app)) |
| default: |
| - log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or \"off\"", fs.Arg(0), appconfig.ExecDeliveryFile) |
| + want := `"off"` |
| + if *environment != "" { |
| + want = `"inherit"` |
| + } |
| + log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or %s", fs.Arg(0), appconfig.ExecDeliveryFile, want) |
| } |
| sess, err := resolveMode(*consoleFlag) |
| @@ -405,31 +432,44 @@ func runEnvExecDelivery(args []string) { |
| printUsing(os.Stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| appName = resolvedApp(sess, acct, *app, usage) |
| - // The wire vocabulary is "file"/"off", never "" — value is "" for |
| - // the local SetExecDelivery convention (clearing the field), but |
| - // the console's PUT decodes ExecDelivery as a pointer where nil |
| - // means "no change", so clearing it must still be spelled "off". |
| - wire := "off" |
| + // The wire vocabulary is a WORD, never "" — the console's PUT |
| + // decodes ExecDelivery as a pointer where nil means "no change", so |
| + // clearing must still be spelled. The word sent is the operator's |
| + // own: the console refuses "off" for a named bundle exactly as this |
| + // function does, so relaying it verbatim keeps the two refusals |
| + // identical rather than letting one paraphrase the other. |
| + wire := cleared |
| if value != "" { |
| wire = value |
| } |
| if _, err := sess.configApply(context.Background(), acct, appName, |
| - apiConfigApply{ExecDelivery: &wire}); err != nil { |
| + apiConfigApply{Environment: *environment, ExecDelivery: &wire}); err != nil { |
| log.Fatal(err) |
| } |
| } else { |
| if *app == "" { |
| log.Fatal(usage) |
| } |
| - if err := appconfig.SetExecDelivery(context.Background(), openStore(), acctOf(), *app, value); err != nil { |
| + if err := appconfig.SetExecDeliveryAt(context.Background(), openStore(), acctOf(), *app, *environment, value); err != nil { |
| log.Fatal(err) |
| } |
| } |
| + word := value |
| if value == "" { |
| - fmt.Printf("%s off for %s\n", colorFor(os.Stdout).ok("exec-delivery"), appName) |
| - } else { |
| - fmt.Printf("%s %s for %s\n", colorFor(os.Stdout).ok("exec-delivery"), value, appName) |
| + word = cleared |
| + } |
| + fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), word, appName, bundleLabel(*environment)) |
| +} |
| + |
| +// bundleAppName renders -app for a refusal printed BEFORE the app has been |
| +// resolved (the refusals above run at flag-parse time, ahead of |
| +// resolvedApp): an omitted -app must not turn the suggested command into |
| +// one with an empty argument. |
| +func bundleAppName(app string) string { |
| + if app == "" { |
| + return "<app>" |
| } |
| + return app |
| } |
| func runEnvEnvironments(args []string) { |
diff --git a/e2e_test.go b/e2e_test.go| index 27a7fa66e..9e63d7cb3 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -1988,10 +1988,11 @@ 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 |
| +// TestExecDeliveryCLI exercises `carlos env exec-delivery` (issues #18, |
| +// #195): the opt-in that lets SyncExec materialize /data/<host>.config.env |
| +// for TCP-addr exec routes — round-trip at app scope, round-trip at |
| +// environment scope, unknown-value refusal, and the two scope-specific |
| +// clearing words refusing each other's ("off" vs "inherit"), following |
| // TestEnvCLI's build-a-real-binary pattern. |
| func TestExecDeliveryCLI(t *testing.T) { |
| goBin, err := exec.LookPath("go") |
| @@ -2074,15 +2075,52 @@ func TestExecDeliveryCLI(t *testing.T) { |
| 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") |
| + |
| + // Named-environment placement (issue #195): the flag lands in THAT |
| + // bundle, and a plain (default-bundle) listing does not mention it — |
| + // the whole point being that opting one bundle in must not read as |
| + // opting the app in. |
| + out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "file") |
| + if !strings.Contains(out, "demo") || !strings.Contains(out, "file") { |
| + t.Fatalf("env exec-delivery -environment demo file = %q, want a confirmation naming the bundle and the value", out) |
| + } |
| + out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") |
| + if !regexp.MustCompile(`(?m)^exec-delivery:\s*file$`).MatchString(out) { |
| + t.Fatalf("env list -environment demo = %q, want an exec-delivery: file line", out) |
| + } |
| + out = run("env", "list", "-account", "bab", "-app", "titogo") |
| + if strings.Contains(out, "exec-delivery") { |
| + t.Fatalf("default-bundle env list = %q, must not report a NAMED bundle's flag", out) |
| + } |
| + |
| + // "off" is refused for a named bundle, by name: clearing there means |
| + // inherit, and a verb that spelled it "off" would read as "stop |
| + // delivering" while doing the opposite. |
| + msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "off") |
| + if !strings.Contains(msg, "inherit") { |
| + t.Fatalf("env exec-delivery -environment demo off = %q, want a refusal naming \"inherit\"", msg) |
| + } |
| + out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") |
| + if !strings.Contains(out, "exec-delivery") { |
| + t.Fatalf("the refused \"off\" cleared the bundle's flag anyway: %q", out) |
| + } |
| + |
| + // "inherit" clears it. |
| + out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "inherit") |
| + if !strings.Contains(out, "inherit") { |
| + t.Fatalf("env exec-delivery -environment demo inherit = %q, want a confirmation", out) |
| + } |
| + out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") |
| + if strings.Contains(out, "exec-delivery") { |
| + t.Fatalf("env list -environment demo after inherit = %q, must no longer mention exec-delivery", out) |
| + } |
| + |
| + // And "inherit" is refused at APP scope, for the mirror-image reason: |
| + // the default bundle has nothing above it to inherit from. |
| + msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "inherit") |
| + if !strings.Contains(msg, "off") { |
| + t.Fatalf("env exec-delivery inherit (no environment) = %q, want a refusal naming \"off\"", msg) |
| } |
| // Turning off against an app that was never configured at all is |
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go| index c1cd87c26..31ec0179b 100644 |
| --- a/internal/console/apiconfig.go |
| +++ b/internal/console/apiconfig.go |
| @@ -232,11 +232,13 @@ func (s *Server) handleAPIConfigGet(w http.ResponseWriter, r *http.Request) { |
| "environments": envs, |
| "vars": vars, |
| } |
| - // exec_delivery is APP-LEVEL: it lives in the default bundle alone and |
| - // LoadMerged never carries it out of a named one (Manifest.ExecDelivery's |
| - // doc comment has the "config-that-lies" rationale). Reporting it while |
| - // answering for a named bundle would suggest it could be set there. |
| - if envName == "" && m.ExecDelivery != "" { |
| + // exec_delivery is emitted for whichever bundle was asked for: since |
| + // issue #195 it can be set in a named one, where it means "the TCP-addr |
| + // exec routes bound to this environment get files". Omitted when the |
| + // bundle is silent on it, rather than reported as the value it |
| + // INHERITS — the answer describes this bundle, and an inherited value |
| + // would read as a statement the bundle does not make. |
| + if m.ExecDelivery != "" { |
| out["exec_delivery"] = m.ExecDelivery |
| } |
| // Public material by design — it only encrypts. Serving it is what makes |
| @@ -368,22 +370,44 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { |
| // a harmless no-op into an error. Same division appconfig.applyAt makes. |
| execDelivery := "" |
| if req.ExecDelivery != nil { |
| + // The wire's clearing word is the SCOPE's own, and the two are not |
| + // interchangeable (issue #195): clearing the default bundle's flag |
| + // stops delivery, while clearing a named bundle's hands the |
| + // question back to the default. Accepting "off" for a named bundle |
| + // would let a caller ask to stop delivery and get the opposite |
| + // whenever the app default is on. Both refusals are worded here, |
| + // once, so the CLI and any other client read the same sentence |
| + // rather than a paraphrase of it. |
| switch *req.ExecDelivery { |
| case appconfig.ExecDeliveryFile: |
| execDelivery = appconfig.ExecDeliveryFile |
| case "off": |
| + if envName != "" { |
| + jsonError(w, http.StatusBadRequest, |
| + "exec_delivery \"off\" is app-level — in a named environment, clearing the flag means \"inherit\" "+ |
| + "(routes bound to it fall back to the app's default bundle), which is not the same as turning "+ |
| + "delivery off. Send \"inherit\" for this bundle, or send \"off\" with no environment to turn "+ |
| + "the app's default off.") |
| + return |
| + } |
| // The wire's "off" is appconfig's "" — the vocabulary changes at |
| // this boundary and nowhere else. |
| execDelivery = "" |
| + case "inherit": |
| + if envName == "" { |
| + jsonError(w, http.StatusBadRequest, |
| + "exec_delivery \"inherit\" needs an environment — the default bundle has nothing above it to "+ |
| + "inherit from. Send \"off\" to clear it.") |
| + return |
| + } |
| + execDelivery = "" |
| default: |
| + want := `"off"` |
| + if envName != "" { |
| + want = `"inherit"` |
| + } |
| jsonError(w, http.StatusBadRequest, |
| - fmt.Sprintf("invalid exec_delivery %q — want %q or \"off\"", *req.ExecDelivery, appconfig.ExecDeliveryFile)) |
| - return |
| - } |
| - if envName != "" { |
| - jsonError(w, http.StatusBadRequest, |
| - "exec_delivery is app-level only — it lives in the default bundle (config.json) alone, "+ |
| - "never in a named environment, so send it without an environment") |
| + fmt.Sprintf("invalid exec_delivery %q — want %q or %s", *req.ExecDelivery, appconfig.ExecDeliveryFile, want)) |
| return |
| } |
| } |
| @@ -425,20 +449,20 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { |
| s.bumpSerial(ctx) |
| } |
| if req.ExecDelivery != nil { |
| - // A SECOND CAS write, and deliberately not folded into the one above: |
| - // SetExecDelivery is app-level and has no environment-scoped variant |
| - // at all (store.go). The two cannot be one transaction, so the audit |
| - // row for the set/unset half is written above, before this runs — if |
| - // this fails, the trail still says truthfully what landed. |
| - if err := appconfig.SetExecDelivery(ctx, s.cfg.Releases, acct.Sqid, app, execDelivery); err != nil { |
| - s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s", acct.Sqid, app), err) |
| + // A SECOND CAS write, and deliberately not folded into the one |
| + // above: it writes one FIELD of the bundle through a different |
| + // setter, where the block above writes vars. The two cannot be one |
| + // transaction, so the audit row for the set/unset half is written |
| + // above, before this runs — if this fails, the trail still says |
| + // truthfully what landed. |
| + if err := appconfig.SetExecDeliveryAt(ctx, s.cfg.Releases, acct.Sqid, app, envName, execDelivery); err != nil { |
| + s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s (%s)", acct.Sqid, app, bundle), err) |
| return |
| } |
| - label := "off" |
| - if execDelivery != "" { |
| - label = execDelivery |
| - } |
| - s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+label+" (cli)") |
| + // The caller's own word, not one reconstructed from the stored |
| + // value: "off" and "inherit" both store "", and a trail that |
| + // recorded them identically would lose which scope was acted on. |
| + s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+*req.ExecDelivery+" ("+bundle+") (cli)") |
| s.bumpSerial(ctx) |
| } |
| writeJSON(w, http.StatusOK, map[string]any{"result": "ok", "removed": removed}) |
diff --git a/internal/console/apiconfig_test.go b/internal/console/apiconfig_test.go| index db95ca691..5d54d35f0 100644 |
| --- a/internal/console/apiconfig_test.go |
| +++ b/internal/console/apiconfig_test.go |
| @@ -496,8 +496,8 @@ func TestAPIConfigNamedEnvironment(t *testing.T) { |
| if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" { |
| t.Fatalf("environments on the default bundle = %v, want [staging]", out["environments"]) |
| } |
| - // The exec_delivery field is app-level: even asking for a named bundle, |
| - // it is never reported from one. |
| + // No exec_delivery anywhere: nothing set it, and the field is omitted |
| + // when a bundle is silent on it rather than reported as empty. |
| if _, present := out["exec_delivery"]; present { |
| t.Fatalf("exec_delivery = %v, want absent", out["exec_delivery"]) |
| } |
| @@ -585,10 +585,18 @@ func TestAPIConfigPutRefusals(t *testing.T) { |
| "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope, "value": "hunter2"}}, |
| }, http.StatusBadRequest, "value"}, |
| {"bad exec_delivery value", map[string]any{"exec_delivery": "x"}, http.StatusBadRequest, "exec_delivery"}, |
| - {"exec_delivery with an environment", map[string]any{ |
| + // Issue #195: "file" IS accepted with an environment now (see |
| + // TestAPIConfigExecDeliveryAtEnvironmentScope). What each scope |
| + // refuses is the OTHER scope's clearing word, because the two mean |
| + // different things: "off" stops delivery, "inherit" hands the |
| + // question back to the app default. |
| + {"exec_delivery off with an environment", map[string]any{ |
| "environment": "staging", |
| - "exec_delivery": "file", |
| - }, http.StatusBadRequest, "app-level"}, |
| + "exec_delivery": "off", |
| + }, http.StatusBadRequest, "inherit"}, |
| + {"exec_delivery inherit without an environment", map[string]any{ |
| + "exec_delivery": "inherit", |
| + }, http.StatusBadRequest, "needs an environment"}, |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
| @@ -902,3 +910,76 @@ func TestAPIConfigPutLargeEnvelope(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestAPIConfigExecDeliveryAtEnvironmentScope is issue #195 through the |
| +// door: the flag lands in the NAMED bundle, the default bundle stays |
| +// silent, and "inherit" clears it. The point of the whole change is that |
| +// opting one bundle in must be readable as exactly that — so a GET for the |
| +// default bundle must not report a named bundle's flag, and vice versa. |
| +func TestAPIConfigExecDeliveryAtEnvironmentScope(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-execdelivery-env") |
| + |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "environment": "relay", |
| + "set": map[string]any{"WALLET": map[string]any{"kind": "env", "value": "sealed"}}, |
| + "exec_delivery": "file", |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("PUT environment=relay exec_delivery=file = %d %v", status, out) |
| + } |
| + |
| + status, out = getJSON(t, configURL(fx, "titogo")+"?environment=relay", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET relay = %d %v", status, out) |
| + } |
| + if str(t, out, "exec_delivery") != appconfig.ExecDeliveryFile { |
| + t.Fatalf("relay exec_delivery = %v, want %q", out["exec_delivery"], appconfig.ExecDeliveryFile) |
| + } |
| + |
| + // The default bundle never learned about it — the fleet-wide switch |
| + // issue #195 is about was exactly this leaking upward. |
| + status, out = getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET default = %d %v", status, out) |
| + } |
| + if _, present := out["exec_delivery"]; present { |
| + t.Fatalf("default bundle exec_delivery = %v, want absent — an environment-scoped opt-in must not read as an app-level one", out["exec_delivery"]) |
| + } |
| + |
| + // "inherit" clears the named bundle's field without touching its vars. |
| + status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "environment": "relay", |
| + "exec_delivery": "inherit", |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("PUT environment=relay exec_delivery=inherit = %d %v", status, out) |
| + } |
| + status, out = getJSON(t, configURL(fx, "titogo")+"?environment=relay", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET relay = %d %v", status, out) |
| + } |
| + if _, present := out["exec_delivery"]; present { |
| + t.Fatalf("relay exec_delivery = %v after inherit, want the field absent", out["exec_delivery"]) |
| + } |
| + if v := varsByKey(t, out)["WALLET"]; str(t, v, "value") != "sealed" { |
| + t.Fatalf("WALLET = %v after inherit, want it untouched", v) |
| + } |
| + |
| + // The audit trail records the caller's own word and the bundle it |
| + // applied to: "off" and "inherit" both store "", so a row built from |
| + // the stored value would lose which scope was acted on. |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var joined string |
| + for _, e := range events { |
| + joined += e.Detail + "\n" |
| + } |
| + if !strings.Contains(joined, "exec-delivery file (relay)") { |
| + t.Fatalf("audit trail = %q, want a row naming the value and the bundle", joined) |
| + } |
| + if !strings.Contains(joined, "exec-delivery inherit (relay)") { |
| + t.Fatalf("audit trail = %q, want the clear recorded as \"inherit\", not as \"off\"", joined) |
| + } |
| +} |