carlos / platform Public

merge: environments — named config bundles, bound per route (pipelines slice 2)

config.json is the default bundle; a named environment is the same
schema-1 manifest at environments/<name>.json; resolution is one overlay
and never a chain. Routes bind via carlos route -environment behind an
existence rail; the adopt pass renders the merged view per route; the
console shows every route's environment and prod-wired canaries get
their honest definition (same environment as stable, OR the apex).

Three review rounds on Opus earned their keep: unset no longer creates
the bundle it removes from (phantom bundles could satisfy the binding
rail), first-time binding no longer replaces hand-staged env files (the
PR #9 truncation class, third appearance today), and the guard that
stopped it now gates on key LOSS rather than file existence — the
existence version froze convergence after Sync's own first write.
Upsert preserves bindings; Platform+Canary routes refuse the
environment axis outright, both directions, because the canary mint
re-Upserts the row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 6c8d5fdbae92c28e69f544579f54893318f8e670 parent c8038aeparent dfa2d3b
19 files changed, +3149 −254
  • appconfig_cli.go +158 −23
  • cli.go +320 −102
  • e2e_test.go +602 −0
  • internal/appconfig/environments.go +194 −0
  • internal/appconfig/environments_test.go +333 −0
  • internal/appconfig/store.go +86 −32
  • internal/appconfig/store_test.go +49 −1
  • internal/appconfig/sync.go +214 −34
  • internal/appconfig/sync_test.go +588 −2
  • internal/console/icons.go +8 −0
  • internal/console/ladder.go +58 −32
  • internal/console/ladder_test.go +134 −16
  • internal/console/templates/ladder.html +1 −0
  • internal/console/templates/layout.html +4 −0
  • internal/console/view.go +18 −0
  • internal/domains/reconcile.go +14 −0
  • internal/registry/registry.go +149 −6
  • internal/registry/registry_test.go +209 −1
  • main.go +10 −5
diff --git a/appconfig_cli.go b/appconfig_cli.go
index a971d9baf..4ee80d1b5 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -15,14 +15,21 @@ import (
"text/tabwriter"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/release"
)
const envUsage = `usage: carlos env <command>
commands:
- set -app <a> KEY=value [KEY2=value2 ...] set one or more plain config vars
- unset -app <a> KEY [KEY2 ...] remove config vars
- list -app <a> list an app's config vars
+ 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
+
+-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.
`
// RunEnv is the `carlos env` verb.
@@ -38,12 +45,102 @@ func RunEnv(args []string) {
runEnvUnset(args[1:])
case "list":
runEnvList(args[1:])
+ case "environments":
+ runEnvEnvironments(args[1:])
default:
fmt.Fprint(os.Stderr, envUsage)
os.Exit(1)
}
}
+// bundleLabel names the bundle a command is reading or writing, for a
+// `list` header or a confirmation line: "default" reads better to a human
+// than "", and matches ValidEnvironmentName's own vocabulary for the base
+// bundle every route already gets when no -environment is bound.
+func bundleLabel(environment string) string {
+ if environment == "" {
+ return "default"
+ }
+ return environment
+}
+
+// checkEnvironmentFlag refuses a bad -environment value before it reaches
+// appconfig, with a reason a bare "invalid" wouldn't give. "" is not
+// reserved — it is the flag's own way of saying "no -environment", i.e. the
+// default bundle — but "default" IS reserved: it already names that same
+// bundle (config.json), so naming it as an -environment value would either
+// error confusingly deep in appconfig or, worse, look like a second bundle
+// that happens to share a name. Every env/secrets subcommand that accepts
+// -environment calls this right after fs.Parse.
+func checkEnvironmentFlag(environment string) {
+ if environment == "" {
+ return
+ }
+ if environment == "default" {
+ log.Fatal(`-environment default is refused: "default" already names the base bundle ` +
+ "(config.json) that every route gets when no -environment is bound — omit -environment " +
+ "to set/unset/list it directly, don't name it")
+ }
+ if !appconfig.ValidEnvironmentName(environment) {
+ log.Fatalf("-environment %q is not a valid environment name — want [a-z0-9-]{1,32}", environment)
+ }
+}
+
+// loadBundle fetches the manifest a `list` command shows: the default
+// bundle when environment=="", or the named environment's own bundle
+// otherwise — never merged. LoadMerged is what a bound route actually gets
+// at materialization time; `list` shows exactly one bundle, matching what
+// `set`/`unset` write to for the same -environment value.
+func loadBundle(ctx context.Context, s release.Store, acct, app, environment string) (*appconfig.Manifest, error) {
+ if environment == "" {
+ return appconfig.Get(ctx, s, acct, app)
+ }
+ return appconfig.LoadEnvironment(ctx, s, acct, app, environment)
+}
+
+// saveEnvVars writes plain config vars to the default bundle when
+// environment=="", or to the named environment's own bundle otherwise —
+// the same CAS discipline either way (appconfig.SetEnv / SaveEnvironment).
+func saveEnvVars(ctx context.Context, s release.Store, acct, app, environment string, kv map[string]string) error {
+ if environment == "" {
+ return appconfig.SetEnv(ctx, s, acct, app, kv)
+ }
+ sets := make(map[string]appconfig.Var, len(kv))
+ for k, v := range kv {
+ sets[k] = appconfig.Var{Kind: "env", Value: v}
+ }
+ return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil)
+}
+
+// saveSecrets writes already-sealed secrets to the default bundle when
+// environment=="", or to the named environment's own bundle otherwise —
+// mirrors saveEnvVars; see its comment.
+func saveSecrets(ctx context.Context, s release.Store, acct, app, environment string, sealed map[string]string) error {
+ if environment == "" {
+ return appconfig.SetSecret(ctx, s, acct, app, sealed)
+ }
+ sets := make(map[string]appconfig.Var, len(sealed))
+ for k, v := range sealed {
+ sets[k] = appconfig.Var{Kind: "secret", Envelope: v}
+ }
+ return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil)
+}
+
+// unsetVars removes vars (env or secret) by key from the default bundle
+// when environment=="", or from the named environment's own bundle
+// otherwise — shared by `env unset` and `secrets unset`, same as
+// appconfig.Unset is shared by their non-environment paths today. It returns
+// how many keys were ACTUALLY removed, which is what the caller prints: a
+// count of arguments reads as success for a typo'd key or a typo'd
+// -environment (2026-08-02 review, MINOR 9). Unsetting from a bundle that
+// does not exist is an error from appconfig, not a create — see applyAt.
+func unsetVars(ctx context.Context, s release.Store, acct, app, environment string, keys []string) (int, error) {
+ if environment == "" {
+ return appconfig.Unset(ctx, s, acct, app, keys)
+ }
+ return appconfig.UnsetEnvironment(ctx, s, acct, app, environment, keys)
+}
+
// parseKV turns ["KEY=value", "KEY2=value2"] into a map, refusing anything
// without an "=" or with an empty key. Shared by `env set` and `secrets set`.
func parseKV(args []string) (map[string]string, error) {
@@ -67,47 +164,55 @@ func parseKV(args []string) (map[string]string, error) {
func runEnvSet(args []string) {
fs := flag.NewFlagSet("env set", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env set -app <a> KEY=value [KEY2=value2 ...]")
+ log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
}
+ checkEnvironmentFlag(*environment)
kv, err := parseKV(fs.Args())
if err != nil {
log.Fatalf("env set: %v", err)
}
- if err := appconfig.SetEnv(context.Background(), openStore(), acctOf(), *app, kv); err != nil {
+ if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d env var(s) for %s\n", len(kv), *app)
+ fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
}
func runEnvUnset(args []string) {
fs := flag.NewFlagSet("env unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos env unset -app <a> KEY [KEY2 ...]")
+ log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]")
}
- if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil {
+ checkEnvironmentFlag(*environment)
+ removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args())
+ if err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d var(s) for %s\n", fs.NArg(), *app)
+ fmt.Printf("unset %d of %d var(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
}
func runEnvList(args []string) {
fs := flag.NewFlagSet("env list", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env list -app <a>")
+ log.Fatal("usage: carlos env list -app <a> [-environment <name>]")
}
- m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app)
+ checkEnvironmentFlag(*environment)
+ m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
if err != nil {
log.Fatal(err)
}
+ fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment))
var keys []string
for k, v := range m.Vars {
if v.Kind == "env" {
@@ -123,13 +228,35 @@ func runEnvList(args []string) {
w.Flush()
}
+func runEnvEnvironments(args []string) {
+ fs := flag.NewFlagSet("env environments", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctOf := accountFlags(fs)
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal("usage: carlos env environments -app <a>")
+ }
+ names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, n := range names {
+ fmt.Println(n)
+ }
+}
+
const secretsUsage = `usage: carlos secrets <command>
commands:
- set -app <a> KEY=value [KEY2=value2 ...] seal and set one or more secrets
- unset -app <a> KEY [KEY2 ...] remove secrets
- list -app <a> list a secret's key names only — never values
- genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair
+ set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] seal and set one or more secrets
+ unset -app <a> [-environment <name>] KEY [KEY2 ...] remove secrets
+ list -app <a> [-environment <name>] list a secret's key names only — never values
+ genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair
+
+-environment names a bundle at apps/<acct>/<app>/environments/<name>.json —
+see "carlos env" for the full explanation; the same bundle backs both env
+vars and secrets, sealed or not. "carlos env environments -app <a>" lists
+an app's bundle names.
`
// RunSecrets is the `carlos secrets` verb.
@@ -166,11 +293,13 @@ func secretsPubKey() *ecdh.PublicKey {
func runSecretsSet(args []string) {
fs := flag.NewFlagSet("secrets set", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos secrets set -app <a> KEY=value [KEY2=value2 ...]")
+ log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
}
+ checkEnvironmentFlag(*environment)
kv, err := parseKV(fs.Args())
if err != nil {
log.Fatalf("secrets set: %v", err)
@@ -192,38 +321,44 @@ func runSecretsSet(args []string) {
}
sealed[k] = env
}
- if err := appconfig.SetSecret(context.Background(), openStore(), acctOf(), *app, sealed); err != nil {
+ if err := saveSecrets(context.Background(), openStore(), acctOf(), *app, *environment, sealed); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d secret(s) for %s\n", len(kv), *app)
+ fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
}
func runSecretsUnset(args []string) {
fs := flag.NewFlagSet("secrets unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos secrets unset -app <a> KEY [KEY2 ...]")
+ log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]")
}
- if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil {
+ checkEnvironmentFlag(*environment)
+ removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args())
+ if err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d secret(s) for %s\n", fs.NArg(), *app)
+ fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
}
func runSecretsList(args []string) {
fs := flag.NewFlagSet("secrets list", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos secrets list -app <a>")
+ log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]")
}
- m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app)
+ checkEnvironmentFlag(*environment)
+ m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
if err != nil {
log.Fatal(err)
}
+ fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment))
var keys []string
for k, v := range m.Vars {
if v.Kind == "secret" {
diff --git a/cli.go b/cli.go
index f393bf274..91be37344 100644
--- a/cli.go
+++ b/cli.go
@@ -18,6 +18,7 @@ import (
"text/tabwriter"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -163,33 +164,55 @@ func runInstances(args []string) {
w.Flush()
}
-// runRoute changes which release channel a route follows — the verb that
-// exists so "move keymail.dev off canary/keymail" or "move Tito's eighteen
-// routes off canary/platform-adoption" is a command instead of raw sqlite
-// against a production registry.
+// runRoute changes which release channel and/or which named config
+// environment a route follows — the verb that exists so "move keymail.dev
+// off canary/keymail" or "bind demo.go.tito.io to the demo environment" is a
+// command instead of raw sqlite against a production registry. The two axes
+// are independent (2026-08-02-pipelines-design.md §2): -channel says how
+// mature a build is, -environment says what glue it's wired to
+// (internal/appconfig's config.json default bundle, optionally overlaid by
+// a named apps/<acct>/<app>/environments/<name>.json bundle — see
+// appconfig.LoadMerged). At least one of the two flags is required; either
+// may be given alone, or both together.
//
-// The safety rail is the whole point: adoption reads a route's channel and
-// converges it onto whatever version that channel currently points at, so
-// repointing a route to a channel holding a DIFFERENT version than the route
-// is actually serving changes what production serves on the next
-// adopt/recheck pass. Before writing anything, this resolves what the
-// TARGET channel's signed pointer says (same env/store plumbing as
-// runAccountsMigrate/verifyMigratedPointers: CARLOS_RELEASE_PUBKEY, the
-// deployment store) and compares it — see routeVersionCheck — against what
-// the route is actually serving. Two outcomes can be written: the versions
-// agree (proceed unconditionally), or they genuinely disagree and the
-// operator passed -allow-version-change (the one thing that flag means).
-// Anything that leaves the comparison UNPROVEN — no pubkey, an unresolvable
-// channel, a bad signature — is refused with NO override: -allow-version-change
-// means "I know it's different, do it anyway," never "skip finding out."
+// The channel safety rail is the whole point of that half: adoption reads a
+// route's channel and converges it onto whatever version that channel
+// currently points at, so repointing a route to a channel holding a
+// DIFFERENT version than the route is actually serving changes what
+// production serves on the next adopt/recheck pass. Before writing
+// anything, this resolves what the TARGET channel's signed pointer says
+// (same env/store plumbing as runAccountsMigrate/verifyMigratedPointers:
+// CARLOS_RELEASE_PUBKEY, the deployment store) and compares it — see
+// routeVersionCheck — against what the route is actually serving. Two
+// outcomes can be written: the versions agree (proceed unconditionally), or
+// they genuinely disagree and the operator passed -allow-version-change
+// (the one thing that flag means). Anything that leaves the comparison
+// UNPROVEN — no pubkey, an unresolvable channel, a bad signature — is
+// refused with NO override: -allow-version-change means "I know it's
+// different, do it anyway," never "skip finding out."
+//
+// The environment rail is simpler and has no override: -environment names a
+// bundle that must already exist in the bucket for the route's app+account
+// (checked via appconfig.ListEnvironments, the same Store the channel rail
+// uses — unavailable store fails closed exactly like the channel rail's
+// missing-pubkey/missing-store cases), or the write is refused, naming
+// `carlos env set -app <a> -environment <name> ...` as the way to create
+// it first. "-environment -" (a literal single dash) clears the binding
+// back to the app's default bundle and needs no bucket check — there is
+// nothing to prove exists.
+//
+// When BOTH flags are given, both rails must resolve cleanly before EITHER
+// axis writes: a single combined transaction (SetChannelsAndEnvironments)
+// commits both columns for every candidate together, so a combined move can
+// never leave a route repointed on one axis but not the other.
//
// -app's bulk form is all-or-nothing (2026-08-02 review, CRITICAL 1): every
-// matched route's rail is resolved BEFORE any write, and a single refusal
-// aborts the whole batch with every failure printed — never a half-moved
-// app. It also excludes Platform routes with a printed reason, since a bulk
-// sweep touching every tenant's minted route in one command is a different,
-// riskier operation than "move this app's ordinary routes" — Platform
-// routes stay opt-in, one -host at a time.
+// matched route's rail(s) are resolved BEFORE any write, and a single
+// refusal — on EITHER axis — aborts the whole batch with every failure
+// printed — never a half-moved app. It also excludes Platform routes with a
+// printed reason, since a bulk sweep touching every tenant's minted route in
+// one command is a different, riskier operation than "move this app's
+// ordinary routes" — Platform routes stay opt-in, one -host at a time.
//
// A Platform+Canary route's Channel is a harder boundary: the domains
// reconciler's canary-minting pass (internal/domains/reconcile.go) is
@@ -201,10 +224,21 @@ func runInstances(args []string) {
// canary/* channels is just as reachable — makes `followed` false for the
// channel it left, and the next reconcile pass re-Upserts the same host
// from `firstReadyUpstream`, silently reverting this verb's write and
-// blanking version/status/dns/cert columns to mintedRoute's zero values.
-// platformCanaryOwnsChannel refuses this unconditionally, structurally, for
+// blanking version/status/dns/cert/ENVIRONMENT columns to mintedRoute's
+// zero values (mintedRoute never sets Environment — see its doc comment).
+// platformCanaryOwnedRow refuses this unconditionally, structurally, for
// every candidate route in pass 1 below — not only in the -host branch —
// so it holds even if a future change lets -app include Platform routes.
+// The SAME function guards the -environment axis (2026-08-02 review,
+// IMPORTANT 4): the re-mint above rebuilds the whole ROW from mintedRoute,
+// not just the channel it derives, so an environment binding on a
+// Platform+Canary route is a binding on a row this verb does not own.
+// registry.Upsert now preserves a non-empty Environment, which makes such a
+// binding survivable rather than silently reverted — but "survives the
+// reconciler" is not the same as "is owned by the operator", and an earlier
+// revision of this comment argued from "reconcile does not DERIVE
+// Environment", which was true and not the point. A plain Platform apex
+// (non-Canary) is still bindable via -host on either axis.
// (A static, non-Canary Platform apex has no such trap: its idempotency
// guard, `hasApex`, never reads Channel at all, so -host may freely repoint
// one — though doing so while it happens to sit on a canary/* channel does
@@ -217,27 +251,58 @@ func runRoute(args []string) {
app := fs.String("app", "", "repoint every non-platform route of this app")
acctFilter := fs.String("account", "ops", `account (sqid or name) to filter -app routes by; "all" for every account`)
channel := fs.String("channel", "", "release channel to subscribe the route(s) to")
+ environment := fs.String("environment", "",
+ `named config environment to bind the route(s) to (must already exist in the bucket); "-" clears to default`)
allowVersionChange := fs.Bool("allow-version-change", false,
"allow repointing to a channel that RESOLVES but holds a different version than the route currently serves "+
"(never overrides an unresolvable target, a bad signature, or a missing pubkey)")
dryRun := fs.Bool("dry-run", false, "run the full safety-rail check and print the plan, but write nothing")
fs.Parse(args)
- const usage = "usage: carlos route -host <host> -channel <channel> [-registry <db>] [-allow-version-change] [-dry-run]\n" +
- " or: carlos route -app <app> -channel <channel> [-account <sqid|name|all>] [-registry <db>] [-allow-version-change] [-dry-run]"
+ const usage = "usage: carlos route -host <host> [-channel <channel>] [-environment <name>] [-registry <db>] [-allow-version-change] [-dry-run]\n" +
+ " or: carlos route -app <app> [-channel <channel>] [-environment <name>] [-account <sqid|name|all>] [-registry <db>] [-allow-version-change] [-dry-run]"
if *host == "" && *app == "" {
log.Fatal(usage)
}
if *host != "" && *app != "" {
log.Fatal("route: -host and -app are mutually exclusive — name the one route, or the app whose routes to move\n\n" + usage)
}
- if *channel == "" {
- log.Fatal(`route: -channel is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
- "this verb sets an explicit subscription, and needs an explicit channel to set it to.")
+ if *channel == "" && *environment == "" {
+ log.Fatal(`route: at least one of -channel or -environment is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
+ "this verb sets an explicit binding, and needs an explicit target to set it to.")
}
- if !release.ValidChannel(*channel) {
+ if *channel != "" && !release.ValidChannel(*channel) {
log.Fatalf("route: %q is not a valid channel", *channel)
}
+ // "-" is the CLI-level "clear to default" sentinel, checked before
+ // ValidEnvironmentName (which would otherwise accept it — a bare hyphen
+ // fits [a-z0-9-]{1,32} — and treat it as a real bundle name). A real
+ // environment named "-" is unreachable through this verb as a result;
+ // that's an accepted asymmetry, not a bug (matches "-" as a stdin/stdout
+ // sentinel elsewhere in Unix tooling).
+ envClear := *environment == "-"
+ // "default" gets its own refusal rather than falling through to the
+ // generic one (2026-08-02 review, MINOR 7): it is a well-formed name that
+ // is reserved, not a malformed one, and the generic message sent the
+ // operator looking for a typo. It also keeps this refusal pinned to the
+ // RESERVATION — with the reservation gone, the existence rail below would
+ // still refuse "default" for an unrelated reason and a test asserting
+ // only "refused" would not notice. Same wording as checkEnvironmentFlag's
+ // (appconfig_cli.go), which every `carlos env`/`secrets` verb uses.
+ if *environment == "default" {
+ log.Fatal(`route: -environment default is refused: "default" already names the base bundle ` +
+ "(config.json) every route gets when nothing is bound — use \"-environment -\" to clear a binding back to it")
+ }
+ if *environment != "" && !envClear && !appconfig.ValidEnvironmentName(*environment) {
+ log.Fatalf("route: %q is not a valid environment name — want [a-z0-9-]{1,32}", *environment)
+ }
+ // resolvedEnv is what actually lands in the registry column: "" either
+ // because -environment was never given (untouched — see the write step)
+ // or because it was explicitly cleared.
+ resolvedEnv := *environment
+ if envClear {
+ resolvedEnv = ""
+ }
r, err := registry.Open(*regPath)
if err != nil {
@@ -276,7 +341,7 @@ func runRoute(args []string) {
// in one command is a different, riskier operation than
// "move this app's ordinary routes" — Platform routes stay
// opt-in, one -host at a time. (Platform+Canary routes are
- // refused even via -host — see platformCanaryOwnsChannel,
+ // refused even via -host — see platformCanaryOwnedRow,
// applied structurally below for every candidate, not just
// here — so this exclusion is belt-and-braces for those, but
// load-bearing for a plain Platform apex.)
@@ -290,10 +355,11 @@ func runRoute(args []string) {
}
}
- // Pass 1: resolve every candidate's rail WITHOUT writing anything. A
+ // Pass 1: resolve every candidate's rail(s) WITHOUT writing anything. A
// check-then-write-per-route loop can leave an -app bulk half-repointed
// if a later route fails after earlier ones already wrote (CRITICAL 1) —
- // so every route's verdict is known before the first write happens.
+ // so every route's verdict, on EVERY axis touched, is known before the
+ // first write happens.
//
// The Platform+Canary refusal is decided HERE, for every candidate,
// rather than only in the -host branch above — a shared function both
@@ -301,7 +367,8 @@ func runRoute(args []string) {
// reach a Platform+Canary route today (-app already excludes every
// Platform route). See runRoute's doc comment for why this refusal has
// no override and applies to ANY channel change, not only leaving
- // canary/*.
+ // canary/* — and why it is Channel-specific, never blocking the
+ // -environment axis.
type verdictKind int
const (
vNeedsCheck verdictKind = iota // routeVersionCheck resolves this below
@@ -317,68 +384,158 @@ func runRoute(args []string) {
kind verdictKind
note string
}
- verdicts := make([]verdict, len(candidates))
- anyNeedsCheck := false
- for i, rt := range candidates {
- from := rt.Channel
- if from == "" {
- from = "stable"
- }
- switch {
- case from == *channel:
- verdicts[i] = verdict{rt: rt, from: from, kind: vNoop}
- case platformCanaryOwnsChannel(rt):
- verdicts[i] = verdict{rt: rt, from: from, kind: vPlatformOwned, note: fmt.Sprintf(
- "Platform+Canary route on %s — its channel is derived and re-asserted by the domains\n"+
- "reconciler's canary-minting pass (internal/domains/reconcile.go), which is idempotent via a\n"+
- "check on THIS route's own Channel column; any change here is silently reverted on the next\n"+
- "reconcile pass. This verb does not own this column", from)}
- default:
- verdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck}
- anyNeedsCheck = true
+ // chanVerdicts stays nil (len 0) when -channel was not given, so every
+ // loop below that ranges over it is naturally a no-op — that IS the
+ // "channel axis untouched" state, not a special case to guard against.
+ var chanVerdicts []verdict
+ if *channel != "" {
+ chanVerdicts = make([]verdict, len(candidates))
+ anyNeedsCheck := false
+ for i, rt := range candidates {
+ from := rt.Channel
+ if from == "" {
+ from = "stable"
+ }
+ switch {
+ case from == *channel:
+ chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNoop}
+ case platformCanaryOwnedRow(rt):
+ chanVerdicts[i] = verdict{rt: rt, from: from, kind: vPlatformOwned, note: fmt.Sprintf(
+ "Platform+Canary route on %s — its channel is derived and re-asserted by the domains\n"+
+ "reconciler's canary-minting pass (internal/domains/reconcile.go), which is idempotent via a\n"+
+ "check on THIS route's own Channel column; any change here is silently reverted on the next\n"+
+ "reconcile pass. This verb does not own this column", from)}
+ default:
+ chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck}
+ anyNeedsCheck = true
+ }
}
- }
- if anyNeedsCheck {
- // Resolved once, the same way verifyMigratedPointers does:
- // byte/version comparison needs the public key, not the signing key
- // — this verb never writes a release object, only the registry row.
- // A run needing no check at all (every candidate a no-op or
- // Platform+Canary-refused) never reaches here, so it needs no
- // store/pubkey configured.
- var pub ed25519.PublicKey
- if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" {
- if p, perr := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); perr == nil {
- pub = p
+ if anyNeedsCheck {
+ // Resolved once, the same way verifyMigratedPointers does:
+ // byte/version comparison needs the public key, not the signing key
+ // — this verb never writes a release object, only the registry row.
+ // A run needing no check at all (every candidate a no-op or
+ // Platform+Canary-refused) never reaches here, so it needs no
+ // store/pubkey configured.
+ var pub ed25519.PublicKey
+ if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" {
+ if p, perr := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); perr == nil {
+ pub = p
+ }
+ }
+ var store release.Store
+ if pub != nil {
+ store = openStore()
+ }
+ ctx := context.Background()
+ for i := range chanVerdicts {
+ if chanVerdicts[i].kind != vNeedsCheck {
+ continue
+ }
+ rt := chanVerdicts[i].rt
+ note, safe, overridable := routeVersionCheck(ctx, store, pub, release.RouteAccount(rt), rt, chanVerdicts[i].from, *channel)
+ chanVerdicts[i].note = note
+ switch {
+ case safe:
+ chanVerdicts[i].kind = vSafe
+ case overridable:
+ chanVerdicts[i].kind = vVersionChange
+ default:
+ chanVerdicts[i].kind = vUnproven
+ }
}
}
+ }
+
+ // The environment rail: an existence check against the bucket, via the
+ // same Store the channel rail uses (unavailable store fails closed
+ // exactly like the channel rail's missing-pubkey/missing-store cases —
+ // openStore's own log.Fatal). No override exists for a missing bundle:
+ // unlike a version mismatch, there is no "I know it's different, do it
+ // anyway" reading of binding a route to config that isn't there.
+ type envVerdictKind int
+ const (
+ eNoop envVerdictKind = iota // already bound to the target
+ eWillWrite // clearing, or the bundle exists — proceed
+ eMissing // named but absent in the bucket — refuse, no override
+ ePlatformOwned // Platform+Canary: the reconciler owns this row, never overridable
+ )
+ type envVerdict struct {
+ rt registry.Route
+ from string
+ kind envVerdictKind
+ note string
+ }
+ var envVerdicts []envVerdict
+ if *environment != "" {
+ envVerdicts = make([]envVerdict, len(candidates))
var store release.Store
- if pub != nil {
- store = openStore()
- }
+ // existsByApp caches one ListEnvironments call per distinct
+ // (account, app) among the candidates (an -app -account all sweep can
+ // span several accounts), mirroring the channel rail's "resolve once"
+ // pattern — never opened at all when every candidate is already bound
+ // to the target or the operator is clearing (envClear needs no bucket
+ // check per the doc comment above). The key is BOTH halves of the
+ // query it caches: keyed on account alone (2026-08-02 review, MINOR
+ // 6) it would approve a bundle that exists for a different app in the
+ // same account. Unreachable today — -app fixes rt.App and -host has
+ // one candidate — which is exactly why it is worth keying correctly
+ // now rather than after a future widening.
+ existsByApp := map[string]map[string]bool{}
ctx := context.Background()
- for i := range verdicts {
- if verdicts[i].kind != vNeedsCheck {
- continue
- }
- rt := verdicts[i].rt
- note, safe, overridable := routeVersionCheck(ctx, store, pub, release.RouteAccount(rt), rt, verdicts[i].from, *channel)
- verdicts[i].note = note
+ for i, rt := range candidates {
+ from := rt.Environment
switch {
- case safe:
- verdicts[i].kind = vSafe
- case overridable:
- verdicts[i].kind = vVersionChange
+ case from == resolvedEnv:
+ envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eNoop}
+ case platformCanaryOwnedRow(rt):
+ // Refused on the same terms as the channel axis, and for the
+ // same structural reason: the reconciler rebuilds this row.
+ // Placed before envClear so CLEARING is refused too — a
+ // half-owned column would be the worst of both.
+ envVerdicts[i] = envVerdict{rt: rt, from: from, kind: ePlatformOwned, note: "Platform+Canary route — its whole row is rebuilt by the domains\n" +
+ "reconciler's canary-minting pass (internal/domains/reconcile.go) whenever that pass's `followed`\n" +
+ "check goes false, from values it derives itself. This verb does not own this row.\n" +
+ "A binding already on such a route (made before this rail existed) survives re-mints and cannot be\n" +
+ "cleared here either: `carlos remove " + rt.Host + "` and let the next reconcile pass re-mint it clean"}
+ case envClear:
+ envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite}
default:
- verdicts[i].kind = vUnproven
+ acct := release.RouteAccount(rt)
+ ck := acct + "/" + rt.App
+ names, cached := existsByApp[ck]
+ if !cached {
+ if store == nil {
+ store = openStore()
+ }
+ list, lerr := appconfig.ListEnvironments(ctx, store, acct, rt.App)
+ if lerr != nil {
+ log.Fatalf("route: list environments for %s/%s: %v", acct, rt.App, lerr)
+ }
+ names = make(map[string]bool, len(list))
+ for _, n := range list {
+ names[n] = true
+ }
+ existsByApp[ck] = names
+ }
+ if names[*environment] {
+ envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite}
+ } else {
+ envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eMissing, note: fmt.Sprintf(
+ "environment %q does not exist for %s/%s — create it first with `carlos env set -app %s -environment %s ...`",
+ *environment, acct, rt.App, rt.App, *environment)}
+ }
}
}
}
- // Pass 2: any refusal aborts the WHOLE batch — print every one, write
- // nothing.
+ // Pass 2: any refusal, on EITHER axis, aborts the WHOLE batch — print
+ // every one, write nothing. This is what makes the combined
+ // -channel/-environment invocation atomic: neither axis's write call
+ // below is reached unless every candidate cleared both rails.
var refusals []string
var toWrite []string
- for _, v := range verdicts {
+ for _, v := range chanVerdicts {
switch v.kind {
case vNoop:
// handled in the final print loop
@@ -399,40 +556,101 @@ func runRoute(args []string) {
v.rt.Host, v.note))
}
}
+ var envToWrite []string
+ for _, v := range envVerdicts {
+ switch v.kind {
+ case eNoop:
+ // handled in the final print loop
+ case eWillWrite:
+ envToWrite = append(envToWrite, v.rt.Host)
+ case eMissing:
+ refusals = append(refusals, fmt.Sprintf("%s: %s", v.rt.Host, v.note))
+ case ePlatformOwned:
+ refusals = append(refusals, fmt.Sprintf(
+ "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note))
+ }
+ }
if len(refusals) > 0 {
for _, msg := range refusals {
fmt.Fprintln(os.Stderr, msg)
}
- fmt.Fprintf(os.Stderr, "route: %d of %d route(s) refused — nothing written\n", len(refusals), len(verdicts))
+ fmt.Fprintf(os.Stderr, "route: %d of %d route(s) refused — nothing written\n", len(refusals), len(candidates))
os.Exit(1)
}
- if len(toWrite) > 0 && !*dryRun {
- if err := r.SetChannels(toWrite, *channel); err != nil {
- log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
+ switch {
+ case *channel != "" && *environment != "":
+ // Combined: one transaction, both columns, for every candidate that
+ // cleared both rails above — including per-axis no-ops, which is
+ // harmless (an UPDATE to the value already there) and keeps this
+ // branch from having to reconcile two possibly-different host lists
+ // into one atomic write.
+ if !*dryRun {
+ hosts := make([]string, len(candidates))
+ for i, rt := range candidates {
+ hosts[i] = rt.Host
+ }
+ if err := r.SetChannelsAndEnvironments(hosts, *channel, resolvedEnv); err != nil {
+ log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
+ }
+ }
+ case *channel != "":
+ if len(toWrite) > 0 && !*dryRun {
+ if err := r.SetChannels(toWrite, *channel); err != nil {
+ log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
+ }
+ }
+ case *environment != "":
+ if len(envToWrite) > 0 && !*dryRun {
+ if err := r.SetEnvironments(envToWrite, resolvedEnv); err != nil {
+ log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
+ }
}
}
+
prefix := ""
if *dryRun {
prefix = "(dry run) "
}
- for _, v := range verdicts {
- if v.kind == vNoop {
- fmt.Printf("%s: already following %s — nothing to do\n", v.rt.Host, *channel)
- continue
+ envLabel := func(e string) string {
+ if e == "" {
+ return "default"
+ }
+ return e
+ }
+ for i := range candidates {
+ if chanVerdicts != nil {
+ v := chanVerdicts[i]
+ if v.kind == vNoop {
+ fmt.Printf("%s: already following %s — nothing to do\n", v.rt.Host, *channel)
+ } else {
+ fmt.Printf("%s%s: %s -> %s (%s)\n", prefix, v.rt.Host, v.from, *channel, v.note)
+ }
+ }
+ if envVerdicts != nil {
+ v := envVerdicts[i]
+ if v.kind == eNoop {
+ fmt.Printf("%s: environment already %s — nothing to do\n", v.rt.Host, envLabel(resolvedEnv))
+ } else {
+ fmt.Printf("%s%s: environment %s -> %s\n", prefix, v.rt.Host, envLabel(v.from), envLabel(resolvedEnv))
+ }
}
- fmt.Printf("%s%s: %s -> %s (%s)\n", prefix, v.rt.Host, v.from, *channel, v.note)
}
}
-// platformCanaryOwnsChannel reports whether the domains reconciler, not this
-// verb, owns rt's Channel column: true for any Platform+Canary route. See
-// runRoute's doc comment for why — the canary-minting pass in
-// internal/domains/reconcile.go is idempotent via `followed`, a check
-// computed from THIS column, not from the `byHost` map the same loop also
-// sets but never reads for that purpose, so ANY change here (not only a move
-// off canary/*) is silently reverted on the next reconcile pass.
-func platformCanaryOwnsChannel(rt registry.Route) bool {
+// platformCanaryOwnedRow reports whether the domains reconciler, not this
+// verb, owns rt's row: true for any Platform+Canary route. See runRoute's doc
+// comment for why — the canary-minting pass in internal/domains/reconcile.go
+// is idempotent via `followed`, a check computed from THAT ROUTE'S OWN
+// Channel column, not from the `byHost` map the same loop also sets but never
+// reads for that purpose. Any change to the channel (not only a move off
+// canary/*) makes `followed` false, and the next pass re-Upserts the whole
+// row from mintedRoute's derived values.
+//
+// One function, both axes: -channel because the reconciler re-derives that
+// column, -environment because it rebuilds the row the column lives on
+// (2026-08-02 review, IMPORTANT 4).
+func platformCanaryOwnedRow(rt registry.Route) bool {
return rt.Platform && rt.Canary
}
diff --git a/e2e_test.go b/e2e_test.go
index 5bd2f63ea..28ca3b04b 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -1432,6 +1432,215 @@ func TestSecretsCLI(t *testing.T) {
}
}
+// TestEnvironmentBundlesCLI exercises `carlos env`/`carlos secrets -environment`
+// (2026-08-02-pipelines-design.md §4a, plan Task 4): named environment
+// bundles are the same manifest shape as config.json, at a sibling bucket
+// key, created implicitly on first `set` exactly like the default bundle —
+// and never touched by a write to the other.
+func TestEnvironmentBundlesCLI(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)
+ }
+
+ // Named-bundle round trip: set on a named environment, see it in that
+ // environment's own `list`, unset removes it.
+ run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL=trace")
+ out := run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo")
+ if !strings.Contains(out, "demo") {
+ t.Fatalf("env list -environment demo = %q, want the bundle name in the header", out)
+ }
+ if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+trace$`).MatchString(out) {
+ t.Fatalf("env list -environment demo = %q, want LOG_LEVEL=trace", out)
+ }
+ // The count is of REMOVALS, not of arguments (review MINOR 9): a key that
+ // was never set must not read as a successful removal.
+ out = run("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL", "NEVER_SET")
+ if !strings.Contains(out, "unset 1 of 2") {
+ t.Fatalf("env unset = %q, want it to report 1 of 2 keys actually removed", out)
+ }
+ out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo")
+ if strings.Contains(out, "LOG_LEVEL") {
+ t.Fatalf("env list -environment demo = %q, LOG_LEVEL survived unset", out)
+ }
+
+ // Review CRITICAL 2: unsetting from a bundle that does not exist is a
+ // refusal, not a create — for a named bundle AND for the default one.
+ // Creating it would mint a manifest that EXISTS with zero vars, which is
+ // the state config sync converges every one of that app's env files to
+ // empty for, as root.
+ msg := runFails("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "neverexisted", "ANY")
+ if !strings.Contains(msg, "nothing to unset") {
+ t.Fatalf("unset on a nonexistent bundle = %q, want it to say there is nothing to unset", msg)
+ }
+ msg = runFails("secrets", "unset", "-account", "bab", "-app", "keymail-never-configured", "ANY")
+ if !strings.Contains(msg, "nothing to unset") {
+ t.Fatalf("secrets unset on a never-configured app = %q, want a refusal", msg)
+ }
+ store := release.NewDirStore(storeDir)
+ if _, _, err := store.Get(context.Background(), "apps/bab/keymail-never-configured/config.json"); err == nil {
+ t.Fatal("an unset against a never-configured app created its config.json")
+ }
+
+ // Default untouched by a named-bundle write: set something on demo, and
+ // on a second, never-before-seen environment; the default bundle (no
+ // -environment) still shows nothing.
+ run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "STRIPE_MODE=sandbox")
+ run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "staging", "STRIPE_MODE=staging-sandbox")
+ out = run("env", "list", "-account", "bab", "-app", "keymail")
+ if !strings.Contains(out, "default") {
+ t.Fatalf("env list (no -environment) = %q, want the bundle header to say default", out)
+ }
+ if strings.Contains(out, "STRIPE_MODE") {
+ t.Fatalf("env list (no -environment) = %q, a named-bundle write leaked into the default bundle", out)
+ }
+ // And the reverse: write the default bundle, and it does not appear
+ // under the named ones.
+ run("env", "set", "-account", "bab", "-app", "keymail", "BASE_ONLY=1")
+ out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo")
+ if strings.Contains(out, "BASE_ONLY") {
+ t.Fatalf("env list -environment demo = %q, a default-bundle write leaked into a named bundle", out)
+ }
+
+ // `carlos env environments -app <a>` lists the bundle names created
+ // implicitly above, sorted, and nothing else (no "default" entry — that
+ // bundle isn't a named environment).
+ out = run("env", "environments", "-account", "bab", "-app", "keymail")
+ names := strings.Fields(strings.TrimSpace(out))
+ if want := []string{"demo", "staging"}; !reflect.DeepEqual(names, want) {
+ t.Fatalf("env environments = %q, want %v", names, want)
+ }
+
+ // An app with no named environments lists none, not an error.
+ out = run("env", "environments", "-account", "bab", "-app", "keymail-bare")
+ if strings.TrimSpace(out) != "" {
+ t.Fatalf("env environments for a bare app = %q, want empty", out)
+ }
+
+ // Reserved-name refusal: "default" is refused, with a message that
+ // explains default already names the base bundle rather than a bare
+ // "invalid name". An invalid-charset name is refused too.
+ msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1")
+ if !strings.Contains(msg, "default") || !strings.Contains(strings.ToLower(msg), "base bundle") {
+ t.Fatalf("env set -environment default = %q, want a refusal naming default as the base bundle", msg)
+ }
+ msg = runFails("secrets", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1")
+ if !strings.Contains(msg, "default") {
+ t.Fatalf("secrets set -environment default = %q, want a refusal", msg)
+ }
+ msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "Not_Valid!", "X=1")
+ if !strings.Contains(msg, "Not_Valid!") {
+ t.Fatalf("env set -environment Not_Valid! = %q, want a refusal naming the bad value", msg)
+ }
+ // "-environment \"\"" is indistinguishable from omitting the flag — the
+ // CLI's sentinel for "the default bundle", never a name to refuse. Not a
+ // refusal case: it must succeed, against the default bundle.
+ out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "")
+ if !strings.Contains(out, "default") {
+ t.Fatalf(`env list -environment "" = %q, want it treated as the default bundle`, out)
+ }
+
+ // The named bundle lands at the sibling bucket key the design specifies.
+ if _, _, err := store.Get(context.Background(), "apps/bab/keymail/environments/demo.json"); err != nil {
+ t.Fatalf("named environment manifest not at the sibling key: %v", err)
+ }
+ // And the default bundle is untouched in shape and key.
+ if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil {
+ t.Fatalf("default config manifest missing: %v", err)
+ }
+}
+
+// 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,
+// the same function Sync calls per bound route — that the merged view a
+// route bound to that environment would actually receive has the override
+// applied and everything else inherited from default.
+func TestEnvironmentMergeVisibleFromCLI(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) {
+ t.Helper()
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ }
+
+ run("env", "set", "-account", "bab", "-app", "tito",
+ "STRIPE_KEY=sk_base", "LOG_LEVEL=info", "REGION=eu")
+ run("env", "set", "-account", "bab", "-app", "tito", "-environment", "demo",
+ "STRIPE_KEY=sk_demo")
+
+ store := release.NewDirStore(storeDir)
+ merged, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "demo")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ want := map[string]string{"STRIPE_KEY": "sk_demo", "LOG_LEVEL": "info", "REGION": "eu"}
+ if len(merged.Vars) != len(want) {
+ t.Fatalf("merged vars = %v, want exactly %v", merged.Vars, want)
+ }
+ for k, v := range want {
+ got, ok := merged.Vars[k]
+ if !ok || got.Value != v {
+ t.Fatalf("merged.Vars[%s] = %+v, want value %q", k, got, v)
+ }
+ }
+
+ // A route with no -environment binding — envName=="" — sees the default
+ // bundle alone, the override absent.
+ base, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "")
+ if err != nil {
+ t.Fatalf("LoadMerged (default): %v", err)
+ }
+ if base.Vars["STRIPE_KEY"].Value != "sk_base" {
+ t.Fatalf("default-bound LoadMerged saw STRIPE_KEY=%q, want sk_base — the demo override leaked into the unbound route",
+ base.Vars["STRIPE_KEY"].Value)
+ }
+}
+
// TestRouteCLI exercises `carlos route`, the verb that lets a route's channel
// subscription change without raw sqlite against a production registry. The
// safety rail IS the point of the verb: a route only repoints when the
@@ -1833,6 +2042,34 @@ func (f *routeTestFixture) channelOf(host string) string {
return rt.Channel
}
+func (f *routeTestFixture) environmentOf(host string) string {
+ f.t.Helper()
+ reg, err := registry.Open(f.regPath)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ defer reg.Close()
+ rt, err := reg.Get(host)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ if rt == nil {
+ f.t.Fatalf("no route for host %q", host)
+ }
+ return rt.Environment
+}
+
+// saveEnvironment writes a named environment bundle directly through
+// internal/appconfig, the same store the fixture's binary reads through
+// CARLOS_DEPLOYMENT_DIR — the existence rail `route -environment` checks.
+func (f *routeTestFixture) saveEnvironment(acct, app, name string) {
+ f.t.Helper()
+ if err := appconfig.SaveEnvironment(context.Background(), f.store, acct, app, name,
+ map[string]appconfig.Var{"X": {Kind: "env", Value: "1"}}, nil); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
// CRITICAL 2 (review): a no-pubkey run must refuse — never a blind write.
func TestRouteRefusesWithoutPubkey(t *testing.T) {
f := newRouteTestFixture(t)
@@ -2195,3 +2432,368 @@ func TestRouteDryRunWritesNothing(t *testing.T) {
t.Fatalf("dry run wrote a channel change: %q", got)
}
}
+
+// --- Task 2: Route.Environment + `carlos route -environment` --------------
+
+// Bind then clear: -environment <name> requires the bundle to exist in the
+// bucket (the existence rail), and -environment - clears back to default
+// with no bucket check at all.
+func TestRouteEnvironmentBindClearRoundTrip(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+ f.saveEnvironment(release.OpsAccount, "tito", "demo")
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "demo")
+ if err != nil {
+ t.Fatalf("route -environment demo: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "default -> demo") {
+ t.Errorf("output does not show the bind:\n%s", out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "demo" {
+ t.Fatalf("environment = %q, want demo", got)
+ }
+
+ out, err = f.run(env, "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "-")
+ if err != nil {
+ t.Fatalf("route -environment -: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "demo -> default") {
+ t.Errorf("output does not show the clear:\n%s", out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "" {
+ t.Fatalf("environment = %q, want empty (default) after clearing", got)
+ }
+}
+
+// A bundle that was never created (or was created for a different app) is
+// refused, not silently bound — the whole point of the existence rail (spec
+// §4a: "binding a bundle that does not exist in the bucket is refused, not
+// recorded"). The refusal names the fix.
+func TestRouteEnvironmentNonexistentBundleRefused(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "nosuchenv")
+ if err == nil {
+ t.Fatalf("binding a nonexistent environment was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "nosuchenv") || !strings.Contains(out, "carlos env set") {
+ t.Errorf("refusal does not name the bundle or the fix:\n%s", out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "" {
+ t.Fatalf("environment changed despite the refusal: %q", got)
+ }
+}
+
+// An invalid environment NAME (bad shape, or the reserved "default") is
+// refused before any bucket check — no CARLOS_DEPLOYMENT_DIR/BUCKET is even
+// required for this refusal to fire.
+func TestRouteEnvironmentInvalidNameRefused(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+
+ for _, bad := range []string{"Has-Upper", "has_underscore", strings.Repeat("a", 33)} {
+ out, err := f.run(os.Environ(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", bad)
+ if err == nil {
+ t.Fatalf("invalid environment name %q was accepted:\n%s", bad, out)
+ }
+ if !strings.Contains(out, "not a valid environment name") {
+ t.Errorf("refusal of %q does not name the reason:\n%s", bad, out)
+ }
+ }
+ // "default" is refused for its OWN reason — it names the base bundle, it
+ // is not merely malformed (review MINOR 7). Asserting the reason is what
+ // keeps this pinned to the reservation: with the reservation removed, the
+ // existence rail would still refuse "default", for an unrelated reason,
+ // and an assertion on err alone would stay green.
+ out, err := f.run(os.Environ(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "default")
+ if err == nil {
+ t.Fatalf("-environment default was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "base bundle") {
+ t.Errorf("refusal of \"default\" does not explain that it already names the base bundle:\n%s", out)
+ }
+}
+
+// -environment alone (no -channel) requires neither -channel's pubkey nor
+// its version-comparison rail — the two axes are independent all the way
+// down to which env vars a run needs.
+func TestRouteEnvironmentAloneNeedsNoPubkey(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+ f.saveEnvironment(release.OpsAccount, "tito", "demo")
+
+ env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+f.storeDir) // no CARLOS_RELEASE_PUBKEY
+ out, err := f.run(env, "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "demo")
+ if err != nil {
+ t.Fatalf("route -environment with no CARLOS_RELEASE_PUBKEY: %v\n%s", err, out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "demo" {
+ t.Fatalf("environment = %q, want demo", got)
+ }
+}
+
+// REGRESSION PIN: -channel alone must never touch Environment — the two
+// columns are independent, and a channel-only invocation is the common
+// case (every existing `carlos route -channel` caller). If SetChannels ever
+// grew a write to the environment column, this would catch it.
+func TestRouteChannelAloneNeverTouchesEnvironment(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "tito", "v1")
+ f.promote(release.OpsAccount, "tito", "canary/x", "v1")
+ f.promote(release.OpsAccount, "tito", "stable", "v1")
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito",
+ Channel: "canary/x", Version: "v1", Status: "ready", Environment: "demo"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("route -channel: %v\n%s", err, out)
+ }
+ if got := f.channelOf("demo.tito.example.com"); got != "stable" {
+ t.Fatalf("channel = %q, want stable", got)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "demo" {
+ t.Fatalf("environment = %q after a -channel-only run, want untouched demo", got)
+ }
+}
+
+// -app bulk atomicity for -environment: within one -account all sweep, one
+// account's app has the named bundle and the other does not — the whole
+// batch must refuse, and NEITHER account's route may move, even the one
+// whose bundle genuinely exists.
+func TestRouteEnvironmentAppBulkAllOrNothingAcrossAccounts(t *testing.T) {
+ f := newRouteTestFixture(t)
+ accOut, err := f.run(f.fullEnv(), "accounts", "create", "-registry", f.regPath, "-name", "Second", "-owner", "second@keymail.dev")
+ if err != nil {
+ t.Fatalf("accounts create: %v\n%s", err, accOut)
+ }
+ m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(accOut)
+ if m == nil {
+ t.Fatalf("accounts create printed no sqid:\n%s", accOut)
+ }
+ sqid := m[1]
+
+ f.saveEnvironment(release.OpsAccount, "acctapp", "demo") // only the ops account has it
+ f.addRoute(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance",
+ App: "acctapp", Account: release.OpsAccount, Status: "ready"})
+ f.addRoute(registry.Route{Host: "second-acctapp.example.com", Kind: "instance",
+ App: "acctapp", Account: sqid, Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-app", "acctapp", "-environment", "demo", "-account", "all")
+ if err == nil {
+ t.Fatalf("a batch spanning an account without the bundle was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "second-acctapp.example.com") {
+ t.Errorf("output does not name the failing route:\n%s", out)
+ }
+ if got := f.environmentOf("ops-acctapp.example.com"); got != "" {
+ t.Fatalf("ops-account route moved despite the refused batch (not atomic): %q", got)
+ }
+ if got := f.environmentOf("second-acctapp.example.com"); got != "" {
+ t.Fatalf("second-account route environment = %q, want untouched empty", got)
+ }
+}
+
+// Combined -channel/-environment: both rails clear, both columns move in
+// one write.
+func TestRouteCombinedChannelAndEnvironmentBothWrite(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "tito", "v1")
+ f.promote(release.OpsAccount, "tito", "canary/x", "v1")
+ f.promote(release.OpsAccount, "tito", "stable", "v1")
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito",
+ Channel: "canary/x", Version: "v1", Status: "ready"})
+ f.saveEnvironment(release.OpsAccount, "tito", "demo")
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-channel", "stable", "-environment", "demo")
+ if err != nil {
+ t.Fatalf("route -channel -environment: %v\n%s", err, out)
+ }
+ if got := f.channelOf("demo.tito.example.com"); got != "stable" {
+ t.Fatalf("channel = %q, want stable", got)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "demo" {
+ t.Fatalf("environment = %q, want demo", got)
+ }
+}
+
+// Combined atomicity: the environment bundle exists (that rail would pass
+// cleanly) but the channel rail refuses (a genuine version change without
+// -allow-version-change) — NEITHER column may move. Proves the two rails
+// share one gate before either axis writes, not two independent writes that
+// happen to usually succeed together.
+func TestRouteCombinedAtomicityChannelRefusalBlocksEnvironmentToo(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "widget", "v1")
+ f.ship(release.OpsAccount, "widget", "v2")
+ f.promote(release.OpsAccount, "widget", "canary/widget", "v1")
+ f.promote(release.OpsAccount, "widget", "stable", "v2")
+ f.addRoute(registry.Route{Host: "widget.example.com", Kind: "instance", App: "widget",
+ Channel: "canary/widget", Version: "v1", Status: "ready"})
+ f.saveEnvironment(release.OpsAccount, "widget", "demo")
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "widget.example.com", "-channel", "stable", "-environment", "demo")
+ if err == nil {
+ t.Fatalf("a combined write with a genuine version change was accepted without -allow-version-change:\n%s", out)
+ }
+ if got := f.channelOf("widget.example.com"); got != "canary/widget" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+ if got := f.environmentOf("widget.example.com"); got != "" {
+ t.Fatalf("environment moved despite the channel rail's refusal (not atomic): %q", got)
+ }
+}
+
+// Unlike Channel, -environment is allowed on a Platform+Canary route — the
+// domains reconciler never derives Environment for a minted route (only
+// Channel, for static mints), so this verb owns the column unconditionally
+// on every route kind.
+// Review IMPORTANT 4: a Platform+Canary route's whole ROW is the domains
+// reconciler's, not this verb's — its canary-minting pass re-Upserts the row
+// from mintedRoute's zero values whenever its `followed` check goes false.
+// Upsert now preserves a non-empty Environment (IMPORTANT 3), which makes the
+// binding survivable rather than silently reverted, but a column the
+// reconciler rebuilds is still not one this verb hands an operator. The
+// refusal is structural, exactly like the Channel one, and shares its
+// function (platformCanaryOwnedRow).
+func TestRouteEnvironmentRefusedOnPlatformCanaryRoute(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "site", "v1")
+ f.promote(release.OpsAccount, "site", "canary/preview", "v1")
+ f.addRoute(registry.Route{Host: "preview.site.bab.apps.example.com", Kind: "static", App: "site",
+ Channel: "canary/preview", Status: "ready", Platform: true, Canary: true})
+ f.saveEnvironment(release.OpsAccount, "site", "demo")
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "preview.site.bab.apps.example.com", "-environment", "demo")
+ if err == nil {
+ t.Fatalf("route -environment on a Platform+Canary route was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "reconciler") {
+ t.Errorf("refusal does not say who owns the row:\n%s", out)
+ }
+ if got := f.environmentOf("preview.site.bab.apps.example.com"); got != "" {
+ t.Fatalf("environment = %q, want it unwritten after the refusal", got)
+ }
+ // The refusal is on the ROW, not on one direction of travel: a binding
+ // made before this rail existed (or by an older binary) is equally not
+ // this verb's to CLEAR. Only a genuine no-op stays quiet, matching the
+ // channel axis, which also refuses moving back to stable.
+ f.addRoute(registry.Route{Host: "legacy.site.bab.apps.example.com", Kind: "static", App: "site",
+ Channel: "canary/preview", Status: "ready", Platform: true, Canary: true, Environment: "demo"})
+ out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "legacy.site.bab.apps.example.com", "-environment", "-")
+ if err == nil {
+ t.Fatalf("clearing an environment on a Platform+Canary route was accepted:\n%s", out)
+ }
+ if got := f.environmentOf("legacy.site.bab.apps.example.com"); got != "demo" {
+ t.Fatalf("environment = %q, want the pre-existing binding left alone by the refusal", got)
+ }
+ // A no-op is not a refusal — nothing is being written.
+ if out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "preview.site.bab.apps.example.com", "-environment", "-"); err != nil {
+ t.Fatalf("a no-op clear on an unbound Platform+Canary route was refused: %v\n%s", err, out)
+ }
+}
+
+// Review IMPORTANT 3: `carlos add` on an existing host must not unbind it.
+// add has no -environment flag, so a plain re-add (a routine operation — it
+// is how a route's socket, backing or db path is corrected) used to clear the
+// binding, and the next config sync then rendered the default bundle over
+// that route's env file and restarted the unit.
+func TestAddOnABoundHostPreservesItsEnvironment(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+ f.saveEnvironment(release.OpsAccount, "tito", "demo")
+ if out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "demo"); err != nil {
+ t.Fatalf("route -environment demo: %v\n%s", err, out)
+ }
+
+ if out, err := f.run(f.fullEnv(), "add", "-registry", f.regPath,
+ "-app", "tito", "demo.tito.example.com"); err != nil {
+ t.Fatalf("re-add: %v\n%s", err, out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "demo" {
+ t.Fatalf("environment = %q after `carlos add` on a bound host, want demo", got)
+ }
+}
+
+// Review CRITICAL 2, end-to-end: a typo'd `env unset -environment` must not
+// mint the bundle it names. A phantom empty bundle satisfies the route
+// existence rail — whose entire purpose is refusing a bundle that is not
+// there — and a route bound to one silently loses the environment it was on.
+func TestRouteEnvironmentRailNotSatisfiableByATypoedUnset(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "env", "unset", "-app", "tito", "-environment", "typoo", "NOTHING")
+ if err == nil {
+ t.Fatalf("env unset against a nonexistent bundle succeeded:\n%s", out)
+ }
+ if !strings.Contains(out, "nothing to unset") {
+ t.Errorf("refusal does not say there was nothing to unset:\n%s", out)
+ }
+ out, err = f.run(f.fullEnv(), "env", "environments", "-app", "tito")
+ if err != nil {
+ t.Fatalf("env environments: %v\n%s", err, out)
+ }
+ if strings.Contains(out, "typoo") {
+ t.Fatalf("the unset minted a phantom bundle:\n%s", out)
+ }
+ out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "typoo")
+ if err == nil {
+ t.Fatalf("the existence rail accepted a bundle that was never created:\n%s", out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "" {
+ t.Fatalf("environment = %q, want it unwritten", got)
+ }
+}
+
+// -dry-run on -environment runs the full existence rail and prints the
+// plan, but writes nothing — same contract as -channel's -dry-run.
+func TestRouteEnvironmentDryRunWritesNothing(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+ f.saveEnvironment(release.OpsAccount, "tito", "demo")
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.tito.example.com", "-environment", "demo", "-dry-run")
+ if err != nil {
+ t.Fatalf("route -environment -dry-run: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "dry run") {
+ t.Errorf("output does not say this was a dry run:\n%s", out)
+ }
+ if got := f.environmentOf("demo.tito.example.com"); got != "" {
+ t.Fatalf("dry run wrote an environment change: %q", got)
+ }
+}
+
+// Neither -channel nor -environment given is a usage error naming the
+// route-creation "" convention, not a silent no-op.
+func TestRouteNeitherChannelNorEnvironmentIsUsageError(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com")
+ if err == nil {
+ t.Fatalf("neither -channel nor -environment was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "stable-by-default") {
+ t.Errorf("refusal does not explain \"\" at route creation vs. this verb:\n%s", out)
+ }
+}
diff --git a/internal/appconfig/environments.go b/internal/appconfig/environments.go
new file mode 100644
index 000000000..3fdcb21fa
--- /dev/null
+++ b/internal/appconfig/environments.go
@@ -0,0 +1,194 @@
+// Named environments: overlays on an app's default config.json bundle, at
+// the pipelines design's sibling bucket path
+// (2026-08-02-pipelines-design.md §4a). Same manifest shape, same CAS
+// discipline, same schema-1 checks as the default bundle (store.go) — a
+// named environment is not a new format, just a second bucket key an app
+// can write the same shape to.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/environments/<name>.json CAS-guarded, schema-1 manifest
+package appconfig
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// EnvironmentKey is the bucket key of app's envName environment bundle —
+// account-scoped and app-scoped the same way Key is, at a sibling path so a
+// prefix listing (ListEnvironments) can enumerate bundles independently of
+// config.json.
+func EnvironmentKey(acct, app, name string) string {
+ return release.AppPrefix(acct, app) + "environments/" + name + ".json"
+}
+
+// EnvironmentsPrefix is the list prefix behind "what named environments does
+// this app have" — every apps/<acct>/<app>/environments/<name>.json key
+// shares it. Unlike release.ChannelsPrefix there is no legacy fallback:
+// environments are new, so there is nothing pre-migration to fall back to.
+func EnvironmentsPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "environments/"
+}
+
+// envNameRe: lowercase letters, digits and hyphens, 1-32 characters — the
+// pipelines design's environment-name shape (§4a). Case-restricted (unlike
+// ValidVarName) because these names appear in bucket paths and CLI
+// arguments an operator types by hand across many apps; one case convention
+// keeps "Demo" and "demo" from silently being two different bundles.
+var envNameRe = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
+
+// ValidEnvironmentName reports whether name is usable as a named
+// environment bundle name. "default" is refused — it names the implicit
+// base bundle (config.json), not a named overlay — and so is "", since an
+// empty name is how callers ask for "no environment" (LoadMerged's
+// envName == "" case), never a bundle to write.
+func ValidEnvironmentName(name string) bool {
+ if name == "default" {
+ return false
+ }
+ return envNameRe.MatchString(name)
+}
+
+// LoadEnvironment fetches app's envName bundle, an empty manifest (not an
+// error) if it has never been configured — same "never configured is not an
+// error" convention as Get, for the same reason: `carlos env list
+// -environment <name>` has nothing to print either way.
+func LoadEnvironment(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) {
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidEnvironmentName(envName) {
+ return nil, fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, envName))
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil
+ }
+ return m, nil
+}
+
+// SaveEnvironment applies sets/unsets to app's envName bundle via the exact
+// same CAS read-modify-write path SetEnv/SetSecret/Unset use for the default
+// bundle (applyAt) — a named environment gets the same racing-write
+// ErrCASConflict discipline the default bundle already has.
+func SaveEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) error {
+ _, err := applyEnvironment(ctx, s, acct, app, envName, sets, unsets)
+ return err
+}
+
+// UnsetEnvironment removes vars by key from app's envName bundle, returning
+// how many of keys were actually there. Split out from SaveEnvironment
+// because the count is the difference between "unset 1 var(s)" and the truth
+// (2026-08-02 review, MINOR 9) — and because a bundle that does not exist is
+// refused here rather than minted (applyAt's doc comment has the why; a
+// phantom bundle satisfies `carlos route -environment`'s existence rail).
+func UnsetEnvironment(ctx context.Context, s release.Store, acct, app, envName string, keys []string) (int, error) {
+ return applyEnvironment(ctx, s, acct, app, envName, nil, keys)
+}
+
+func applyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) {
+ if !release.ValidAccount(acct) {
+ return 0, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return 0, fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidEnvironmentName(envName) {
+ return 0, fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ return applyAt(ctx, s, EnvironmentKey(acct, app, envName), app, envName, sets, unsets)
+}
+
+// ListEnvironments returns app's named environment bundle names (bare
+// names, not full keys), sorted, from a prefix listing — the source
+// `carlos env environments -app <a>` (and the route -environment existence
+// rail) reads. An app with none configured lists empty, not an error.
+func ListEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, fmt.Errorf("config: invalid app %q", app)
+ }
+ prefix := EnvironmentsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(keys))
+ for _, k := range keys {
+ names = append(names, strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json"))
+ }
+ sort.Strings(names)
+ return names, nil
+}
+
+// LoadMerged resolves the manifest a route actually gets at materialization
+// time: the default bundle (config.json) overlaid by envName's named
+// bundle, the environment winning per key — one overlay, never a chain
+// (pipelines design §4a).
+//
+// envName == "" resolves to the default bundle alone (or an empty manifest
+// if there is no default bundle either — matches Get/Sync's existing
+// "never configured" convention: sync.go's getIfExists-then-skip path never
+// treats an unconfigured app as an error). A present default with no named
+// environment, or a present named environment with no default, both resolve
+// to just that side — an environment does not require a default to exist.
+//
+// A NAMED environment that is absent is different: the caller (a route
+// bound to it via `carlos route -environment`) asserted it exists, so its
+// absence is a defect the operator needs to see, not a silently empty
+// overlay. This is what makes the route-binding existence rail meaningful —
+// see cli.go's runRoute, which checks existence at BIND time so this error
+// is rare in practice (it means the bundle was deleted after binding, not
+// that the bind was unchecked).
+func LoadMerged(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) {
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, fmt.Errorf("config: invalid app %q", app)
+ }
+ def, defFound, err := getIfExistsAt(ctx, s, Key(acct, app))
+ if err != nil {
+ return nil, err
+ }
+ if envName == "" {
+ if !defFound {
+ return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil
+ }
+ return def, nil
+ }
+ if !ValidEnvironmentName(envName) {
+ return nil, fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ env, envFound, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, envName))
+ if err != nil {
+ return nil, err
+ }
+ if !envFound {
+ return nil, fmt.Errorf("config: environment %q not found for %s/%s — bound but never created (`carlos env set -app %s -environment %s ...`)", envName, acct, app, app, envName)
+ }
+ merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}
+ if defFound {
+ for k, v := range def.Vars {
+ merged.Vars[k] = v
+ }
+ }
+ for k, v := range env.Vars {
+ merged.Vars[k] = v
+ }
+ return merged, nil
+}
diff --git a/internal/appconfig/environments_test.go b/internal/appconfig/environments_test.go
new file mode 100644
index 000000000..24ef81079
--- /dev/null
+++ b/internal/appconfig/environments_test.go
@@ -0,0 +1,333 @@
+// internal/appconfig/environments_test.go
+package appconfig
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestEnvironmentKeyIsAccountAndAppScoped(t *testing.T) {
+ got := EnvironmentKey("bab", "keymail", "demo")
+ want := "apps/bab/keymail/environments/demo.json"
+ if got != want {
+ t.Fatalf("EnvironmentKey(bab, keymail, demo) = %q, want %q", got, want)
+ }
+}
+
+func TestValidEnvironmentName(t *testing.T) {
+ valid := []string{"demo", "a", "connect-timeslots", "yste-demo", "0-9"}
+ invalid := []string{
+ "", "default", // reserved / empty
+ "Demo", // uppercase
+ "has space", // space
+ "has_us", // underscore not allowed
+ "UP",
+ string(make([]byte, 33)), // too long (33 NUL bytes, also fails the char class)
+ }
+ for _, n := range valid {
+ if !ValidEnvironmentName(n) {
+ t.Errorf("ValidEnvironmentName(%q) = false, want true", n)
+ }
+ }
+ for _, n := range invalid {
+ if ValidEnvironmentName(n) {
+ t.Errorf("ValidEnvironmentName(%q) = true, want false", n)
+ }
+ }
+}
+
+func TestValidEnvironmentNameLengthBoundary(t *testing.T) {
+ ok32 := "01234567890123456789012345678901" // 32 chars
+ if len(ok32) != 32 {
+ t.Fatalf("test fixture is %d chars, want 32", len(ok32))
+ }
+ if !ValidEnvironmentName(ok32) {
+ t.Errorf("ValidEnvironmentName(32 chars) = false, want true")
+ }
+ tooLong := ok32 + "x"
+ if ValidEnvironmentName(tooLong) {
+ t.Errorf("ValidEnvironmentName(33 chars) = true, want false")
+ }
+}
+
+func TestLoadEnvironmentOnMissingBundleReturnsEmpty(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ m, err := LoadEnvironment(context.Background(), s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadEnvironment: %v", err)
+ }
+ if len(m.Vars) != 0 {
+ t.Fatalf("Vars = %v, want empty for a never-configured environment", m.Vars)
+ }
+}
+
+func TestLoadEnvironmentRejectsReservedName(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if _, err := LoadEnvironment(context.Background(), s, "bab", "keymail", "default"); err == nil {
+ t.Fatal("LoadEnvironment(..., \"default\") accepted, want a refusal")
+ }
+}
+
+func TestSaveEnvironmentThenLoad(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo",
+ map[string]Var{"LOG_LEVEL": {Kind: "env", Value: "debug"}}, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ m, err := LoadEnvironment(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadEnvironment: %v", err)
+ }
+ if v := m.Vars["LOG_LEVEL"]; v.Kind != "env" || v.Value != "debug" {
+ t.Fatalf("LOG_LEVEL = %+v, want kind env value debug", v)
+ }
+ // The default bundle (config.json) must be untouched by a named-bundle write.
+ def, err := Get(ctx, s, "bab", "keymail")
+ if err != nil {
+ t.Fatalf("Get (default): %v", err)
+ }
+ if len(def.Vars) != 0 {
+ t.Fatalf("default bundle vars = %v, want empty — SaveEnvironment must not touch config.json", def.Vars)
+ }
+}
+
+func TestSaveEnvironmentRejectsReservedOrInvalidName(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, bad := range []string{"", "default", "Has-Upper", "has_underscore"} {
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", bad,
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err == nil {
+ t.Errorf("SaveEnvironment(..., %q, ...) accepted, want a refusal", bad)
+ }
+ }
+}
+
+func TestSaveEnvironmentNewerSchemaRefused(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ raw := []byte(`{"schema": 2, "app": "keymail", "vars": {}}`)
+ if _, err := s.Put(ctx, EnvironmentKey("bab", "keymail", "demo"), raw, ""); err != nil {
+ t.Fatalf("Put: %v", err)
+ }
+ if _, err := LoadEnvironment(ctx, s, "bab", "keymail", "demo"); err == nil {
+ t.Fatal("LoadEnvironment read a schema-2 bundle without complaint, want a refusal")
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err == nil {
+ t.Fatal("SaveEnvironment wrote over a schema-2 bundle, want a refusal")
+ }
+}
+
+func TestListEnvironments(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, name := range []string{"demo", "connect", "timeslots"} {
+ if err := SaveEnvironment(ctx, s, "bab", "tito", name,
+ map[string]Var{"X": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatalf("SaveEnvironment(%s): %v", name, err)
+ }
+ }
+ // A different app's environment must not leak in.
+ if err := SaveEnvironment(ctx, s, "bab", "other-app", "demo",
+ map[string]Var{"X": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ names, err := ListEnvironments(ctx, s, "bab", "tito")
+ if err != nil {
+ t.Fatalf("ListEnvironments: %v", err)
+ }
+ want := []string{"connect", "demo", "timeslots"}
+ if len(names) != len(want) {
+ t.Fatalf("ListEnvironments = %v, want %v", names, want)
+ }
+ for i := range want {
+ if names[i] != want[i] {
+ t.Fatalf("ListEnvironments = %v, want %v", names, want)
+ }
+ }
+}
+
+func TestListEnvironmentsEmptyWhenNoneConfigured(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ names, err := ListEnvironments(context.Background(), s, "bab", "keymail")
+ if err != nil {
+ t.Fatalf("ListEnvironments: %v", err)
+ }
+ if len(names) != 0 {
+ t.Fatalf("ListEnvironments = %v, want empty", names)
+ }
+}
+
+// --- LoadMerged ---------------------------------------------------------
+
+func TestLoadMergedEmptyEnvNameIsDefaultOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "default-a"}); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadMerged(ctx, s, "bab", "keymail", "")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ if len(m.Vars) != 1 || m.Vars["A"].Value != "default-a" {
+ t.Fatalf("LoadMerged(envName=\"\") = %+v, want just the default bundle", m.Vars)
+ }
+}
+
+// Matches Sync's current behaviour for an unconfigured app: no default
+// object AND envName == "" resolves to an empty manifest, not an error.
+func TestLoadMergedNoDefaultNoEnvNameIsEmptyNotError(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ m, err := LoadMerged(context.Background(), s, "bab", "keymail", "")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v, want no error for an unconfigured app", err)
+ }
+ if len(m.Vars) != 0 {
+ t.Fatalf("Vars = %v, want empty", m.Vars)
+ }
+}
+
+func TestLoadMergedEnvironmentOverlaysDefaultWinningPerKey(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{
+ "SHARED": "default-value", "DEFAULT_ONLY": "d",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "SHARED": {Kind: "env", Value: "demo-value"},
+ "ENV_ONLY": {Kind: "env", Value: "e"},
+ }, nil); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadMerged(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ if len(m.Vars) != 3 {
+ t.Fatalf("Vars = %+v, want 3 keys (SHARED, DEFAULT_ONLY, ENV_ONLY)", m.Vars)
+ }
+ if m.Vars["SHARED"].Value != "demo-value" {
+ t.Fatalf("SHARED = %q, want the environment's overlay value to win", m.Vars["SHARED"].Value)
+ }
+ if m.Vars["DEFAULT_ONLY"].Value != "d" {
+ t.Fatalf("DEFAULT_ONLY = %q, want the default's value preserved", m.Vars["DEFAULT_ONLY"].Value)
+ }
+ if m.Vars["ENV_ONLY"].Value != "e" {
+ t.Fatalf("ENV_ONLY = %q, want the environment-only value preserved", m.Vars["ENV_ONLY"].Value)
+ }
+}
+
+// A MISSING default with a present, named environment resolves to the
+// environment alone — not an error, and not folded into an empty manifest.
+func TestLoadMergedEnvironmentOnlyNoDefault(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadMerged(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ if len(m.Vars) != 1 || m.Vars["A"].Value != "1" {
+ t.Fatalf("LoadMerged(env-only) = %+v, want just the environment's vars", m.Vars)
+ }
+}
+
+// A missing environment named EXPLICITLY is an error: the caller bound a
+// route to it, so its absence is a defect, not an empty set — distinct from
+// envName == "", which is a legitimate "no environment bound" state.
+func TestLoadMergedMissingNamedEnvironmentErrors(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadMerged(ctx, s, "bab", "keymail", "demo"); err == nil {
+ t.Fatal("LoadMerged with a named-but-absent environment: got nil error, want one")
+ }
+}
+
+func TestLoadMergedRejectsInvalidEnvironmentName(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if _, err := LoadMerged(context.Background(), s, "bab", "keymail", "default"); err == nil {
+ t.Fatal("LoadMerged(..., \"default\") accepted, want a refusal — \"default\" is not a named environment")
+ }
+}
+
+func TestLoadMergedSchemaTooNewOnEitherSideRefuses(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ raw := []byte(`{"schema": 2, "app": "keymail", "vars": {}}`)
+ if _, err := s.Put(ctx, EnvironmentKey("bab", "keymail", "demo"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadMerged(ctx, s, "bab", "keymail", "demo"); err == nil {
+ t.Fatal("LoadMerged over a schema-2 environment bundle: got nil error, want one")
+ }
+}
+
+// Review CRITICAL 2, named-bundle half: an unset against a bundle that does
+// not exist must not MINT it. A phantom empty bundle passes the route
+// existence rail (`carlos route -environment`), which exists precisely to
+// refuse binding a bundle that isn't there — and a route bound to it silently
+// loses whatever environment it was on.
+func TestUnsetEnvironmentOnMissingBundleErrorsAndMintsNothing(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ removed, err := UnsetEnvironment(ctx, s, "bab", "keymail", "typoo", []string{"NOTHING"})
+ if err == nil {
+ t.Fatalf("unset against a nonexistent bundle succeeded (removed %d), want a refusal", removed)
+ }
+ if !strings.Contains(err.Error(), "nothing to unset") {
+ t.Errorf("error = %v, want it to say there is nothing to unset", err)
+ }
+ names, err := ListEnvironments(ctx, s, "bab", "keymail")
+ if err != nil {
+ t.Fatalf("ListEnvironments: %v", err)
+ }
+ if len(names) != 0 {
+ t.Fatalf("environments = %v, want none — the unset minted a phantom bundle", names)
+ }
+}
+
+// The same command against a bundle that DOES exist works, and reports how
+// many keys it actually removed (review MINOR 9), not how many it was given.
+func TestUnsetEnvironmentReportsActualRemovals(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "A": {Kind: "env", Value: "1"},
+ "B": {Kind: "env", Value: "2"},
+ }, nil); err != nil {
+ t.Fatal(err)
+ }
+ removed, err := UnsetEnvironment(ctx, s, "bab", "keymail", "demo", []string{"A", "NEVER_SET"})
+ if err != nil {
+ t.Fatalf("UnsetEnvironment: %v", err)
+ }
+ if removed != 1 {
+ t.Fatalf("removed = %d, want 1 (A removed, NEVER_SET was never there)", removed)
+ }
+ m, err := LoadEnvironment(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := m.Vars["A"]; ok {
+ t.Fatal("A survived the unset")
+ }
+ if m.Vars["B"].Value != "2" {
+ t.Fatalf("B = %+v, want it untouched", m.Vars["B"])
+ }
+}
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go
index 7fdd51b86..33883ec37 100644
--- a/internal/appconfig/store.go
+++ b/internal/appconfig/store.go
@@ -43,7 +43,16 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
if !release.ValidApp(app) {
return nil, false, fmt.Errorf("config: invalid app %q", app)
}
- raw, _, err := s.Get(ctx, Key(acct, app))
+ return getIfExistsAt(ctx, s, Key(acct, app))
+}
+
+// getIfExistsAt is getIfExists generalized to any manifest key — Key(acct,
+// app) for the default bundle, EnvironmentKey(acct, app, name) for a named
+// one (see environments.go). Same shape either way: a config.json and a
+// named environment bundle are the identical manifest format at sibling
+// bucket paths, so one read path serves both.
+func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) {
+ raw, _, err := s.Get(ctx, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
@@ -52,55 +61,81 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return nil, false, fmt.Errorf("config %s: %w", Key(acct, app), err)
+ return nil, false, fmt.Errorf("config %s: %w", key, err)
}
// json.Unmarshal has already dropped any fields this binary doesn't
// know about, so acting on a newer-schema manifest would silently
// discard whatever those fields meant. Refuse instead.
if m.Schema > 1 {
- return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", Key(acct, app), m.Schema)
+ return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
}
return &m, true, nil
}
-// getForWrite is Get plus the etag a caller needs to write back — a
-// not-found manifest reads as empty with etag "" (create-only Put).
-func getForWrite(ctx context.Context, s release.Store, acct, app string) (Manifest, string, error) {
- raw, etag, err := s.Get(ctx, Key(acct, app))
+// getForWriteAt is Get plus the two bits a caller needs to write back: the
+// etag (a not-found manifest reads as empty with etag "", a create-only Put)
+// and whether the object exists at all. found is NOT inferable from the etag
+// — a store is free to hand back an empty etag for an object that exists —
+// and applyAt's refusal to create a bundle on an unset-only call turns on it.
+// Generalized to any manifest key; see getIfExistsAt's doc comment for why
+// one path serves both the default bundle and a named environment.
+func getForWriteAt(ctx context.Context, s release.Store, key, app string) (Manifest, string, bool, error) {
+ raw, etag, err := s.Get(ctx, key)
switch {
case err == nil:
var m Manifest
if uerr := json.Unmarshal(raw, &m); uerr != nil {
- return Manifest{}, "", fmt.Errorf("config %s: %w", Key(acct, app), uerr)
+ return Manifest{}, "", false, fmt.Errorf("config %s: %w", key, uerr)
}
// Same newer-schema refusal as getIfExists — writing back through
// this path would downgrade the object to schema 1 minus the fields
// unmarshal already dropped.
if m.Schema > 1 {
- return Manifest{}, "", fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", Key(acct, app), m.Schema)
+ return Manifest{}, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
}
- return m, etag, nil
+ return m, etag, true, nil
case errors.Is(err, os.ErrNotExist):
- return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", nil
+ return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", false, nil
default:
- return Manifest{}, "", err
+ return Manifest{}, "", false, err
}
}
-// apply reads, applies sets and unsets, and writes back via CAS. A racing
-// concurrent write surfaces as release.ErrCASConflict — callers report it
-// and let the operator re-run; there is no automatic retry, matching
-// release.Promote's own behavior.
-func apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) error {
+// apply reads, applies sets and unsets, and writes back via CAS, reporting
+// how many of unsets were actually present. A racing concurrent write
+// surfaces as release.ErrCASConflict — callers report it and let the operator
+// re-run; there is no automatic retry, matching release.Promote's own
+// behavior.
+func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets map[string]Var, unsets []string) (int, error) {
if !release.ValidAccount(acct) {
- return fmt.Errorf("config: invalid account %q", acct)
+ return 0, fmt.Errorf("config: invalid account %q", acct)
}
if !release.ValidApp(app) {
- return fmt.Errorf("config: invalid app %q", app)
+ return 0, fmt.Errorf("config: invalid app %q", app)
}
+ return applyAt(ctx, s, Key(acct, app), app, bundle, sets, unsets)
+}
+
+// applyAt is apply generalized to any manifest key — see getIfExistsAt's doc
+// comment for why one path serves both the default bundle and a named
+// environment. Callers validate acct/app/environment-name themselves before
+// reaching here, same division as apply does for Key(acct, app). bundle is
+// how to name this object to a human ("default", or the environment name);
+// it never affects which key is written.
+//
+// An unset-only call against an object that does not exist is REFUSED, not
+// silently turned into a create (2026-08-02 review, CRITICAL 2). Creating it
+// would mint an empty manifest, and an empty manifest that EXISTS means
+// "configured, then fully unset" by this package's own contract — which is
+// exactly the state Sync converges every one of that app's route env files
+// to empty for, as root, restarting each unit. It also minted phantom
+// environment bundles that then satisfied `carlos route -environment`'s
+// existence rail. Sets still create, which is how a bundle is meant to come
+// into being.
+func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets map[string]Var, unsets []string) (int, error) {
for k, v := range sets {
if !ValidVarName(k) {
- return fmt.Errorf("config: invalid var name %q", k)
+ return 0, fmt.Errorf("config: invalid var name %q", k)
}
// Env values are validated here, while the operator can still see
// the plaintext — a bad value that only failed at render time would
@@ -109,18 +144,25 @@ func apply(ctx context.Context, s release.Store, acct, app string, sets map[stri
// CLI, before sealing; by this point they're opaque envelopes.
if v.Kind == "env" {
if err := ValidVarValue(v.Value); err != nil {
- return fmt.Errorf("config: var %s: %w", k, err)
+ return 0, fmt.Errorf("config: var %s: %w", k, err)
}
}
}
- m, etag, err := getForWrite(ctx, s, acct, app)
+ m, etag, found, err := getForWriteAt(ctx, s, key, app)
if err != nil {
- return err
+ return 0, err
+ }
+ if !found && len(sets) == 0 {
+ return 0, fmt.Errorf("config: %s has no %s bundle — nothing to unset", app, bundle)
}
if m.Vars == nil {
m.Vars = map[string]Var{}
}
+ removed := 0
for _, k := range unsets {
+ if _, ok := m.Vars[k]; ok {
+ removed++
+ }
delete(m.Vars, k)
}
for k, v := range sets {
@@ -129,19 +171,28 @@ func apply(ctx context.Context, s release.Store, acct, app string, sets map[stri
m.Schema, m.App = 1, app
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
- return err
+ return 0, err
}
- _, err = s.Put(ctx, Key(acct, app), raw, etag)
- return err
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ return 0, err
+ }
+ return removed, nil
}
+// DefaultBundle is how the default bundle (config.json) is named to a human
+// in messages — the same vocabulary ValidEnvironmentName reserves and the CLI
+// prints, kept in one place so an error from this package and a `list` header
+// can never drift apart.
+const DefaultBundle = "default"
+
// SetEnv writes one or more plaintext config vars.
func SetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) error {
sets := make(map[string]Var, len(kv))
for k, v := range kv {
sets[k] = Var{Kind: "env", Value: v}
}
- return apply(ctx, s, acct, app, sets, nil)
+ _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil)
+ return err
}
// SetSecret writes one or more secrets. sealed values must already be
@@ -152,11 +203,14 @@ func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed ma
for k, v := range sealed {
sets[k] = Var{Kind: "secret", Envelope: v}
}
- return apply(ctx, s, acct, app, sets, nil)
+ _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil)
+ return err
}
-// Unset removes vars (env or secret) by key. Unsetting a key that isn't set
-// is a no-op success, matching heroku config:unset's idempotence.
-func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) error {
- return apply(ctx, s, acct, app, nil, keys)
+// Unset removes vars (env or secret) by key from the default bundle,
+// returning how many of keys were actually there. Unsetting a key that isn't
+// set is a no-op success, matching heroku config:unset's idempotence —
+// unsetting from a bundle that does not EXIST is not (see applyAt).
+func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) (int, error) {
+ return apply(ctx, s, acct, app, DefaultBundle, nil, keys)
}
diff --git a/internal/appconfig/store_test.go b/internal/appconfig/store_test.go
index 9a8a2aa79..186af2492 100644
--- a/internal/appconfig/store_test.go
+++ b/internal/appconfig/store_test.go
@@ -4,6 +4,7 @@ package appconfig
import (
"context"
"errors"
+ "strings"
"testing"
"github.com/carlosframework/platform/internal/release"
@@ -41,9 +42,13 @@ func TestUnsetRemovesVar(t *testing.T) {
if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
t.Fatalf("SetEnv: %v", err)
}
- if err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil {
+ removed, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"})
+ if err != nil {
t.Fatalf("Unset: %v", err)
}
+ if removed != 1 {
+ t.Fatalf("Unset removed %d, want 1", removed)
+ }
m, err := Get(ctx, s, "bab", "keymail")
if err != nil {
t.Fatalf("Get: %v", err)
@@ -53,6 +58,49 @@ func TestUnsetRemovesVar(t *testing.T) {
}
}
+// Review CRITICAL 2: an unset against an app that has never been configured
+// must NOT create the manifest it was asked to remove from. A created-empty
+// config.json reads as "configured, then fully unset" — the state Sync
+// converges every one of that app's env files to EMPTY for, as root.
+func TestUnsetOnNeverConfiguredAppErrorsAndWritesNothing(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ removed, err := Unset(ctx, s, "bab", "keymail", []string{"OLDKEY"})
+ if err == nil {
+ t.Fatalf("Unset on a never-configured app succeeded (removed %d), want a refusal", removed)
+ }
+ if !strings.Contains(err.Error(), "nothing to unset") {
+ t.Errorf("error = %v, want it to say there is nothing to unset", err)
+ }
+ if _, found, gerr := getIfExists(ctx, s, "bab", "keymail"); gerr != nil || found {
+ t.Fatalf("unset created a manifest object (found = %v, err = %v)", found, gerr)
+ }
+}
+
+// Unsetting a key that is not set, on a bundle that DOES exist, stays a
+// success (heroku config:unset's idempotence) — it just removes nothing.
+func TestUnsetOfAnAbsentKeyOnAnExistingBundleIsANoopSuccess(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"KEEP": "1"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ removed, err := Unset(ctx, s, "bab", "keymail", []string{"NEVER_SET"})
+ if err != nil {
+ t.Fatalf("Unset of an absent key: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("Unset reported %d removals, want 0 — the count is of removals, not arguments", removed)
+ }
+ m, err := Get(ctx, s, "bab", "keymail")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if m.Vars["KEEP"].Value != "1" {
+ t.Fatalf("KEEP = %+v, want it untouched", m.Vars["KEEP"])
+ }
+}
+
func TestSetSecretStoresEnvelopeOnly(t *testing.T) {
s := release.NewDirStore(t.TempDir())
ctx := context.Background()
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 3bc7d0112..7be8313f3 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -1,8 +1,11 @@
-// Sync is the host-side convergence sweep: for every app the box's
-// registry runs, fetch its config manifest once and materialize the
-// result into every route's EnvironmentFile. Shaped after release.Pass
-// (resolve once per group, converge once per route) but with none of that
-// function's staging/symlink/bake machinery — config isn't versioned.
+// Sync is the host-side convergence sweep: for every (app, environment) the
+// box's registry runs, resolve the merged config manifest once and
+// materialize the result into every route's EnvironmentFile. Shaped after
+// release.Pass (resolve once per group, converge once per route) but with
+// none of that function's staging/symlink/bake machinery — config isn't
+// versioned. The group is (Account, App, Environment): a route's bound
+// environment (environments.go, pipelines design §4a) is an overlay on the
+// app's default bundle, so two routes of one app can resolve differently.
package appconfig
import (
@@ -12,6 +15,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"github.com/carlosframework/platform/internal/registry"
@@ -23,50 +27,129 @@ import (
// (EnvironmentFile=-/etc/carlos/apps/%i.env, %i = route Host).
const DefaultEnvDir = "/etc/carlos/apps"
-// Sync fetches apps/<acct>/<app>/config.json once per distinct
-// (Account, App) among reg's routes, then for every route running that
-// app renders <envDir>/<route.Host>.env and, if it changed, writes it and
+// Sync resolves each route's merged config once per distinct
+// (Account, App, Environment) among reg's routes, then for every route
+// renders <envDir>/<route.Host>.env and, if it changed, writes it and
// restarts that route's unit via restart. Die-free by design, like
-// release.Pass: a fetch or render failure for one app logs and continues
-// rather than aborting the whole sweep.
+// release.Pass: a fetch or resolve failure for one app+environment logs and
+// continues rather than aborting the whole sweep.
+//
+// The cache key carries Environment as well as (Account, App) because two
+// routes of ONE app bound to different environments resolve to different
+// manifests (pipelines design §4a): keying on (acct, app) alone would render
+// whichever route was seen first into both files. Distinct environments cost
+// one extra resolve each, still one pass over the routes.
func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error {
routes, err := reg.List()
if err != nil {
return err
}
- type groupKey struct{ acct, app string }
- manifests := map[groupKey]*Manifest{}
+ type groupKey struct{ acct, app, env string }
+ // A resolution is one of three states, and they are NOT interchangeable:
+ // a manifest to converge to; "this app was never configured" (nil, nil),
+ // which leaves the file alone; and "resolution failed" (nil, err), which
+ // also leaves the file alone but says so, loudly, per route. Never fold a
+ // failure into an empty manifest: an empty render differs from whatever
+ // is on disk, so it would truncate a live route's env file as root and
+ // restart the unit on it.
+ type resolution struct {
+ m *Manifest
+ err error
+ // defaultMissing marks a bound route's merged view as the named
+ // bundle ALONE, because the app has no config.json at all. Such a
+ // render is not an overlay, it is a wholesale replacement, so
+ // syncHost refuses to let it drop keys the file on disk already holds
+ // (see its doc comment; 2026-08-02 review, CRITICAL 1 and round 2).
+ defaultMissing bool
+ }
+ resolved := map[groupKey]resolution{}
+ // The default bundle is read once per (acct, app) and reused across that
+ // app's environments: every bound route needs it twice over — LoadMerged
+ // merges it, and the CRITICAL 1 guard turns on whether it exists at all.
+ type appKey struct{ acct, app string }
+ type defaultRes struct {
+ m *Manifest
+ found bool
+ err error
+ }
+ defaults := map[appKey]defaultRes{}
+ // getIfExists, not Get: an app with no manifest object in the bucket at
+ // all must be distinguishable from one whose every var was unset. Get
+ // folds those together into an empty manifest; the first has to leave a
+ // host's hand-staged env file untouched and the second has to converge it
+ // to empty. LoadMerged makes the same fold, which is why this read exists
+ // alongside it rather than being replaced by it.
+ loadDefault := func(acct, app string) defaultRes {
+ ak := appKey{acct, app}
+ if d, done := defaults[ak]; done {
+ return d
+ }
+ m, found, err := getIfExists(ctx, s, acct, app)
+ if err != nil {
+ // Reported per route below rather than here (where PR #9 printed
+ // one line per app): the operator's question on a fetch failure
+ // is which routes are now running on a stale file, and the answer
+ // is per route either way.
+ err = fmt.Errorf("%s/%s: config fetch failed: %w", acct, app, err)
+ }
+ d := defaultRes{m: m, found: found, err: err}
+ defaults[ak] = d
+ return d
+ }
for _, rt := range routes {
if !syncable(rt) {
continue
}
- k := groupKey{release.RouteAccount(rt), rt.App}
- if _, done := manifests[k]; done {
+ k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
+ if _, done := resolved[k]; done {
continue
}
- // getIfExists, not Get: an app with no manifest object in the bucket
- // at all is left entirely alone (nil manifest => skipped below), so
- // turning this feature on does not truncate env files that are still
- // hand-staged on the host. Get folds not-found into an empty
- // manifest, which would be indistinguishable from a real, fully
- // unset one — and those must converge.
- m, found, err := getIfExists(ctx, s, k.acct, k.app)
- if err != nil {
- fmt.Printf(" %s/%s: config fetch failed: %v\n", k.acct, k.app, err)
- manifests[k] = nil
+ def := loadDefault(k.acct, k.app)
+ if def.err != nil {
+ resolved[k] = resolution{err: def.err}
+ continue
+ }
+ if k.env == "" {
+ if !def.found {
+ resolved[k] = resolution{}
+ continue
+ }
+ resolved[k] = resolution{m: def.m}
continue
}
- if !found {
- manifests[k] = nil
+ // A bound environment asserts the bundle exists, so LoadMerged treats
+ // its absence as an error rather than an empty overlay — and that
+ // error must stay contained to the routes bound to it. No
+ // never-configured case here: an app can lack a default bundle and
+ // still resolve (environment-only), and if the environment itself is
+ // missing that is the error above, not silence.
+ m, err := LoadMerged(ctx, s, k.acct, k.app, k.env)
+ if err != nil || m == nil {
+ if err == nil {
+ err = fmt.Errorf("config: resolved a nil manifest for environment %q", k.env)
+ }
+ resolved[k] = resolution{err: err}
continue
}
- manifests[k] = m
+ resolved[k] = resolution{m: m, defaultMissing: !def.found}
}
for _, rt := range routes {
if !syncable(rt) {
continue
}
- m := manifests[groupKey{release.RouteAccount(rt), rt.App}]
+ r := resolved[groupKey{release.RouteAccount(rt), rt.App, rt.Environment}]
+ if r.err != nil {
+ // Loud, per route, every pass: this route is running on a stale
+ // env file until an operator fixes it. Its file is left exactly
+ // as it is — nothing below this line runs for it.
+ env := rt.Environment
+ if env == "" {
+ env = "default"
+ }
+ fmt.Printf(" %s: config unresolved (environment %q), env file left as-is: %v\n", rt.Host, env, r.err)
+ continue
+ }
+ m := r.m
if m == nil {
continue
}
@@ -80,7 +163,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
fmt.Printf(" %s: has bucket config but is exec-backed — config sync only reaches carlos-app@ unit routes, skipped\n", rt.Host)
continue
}
- syncHost(rt, m, priv, envDir, restart)
+ syncHost(rt, m, r.defaultMissing, priv, envDir, restart)
}
// An empty route list is never authority to delete anything:
// registry.Open silently creates an empty database when handed a wrong
@@ -102,6 +185,12 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
// assumption release adoption's symlink repointing already makes), so a
// host with zero rows is reaped immediately, with no grace period. A
// missing or unreadable env dir just means there is nothing to reap.
+//
+// Config state is deliberately not an input here: whether an app has a
+// manifest, or whether a route's bound environment still exists in the
+// bucket, has no bearing on whether its file is orphaned. Deleting an
+// environment bundle must never delete a live route's env file — that route
+// keeps its last-good file and Sync logs the unresolved binding instead.
func reap(routes []registry.Route, envDir string) {
ents, err := os.ReadDir(envDir)
if err != nil {
@@ -140,6 +229,42 @@ func reap(routes []registry.Route, envDir string) {
}
}
+// lostKeys reports which var names the file on disk holds that the manifest
+// about to replace it does not, sorted; parsed is false if cur is not
+// KEY=value throughout, in which case lost is meaningless and the caller must
+// treat the file as unsafe to overwrite.
+//
+// It compares against m.Vars rather than the rendered bytes on purpose: the
+// render is what the caller is deciding whether to WRITE, and this decision
+// has to be made before rendering (a refused route must never reach the
+// render-diff-write-restart path at all). RenderEnv emits exactly one
+// KEY=value line per var, so the two key sets are the same by construction.
+//
+// Blank lines and #-comments are ignored, matching how systemd's
+// EnvironmentFile reads these files: they carry no configuration, so their
+// loss is not the loss this guard exists to prevent. Anything else without an
+// "=" and a non-empty name makes the whole file unparseable rather than
+// silently skipped — a line this function does not understand may well be a
+// var, and guessing it is not is the unsafe direction.
+func lostKeys(cur []byte, m *Manifest) (lost []string, parsed bool) {
+ for _, line := range strings.Split(string(cur), "\n") {
+ s := strings.TrimSpace(line)
+ if s == "" || strings.HasPrefix(s, "#") {
+ continue
+ }
+ name, _, ok := strings.Cut(s, "=")
+ name = strings.TrimSpace(name)
+ if !ok || name == "" {
+ return nil, false
+ }
+ if _, kept := m.Vars[name]; !kept {
+ lost = append(lost, name)
+ }
+ }
+ sort.Strings(lost)
+ return lost, true
+}
+
// syncable mirrors release.adoptable: lifecycle-managed instances of a
// known app, excluding platform-minted alias routes (an alias shares its
// canonical route's process and therefore its canonical route's config —
@@ -150,11 +275,16 @@ func syncable(rt registry.Route) bool {
(rt.Status == "" || rt.Status == "ready")
}
-// syncHost converges one route's env file. It is called only for apps whose
-// manifest object really exists in the bucket, so an empty manifest here
-// means "every var was unset" and converges the file to empty — the
-// "never configured at all" case is filtered out by Sync, before this.
-func syncHost(rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) {
+// syncHost converges one route's env file. It is called only with a manifest
+// that really resolved — the object (default bundle, named environment, or
+// both) exists in the bucket — so an empty manifest here means "every var
+// was unset" and converges the file to empty. The "never configured at all"
+// and "resolution failed" cases are both filtered out by Sync, before this:
+// either one reaching here would truncate a live env file.
+//
+// defaultMissing says the merged view is a bound environment's bundle ALONE
+// because the app has no config.json — see the guard below.
+func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) {
// The host is joined into a root-written path; a registry row carrying
// a separator must never place a file outside envDir. Same trust level
// as release's live/<host> symlinks, but this one is one line to pin.
@@ -169,6 +299,56 @@ func syncHost(rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, envDir stri
fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err)
return
}
+ // The same protection an UNBOUND route gets from Sync's "no manifest
+ // object at all ⇒ leave the file alone" skip, applied to the DEFAULT half
+ // of a bound route's merge (2026-08-02 review, CRITICAL 1). Binding an
+ // environment to an app that has never had a config.json makes the merged
+ // render the override bundle ALONE — so the documented migration step
+ // (create a small override, bind the routes) would replace every var a
+ // hand-staged file holds with just the override's, as root, and restart
+ // the unit.
+ //
+ // The test is KEY LOSS, not "a non-empty file exists" (round-2 review):
+ // after this function's own first write the file is non-empty, so the
+ // cruder test refused to replace content Sync itself authored and froze
+ // the route forever. Losing keys is the actual hazard; a changed value or
+ // an added key is ordinary convergence. It also self-heals — the moment
+ // the missing keys exist in a bundle, nothing is lost and the route
+ // converges with no further intervention.
+ //
+ // - default bundle EXISTS (even with zero vars): the operator has
+ // adopted bucket config for this app, the merged render is
+ // authoritative, and none of this applies.
+ // - default MISSING and the file holds keys the render drops: refuse
+ // this route, loudly, every pass. Those keys are not ours to delete.
+ // - default MISSING and nothing is lost (including an empty or absent
+ // file): converge.
+ //
+ // Known limitation, accepted with the ruling: while an app has no default
+ // bundle, REMOVING a key from its environment bundle is indistinguishable
+ // from the truncation hazard and is refused. The message names both ways
+ // out. A "# managed by carlos" marker line would separate the two cases
+ // properly, at the cost of changing the rendered file format.
+ if defaultMissing && existed && len(cur) > 0 {
+ lost, parsed := lostKeys(cur, m)
+ switch {
+ case !parsed:
+ // Unparseable content has unknown keys, so nothing here can prove
+ // the render drops none of them. Assume the worst — this guard's
+ // whole job is to be wrong in the safe direction.
+ fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+
+ "refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+
+ "import its config (`carlos env set -app %s KEY=value ...`) or remove the file\n",
+ rt.Host, rt.Environment, rt.App, path, rt.App)
+ return
+ case len(lost) > 0:
+ fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+
+ "refusing to drop %d var(s) already in %s (%s); import them (`carlos env set -app %s KEY=value ...`) "+
+ "and this route converges on the next pass, or remove the file if it is dead\n",
+ rt.Host, rt.Environment, rt.App, len(lost), path, strings.Join(lost, ", "), rt.App)
+ return
+ }
+ }
rendered, err := RenderEnv(m, priv)
if err != nil {
fmt.Printf(" %s: render config: %v\n", rt.Host, err)
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go
index 0a0c4aced..1fedbc201 100644
--- a/internal/appconfig/sync_test.go
+++ b/internal/appconfig/sync_test.go
@@ -196,7 +196,7 @@ func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) {
t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug", got, err)
}
- if err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil {
+ if _, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil {
t.Fatalf("Unset: %v", err)
}
restarted = nil
@@ -396,7 +396,7 @@ func TestSyncHostRefusesNonBareHostname(t *testing.T) {
}
m := &Manifest{Schema: 1, App: "evil", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
for _, host := range []string{"../escape", "a/b", "", ".", ".."} {
- syncHost(registry.Route{Host: host}, m, nil, envDir, func(string) error { return nil })
+ syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil })
}
if _, err := os.Stat(filepath.Join(envDir, "..", "escape.env")); !os.IsNotExist(err) {
t.Fatalf("a path-escaping host wrote outside envDir, stat err = %v", err)
@@ -435,6 +435,592 @@ func TestSyncSurvivesAMissingEnvDir(t *testing.T) {
}
}
+func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) {
+ // One app, three routes of it, two of them bound to different named
+ // environments. Each route's file must be the default bundle overlaid by
+ // ITS OWN environment — no leakage of one route's environment into
+ // another's render (the per-(acct, app) fetch cache must not collapse
+ // routes that resolve differently).
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "info", "SHARED": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "debug"},
+ "DEMO_ONLY": {Kind: "env", Value: "1"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment demo: %v", err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "staging", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "trace"},
+ "STAGING_ONLY": {Kind: "env", Value: "1"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment staging: %v", err)
+ }
+ routes := map[string]string{ // host -> bound environment
+ "plain.example.com": "",
+ "demo.example.com": "demo",
+ "staging.example.com": "staging",
+ }
+ for host, env := range routes {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: env,
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+
+ var restarted []string
+ restart := func(h string) error { restarted = append(restarted, h); return nil }
+ if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ want := map[string]string{
+ "plain.example.com": "LOG_LEVEL=info\nSHARED=yes\n",
+ "demo.example.com": "DEMO_ONLY=1\nLOG_LEVEL=debug\nSHARED=yes\n",
+ "staging.example.com": "LOG_LEVEL=trace\nSHARED=yes\nSTAGING_ONLY=1\n",
+ }
+ for host, body := range want {
+ got, err := os.ReadFile(filepath.Join(envDir, host+".env"))
+ if err != nil {
+ t.Fatalf("%s: read rendered env: %v", host, err)
+ }
+ if string(got) != body {
+ t.Fatalf("%s: rendered env = %q, want %q", host, got, body)
+ }
+ }
+ if len(restarted) != 3 {
+ t.Fatalf("restarted = %v, want all three routes", restarted)
+ }
+
+ // Unchanged merged output on a second pass: no rewrite, no restart —
+ // including for the environment-bound routes, whose merge must be stable.
+ before := map[string]os.FileInfo{}
+ for host := range want {
+ fi, err := os.Stat(filepath.Join(envDir, host+".env"))
+ if err != nil {
+ t.Fatalf("%s: stat: %v", host, err)
+ }
+ before[host] = fi
+ }
+ restarted = nil
+ if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ t.Fatalf("Sync (second pass): %v", err)
+ }
+ if len(restarted) != 0 {
+ t.Fatalf("second pass restarted %v, want none", restarted)
+ }
+ for host, fi := range before {
+ now, err := os.Stat(filepath.Join(envDir, host+".env"))
+ if err != nil {
+ t.Fatalf("%s: stat after second pass: %v", host, err)
+ }
+ if !now.ModTime().Equal(fi.ModTime()) {
+ t.Fatalf("%s: env file was rewritten on an unchanged pass", host)
+ }
+ }
+}
+
+func TestSyncLeavesEnvFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) {
+ // The bundle was deleted out from under a bound route. That route's
+ // resolution FAILS — and a failed resolution must never reach the
+ // render-and-diff path, because an empty render differs from the file on
+ // disk and would truncate it as root and restart the unit. The last-good
+ // file stays byte-identical and untouched; other routes of the same app
+ // still converge.
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "info"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ for host, env := range map[string]string{
+ "gone.example.com": "vanished",
+ "ok.example.com": "",
+ } {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: env,
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+ lastGood := "LOG_LEVEL=debug\nDB_URL=postgres://last-good\n"
+ path := filepath.Join(envDir, "gone.example.com.env")
+ if err := os.WriteFile(path, []byte(lastGood), 0o600); err != nil {
+ t.Fatalf("stage last-good env file: %v", err)
+ }
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat staged file: %v", err)
+ }
+
+ var restarted []string
+ if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("last-good env file gone after Sync: %v", err)
+ }
+ if string(got) != lastGood {
+ t.Fatalf("env file = %q, want it untouched (%q)", got, lastGood)
+ }
+ now, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat after Sync: %v", err)
+ }
+ if !now.ModTime().Equal(fi.ModTime()) {
+ t.Fatalf("env file of a route with a missing environment was rewritten")
+ }
+ if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
+ t.Fatalf("a temp file was written for a route whose environment is missing, stat err = %v", err)
+ }
+ for _, h := range restarted {
+ if h == "gone.example.com" {
+ t.Fatalf("restarted a route whose bound environment is missing: %v", restarted)
+ }
+ }
+ // The sibling route must be entirely unaffected by the other's failure.
+ sibling, err := os.ReadFile(filepath.Join(envDir, "ok.example.com.env"))
+ if err != nil {
+ t.Fatalf("sibling route not materialized: %v", err)
+ }
+ if string(sibling) != "LOG_LEVEL=info\n" {
+ t.Fatalf("sibling env = %q, want LOG_LEVEL=info", sibling)
+ }
+ if len(restarted) != 1 || restarted[0] != "ok.example.com" {
+ t.Fatalf("restarted = %v, want exactly [ok.example.com]", restarted)
+ }
+}
+
+func TestSyncDoesNotReapEnvFileWhenEnvironmentIsDeleted(t *testing.T) {
+ // Reaping is keyed on ROUTES, never on environments: deleting a bundle
+ // must not make a live route's env file look orphaned.
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "bound.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: "deleted-env",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ path := filepath.Join(envDir, "bound.example.com.env")
+ if err := os.WriteFile(path, []byte("KEEP=1\n"), 0o600); err != nil {
+ t.Fatalf("stage env file: %v", err)
+ }
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("env file reaped after its environment was deleted: %v", err)
+ }
+ if string(got) != "KEEP=1\n" {
+ t.Fatalf("env file = %q, want it untouched", got)
+ }
+}
+
+// Review CRITICAL 1: binding an environment must not be a way to replace a
+// hand-staged production env file. The unbound path is protected by
+// getIfExists ("no config.json at all ⇒ leave the file alone"); a bound route
+// resolves through LoadMerged, which treats a missing default as an empty
+// base — so without this guard, the documented migration step (create a small
+// override bundle, bind the routes) replaces every var the staged file held
+// with just the override's.
+func TestSyncRefusesToReplaceStagedFileWhenAppHasNoDefaultBundle(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ // An override bundle only — the app has never had a config.json.
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "debug"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ for host, env := range map[string]string{
+ "prod.example.com": "demo",
+ "fresh.example.com": "demo",
+ } {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: env,
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+ staged := "DB_URL=postgres://prod\nSTRIPE_KEY=sk_live\nSMTP_HOST=mail\nLOG_LEVEL=info\n"
+ path := filepath.Join(envDir, "prod.example.com.env")
+ if err := os.WriteFile(path, []byte(staged), 0o600); err != nil {
+ t.Fatalf("stage env file: %v", err)
+ }
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat staged file: %v", err)
+ }
+
+ var restarted []string
+ if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("staged env file gone after Sync: %v", err)
+ }
+ if string(got) != staged {
+ t.Fatalf("staged env file = %q, want it untouched (%q)", got, staged)
+ }
+ if now, err := os.Stat(path); err != nil || !now.ModTime().Equal(fi.ModTime()) {
+ t.Fatalf("staged env file was rewritten (stat err = %v)", err)
+ }
+ for _, h := range restarted {
+ if h == "prod.example.com" {
+ t.Fatalf("restarted a route whose staged file was left in place: %v", restarted)
+ }
+ }
+ // The route with nothing staged has nothing to lose: environment-only
+ // render proceeds for it, in the same pass.
+ fresh, err := os.ReadFile(filepath.Join(envDir, "fresh.example.com.env"))
+ if err != nil {
+ t.Fatalf("fresh route not materialized: %v", err)
+ }
+ if string(fresh) != "LOG_LEVEL=debug\n" {
+ t.Fatalf("fresh route env = %q, want LOG_LEVEL=debug", fresh)
+ }
+ if len(restarted) != 1 || restarted[0] != "fresh.example.com" {
+ t.Fatalf("restarted = %v, want exactly [fresh.example.com]", restarted)
+ }
+}
+
+// Round-2 review, IMPORTANT: the no-default guard must not freeze a route on
+// the file SYNC ITSELF wrote. "A non-empty file exists" is not evidence a
+// human staged it — after pass 1 it is evidence of pass 1 — so the guard is
+// keyed on KEY LOSS instead: it refuses only when the file on disk holds keys
+// the merged render would drop. Same keys with different values, and supersets
+// of what is on disk, converge freely.
+func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ // No config.json for this app, ever — only a named bundle.
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "debug"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "fresh.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: "demo",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ var restarted []string
+ restart := func(h string) error { restarted = append(restarted, h); return nil }
+ if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ t.Fatalf("Sync (pass 1): %v", err)
+ }
+ path := filepath.Join(envDir, "fresh.example.com.env")
+ if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\n" {
+ t.Fatalf("pass 1: env = %q, err = %v; want LOG_LEVEL=debug", got, err)
+ }
+
+ // The operator edits the bundle: one value changed, one key added.
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "trace"},
+ "NEW": {Kind: "env", Value: "1"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment (edit): %v", err)
+ }
+ restarted = nil
+ if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ t.Fatalf("Sync (pass 2): %v", err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("pass 2: read env: %v", err)
+ }
+ if string(got) != "LOG_LEVEL=trace\nNEW=1\n" {
+ t.Fatalf("pass 2: env = %q, want the edited bundle to converge", got)
+ }
+ if len(restarted) != 1 || restarted[0] != "fresh.example.com" {
+ t.Fatalf("pass 2: restarted = %v, want exactly [fresh.example.com]", restarted)
+ }
+}
+
+// A file this pass cannot parse is content whose keys are unknown, so it is
+// impossible to prove nothing would be lost — assume the worst and refuse,
+// same direction as the guard's other cases.
+func TestSyncRefusesToReplaceAnUnparseableFileWhenAppHasNoDefaultBundle(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "debug"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "odd.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: "demo",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ junk := "this file is not KEY=value at all\n"
+ path := filepath.Join(envDir, "odd.example.com.env")
+ if err := os.WriteFile(path, []byte(junk), 0o600); err != nil {
+ t.Fatalf("stage junk env file: %v", err)
+ }
+ var restarted []string
+ if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read env: %v", err)
+ }
+ if string(got) != junk {
+ t.Fatalf("env = %q, want an unparseable file left untouched", got)
+ }
+ if len(restarted) != 0 {
+ t.Fatalf("restarted = %v, want none", restarted)
+ }
+}
+
+// Known limitation of the key-loss gate, pinned so it is not a surprise:
+// while an app has NO default bundle, REMOVING a key from its environment
+// bundle looks exactly like the truncation hazard (a key on disk that the
+// render drops) and is refused. The refusal names both escapes — put the
+// remaining config in a default bundle, or delete the file. It self-heals the
+// moment a default bundle exists, which is the migration this guard is for.
+func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "KEEP": {Kind: "env", Value: "1"},
+ "GONE": {Kind: "env", Value: "2"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "shrink.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: "demo",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ t.Fatalf("Sync (pass 1): %v", err)
+ }
+ path := filepath.Join(envDir, "shrink.example.com.env")
+ if _, err := UnsetEnvironment(ctx, s, "bab", "keymail", "demo", []string{"GONE"}); err != nil {
+ t.Fatalf("UnsetEnvironment: %v", err)
+ }
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ t.Fatalf("Sync (pass 2): %v", err)
+ }
+ if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\n" {
+ t.Fatalf("env = %q, want the removal refused while the app has no default bundle", got)
+ }
+ // The moment a default bundle exists the app is bucket-managed and the
+ // merged render is authoritative — the removal lands, no operator
+ // intervention beyond the import the refusal asked for.
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"KEEP": "1"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ t.Fatalf("Sync (pass 3): %v", err)
+ }
+ if got, _ := os.ReadFile(path); string(got) != "KEEP=1\n" {
+ t.Fatalf("env = %q, want the removal to land once a default bundle exists", got)
+ }
+}
+
+func TestSyncMergedRenderIsAuthoritativeOnceADefaultBundleExists(t *testing.T) {
+ // The other side of the guard above: once the app HAS a config.json —
+ // even one with every var unset — the operator has adopted bucket config
+ // for it, and the merged render is authoritative over whatever is on
+ // disk. Anything else would freeze a migrated app's file forever.
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"SEED": "1"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, err := Unset(ctx, s, "bab", "keymail", []string{"SEED"}); err != nil {
+ t.Fatalf("Unset: %v", err) // object now exists with zero vars
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "LOG_LEVEL": {Kind: "env", Value: "debug"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "prod.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: "demo",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ path := filepath.Join(envDir, "prod.example.com.env")
+ if err := os.WriteFile(path, []byte("DB_URL=postgres://prod\n"), 0o600); err != nil {
+ t.Fatalf("stage env file: %v", err)
+ }
+
+ var restarted []string
+ if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read env: %v", err)
+ }
+ if string(got) != "LOG_LEVEL=debug\n" {
+ t.Fatalf("env = %q, want the merged render to win once a default bundle exists", got)
+ }
+ if len(restarted) != 1 || restarted[0] != "prod.example.com" {
+ t.Fatalf("restarted = %v, want exactly [prod.example.com]", restarted)
+ }
+}
+
+func TestSyncEnvironmentOnlyAppWithNoDefaultBundle(t *testing.T) {
+ // A named environment does not require a default bundle to exist:
+ // LoadMerged resolves to the environment alone, and Sync must converge it
+ // (the object exists, so this is a configured app, not an unconfigured one).
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "ONLY": {Kind: "env", Value: "here"},
+ }, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ for _, host := range []string{"demo.example.com", "emptyfile.example.com"} {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit, Environment: "demo",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+ // An EMPTY file on disk is not staged config — there is nothing in it to
+ // lose, so the no-default guard (CRITICAL 1) must not freeze it.
+ if err := os.WriteFile(filepath.Join(envDir, "emptyfile.example.com.env"), nil, 0o600); err != nil {
+ t.Fatalf("stage empty env file: %v", err)
+ }
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ for _, host := range []string{"demo.example.com", "emptyfile.example.com"} {
+ got, err := os.ReadFile(filepath.Join(envDir, host+".env"))
+ if err != nil {
+ t.Fatalf("%s: read rendered env: %v", host, err)
+ }
+ if string(got) != "ONLY=here\n" {
+ t.Fatalf("%s: rendered env = %q, want ONLY=here", host, got)
+ }
+ }
+}
+
func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) {
// One app, two simultaneous routes (e.g. a stable route and a canary
// route are two different Hosts) — the shared config must land in
diff --git a/internal/console/icons.go b/internal/console/icons.go
index 0956bb164..6e5a22618 100644
--- a/internal/console/icons.go
+++ b/internal/console/icons.go
@@ -77,6 +77,13 @@ const (
iconTriangleAlert template.HTML = iconOpen +
`<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>` +
`<path d="M12 9v4"/><path d="M12 17h.01"/></svg>`
+
+ // A route's bound environment, beside the rung it is listed under
+ // (Lucide: layers).
+ iconLayers template.HTML = iconOpen +
+ `<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/>` +
+ `<path d="m6.08 9.5-3.5 1.6a1 1 0 0 0 0 1.81l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83l-3.5-1.59"/>` +
+ `<path d="m6.08 14.5-3.5 1.6a1 1 0 0 0 0 1.81l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83l-3.5-1.59"/></svg>`
)
// icons maps each Lucide slug to its vendored markup. Slugs are the Lucide
@@ -88,6 +95,7 @@ var icons = map[string]template.HTML{
"arrow-down": iconArrowDown,
"flask-conical": iconFlask,
"triangle-alert": iconTriangleAlert,
+ "layers": iconLayers,
}
// icon renders one chrome icon by its Lucide slug: {{icon "chevron-down"}}.
diff --git a/internal/console/ladder.go b/internal/console/ladder.go
index cb05575a0..2d7e290a9 100644
--- a/internal/console/ladder.go
+++ b/internal/console/ladder.go
@@ -9,12 +9,16 @@ package console
// reads — the app's channel pointers and its registry routes — so it can
// disagree with neither.
//
-// Two things it deliberately is not. It is not a second promote path: the
-// controls post to the app's one promote endpoint, which enforces the ladder
-// and the passkey gate for everybody, so a hand-written POST meets the same
-// doors as a button. And it is not an environment view — environments do not
-// exist yet (spec §4, deferred), which is why the one place this file guesses
-// at "production" is quarantined in prodWiredCanaryHosts below.
+// One thing it deliberately is not: a second promote path. The controls post
+// to the app's one promote endpoint, which enforces the ladder and the
+// passkey gate for everybody, so a hand-written POST meets the same doors as
+// a button.
+//
+// Every route also shows the environment it is wired to (spec §4a) beside
+// the channel it is listed under — "default" for a route with no binding —
+// and prodWiredCanaryHosts below answers "production" from that AND from
+// which host is the app's apex: the environment says what glue a canary is
+// wired to, the apex says whether it is in front of everybody.
import (
"sort"
@@ -59,10 +63,15 @@ type LadderChannel struct {
// LadderRoute is one host under the channel it follows.
type LadderRoute struct {
Host string
+ // Environment is the named config bundle this route is wired to — ""
+ // (rendered "default" in ladder.html) means the app's default bundle
+ // alone. Independent of the channel it is listed under (spec §4a).
+ Environment string
// Canary is whether this host follows a canary channel at all.
Canary bool
- // ProdWired is the conspicuous case: a canary in front of everybody. See
- // prodWiredCanaryHosts for exactly how much this claim is worth.
+ // ProdWired is the conspicuous case: a canary bound to the same
+ // environment as one of the app's stable routes, OR sitting on the app's
+ // apex host. See prodWiredCanaryHosts for why it is both.
ProdWired bool
}
@@ -104,9 +113,10 @@ func assembleLadder(channels []ChannelView, routes []RouteView) []LadderRung {
for _, rt := range routes {
rung, ok := release.Rung(rt.Channel)
byChannel[rt.Channel] = append(byChannel[rt.Channel], LadderRoute{
- Host: rt.Host,
- Canary: ok && rung == release.RungCanary,
- ProdWired: prod[rt.Host],
+ Host: rt.Host,
+ Environment: rt.Environment,
+ Canary: ok && rung == release.RungCanary,
+ ProdWired: prod[rt.Host],
})
}
entries := map[string][]LadderChannel{}
@@ -174,27 +184,42 @@ func stepUp(from, to string, chans []LadderChannel) *LadderStep {
return s
}
-// prodWiredCanaryHosts is THE INFERENCE, and it is alone in this file on
-// purpose.
+// prodWiredCanaryHosts is spec §4a's honest definition of "prod-wired",
+// replacing the apex-host guess this function used before named
+// environments existed (2026-08-02-pipelines-design.md §4a, last
+// paragraph): a canary/* route is prod-wired when it binds the SAME
+// ENVIRONMENT as the app's stable route — an unbound route binds "" (the
+// default bundle), so two unbound routes count as matching, exactly as the
+// spec says "or both bind default".
//
-// Spec §5 wants a canary wired to a production environment flagged
-// conspicuously, because an unreviewed build serving everybody must not be
-// discoverable only by reading the registry. But environments (spec §4) do
-// not exist yet — they are a subsystem with their own plan — so there is
-// nothing on a route that says "production" today.
+// An app can have more than one stable-channel route (e.g. two hosts both
+// following `stable`); this treats stable's set of bound environments as
+// "production", so a canary matching ANY of them is flagged. An app with no
+// stable route at all has no production environment to be wired in front of,
+// so that signal flags nothing — the correct answer, not a gap.
//
-// Until there is, production is inferred from the route being the app's
-// PRIMARY host: the apex it is reached at, picked as fewest labels, then
-// shortest, then alphabetical. A canary on `keymail.dev` is in front of
-// everybody; a canary on `rc.keymail.dev` is a side door somebody opened on
-// purpose. It is a heuristic and it is allowed to be wrong in both directions
-// — it flags, it never restricts.
+// The apex-host heuristic this replaced is KEPT as a second signal, OR'd in
+// (2026-08-02 review, IMPORTANT 5). The two answer different halves of §5's
+// worry and neither is a superset of the other: the environment set says
+// "wired to production GLUE", the apex host says "in front of production
+// TRAFFIC". A canary on the app's apex bound to `sandbox` serves everybody
+// and binds nothing production — the environment rule alone would say
+// nothing about it, which is a warning the console used to give and would
+// have silently stopped giving. Production is the app's PRIMARY host: fewest
+// labels, then shortest, then alphabetical.
//
-// WHEN ENVIRONMENTS LAND: delete the heuristic and answer from the route's
-// environment instead. This function is the whole of what has to change; its
-// callers only ever ask "is this host a prod-wired canary".
+// It flags, it never restricts: callers only ever ask "is this host a
+// prod-wired canary", and both signals are allowed to be wrong in the
+// flagging direction. More flags, never fewer, is the conservative side of
+// this particular trade.
func prodWiredCanaryHosts(routes []RouteView) map[string]bool {
- primary := ""
+ stableEnvs := map[string]bool{}
+ for _, rt := range routes {
+ if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable {
+ stableEnvs[rt.Environment] = true
+ }
+ }
+ apex := ""
better := func(a, b string) bool {
la, lb := strings.Count(a, "."), strings.Count(b, ".")
if la != lb {
@@ -209,16 +234,17 @@ func prodWiredCanaryHosts(routes []RouteView) map[string]bool {
if rt.Host == "" {
continue
}
- if primary == "" || better(rt.Host, primary) {
- primary = rt.Host
+ if apex == "" || better(rt.Host, apex) {
+ apex = rt.Host
}
}
out := map[string]bool{}
for _, rt := range routes {
- if rt.Host != primary {
+ rung, ok := release.Rung(rt.Channel)
+ if !ok || rung != release.RungCanary {
continue
}
- if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungCanary {
+ if stableEnvs[rt.Environment] || rt.Host == apex {
out[rt.Host] = true
}
}
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index b9bac411a..4742efb0d 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -209,33 +209,151 @@ func TestLadderRefusesASkip(t *testing.T) {
}
}
-// A canary is badged; a canary on the app's primary host is badged
-// unmissably, because that is an unreviewed build serving everybody
-// (spec §5). Both badges are chrome, so both are icons, not glyphs.
+// A canary is badged; a canary that binds the SAME ENVIRONMENT as the app's
+// stable route is badged unmissably, because that is an unreviewed build
+// wired to production glue (spec §4a, §5) — whichever host it happens to be
+// on. A canary bound to a genuinely different environment is the ordinary,
+// unremarkable case. Both badges are chrome, so both are icons, not glyphs.
func TestProdWiredCanaryIsBadgedDistinctly(t *testing.T) {
fx := newConsoleFixture(t)
- // The app's apex host follows a canary channel — and a side host follows
- // the same one, which is the ordinary, unremarkable case.
- fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
- Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9001"})
+ // The fixture's own kass.carlosapp.dev is already on stable, unbound
+ // (Environment ""). A canary that is ALSO unbound binds the same
+ // environment as stable ("default" both times) and is prod-wired; one
+ // bound to a real, different environment is not — regardless of which
+ // host either is on.
fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"})
+ fx.addRoute(t, registry.Route{Host: "side.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9003"})
c := signInPaul(t, fx)
_, _, body := get(t, c, fx.u("/apps/kass"))
block := rungBlock(t, body, "canary")
- apex := routeLine(t, block, "kass.carlosapp.dev")
- if !strings.Contains(apex, "chip-prod") {
- t.Errorf("the app's primary host on a canary channel is not flagged:\n%s", apex)
+ sameEnv := routeLine(t, block, "rc.kass.carlosapp.dev")
+ if !strings.Contains(sameEnv, "chip-prod") {
+ t.Errorf("a canary binding the same (default) environment as stable is not flagged:\n%s", sameEnv)
}
- side := routeLine(t, block, "rc.kass.carlosapp.dev")
- if !strings.Contains(side, "chip-canary") {
- t.Errorf("a canary route carries no canary badge:\n%s", side)
+ diffEnv := routeLine(t, block, "side.kass.carlosapp.dev")
+ if !strings.Contains(diffEnv, "chip-canary") {
+ t.Errorf("a canary route carries no canary badge:\n%s", diffEnv)
}
- if strings.Contains(side, "chip-prod") {
- t.Errorf("a side canary is flagged as production-wired:\n%s", side)
+ if strings.Contains(diffEnv, "chip-prod") {
+ t.Errorf("a canary bound to a DIFFERENT environment than stable is flagged as production-wired:\n%s", diffEnv)
}
- if !strings.Contains(apex, "<svg") || !strings.Contains(side, "<svg") {
+ if !strings.Contains(sameEnv, "<svg") || !strings.Contains(diffEnv, "<svg") {
t.Error("the badges are text glyphs, not icons")
}
}
+
+// A canary on the app's APEX host serves everybody, whatever it binds — so
+// it stays flagged even when its environment differs from stable's (review
+// IMPORTANT 5). The environment set-membership rule is the honest definition
+// of "wired to production glue"; the apex signal is the honest definition of
+// "in front of production traffic". Losing the second one when the first
+// landed would have shipped a silent reduction in warning coverage.
+func TestProdWiredCanaryOnTheApexHostIsFlaggedWhateverItBinds(t *testing.T) {
+ fx := newConsoleFixture(t)
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Put a canary on the apex host, bound to an environment nothing on
+ // stable binds; docs.kass.carlosapp.dev stays as the app's stable route.
+ if err := reg.Delete("kass.carlosapp.dev"); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9007"})
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ block := rungBlock(t, body, "canary")
+ line := routeLine(t, block, "kass.carlosapp.dev")
+ if !strings.Contains(line, "chip-prod") {
+ t.Errorf("a canary on the apex host is not flagged prod-wired:\n%s", line)
+ }
+}
+
+// No stable route at all means no production environment to be wired in
+// front of — a canary is never flagged by the ENVIRONMENT signal in that
+// case, whatever it binds.
+func TestProdWiredCanaryNeedsAStableRouteToCompareAgainst(t *testing.T) {
+ fx := newConsoleFixture(t)
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Remove the fixture's two stable-channel kass routes so the app has no
+ // stable route left to compare a canary against.
+ if err := reg.Delete("kass.carlosapp.dev"); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Delete("docs.kass.carlosapp.dev"); err != nil {
+ t.Fatal(err)
+ }
+ // A non-canary route holds the apex, so the OTHER prod-wired signal (the
+ // apex-host one, review IMPORTANT 5) cannot fire and this test stays
+ // about the environment rule alone.
+ fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "beta", Addr: "127.0.0.1:9005"})
+ fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9004"})
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ block := rungBlock(t, body, "canary")
+ line := routeLine(t, block, "rc.kass.carlosapp.dev")
+ if strings.Contains(line, "chip-prod") {
+ t.Errorf("a canary is flagged prod-wired on an app with no stable route:\n%s", line)
+ }
+}
+
+// Every route on the page shows the environment it is bound to, beside the
+// channel it is listed under — "default" for an unbound route, the bundle
+// name for a bound one (spec §4a, plan Task 5).
+func TestLadderShowsEachRoutesEnvironment(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "eu.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v1", App: "kass", Channel: "stable", Environment: "eu-prod", Addr: "127.0.0.1:9011"})
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ block := rungBlock(t, body, "stable")
+
+ unbound := routeLine(t, block, "kass.carlosapp.dev")
+ if !strings.Contains(unbound, "chip-env") || !strings.Contains(unbound, "default") {
+ t.Errorf("an unbound route does not show \"default\" as its environment:\n%s", unbound)
+ }
+ bound := routeLine(t, block, "eu.kass.carlosapp.dev")
+ if !strings.Contains(bound, "chip-env") || !strings.Contains(bound, "eu-prod") {
+ t.Errorf("a bound route does not show its environment name:\n%s", bound)
+ }
+}
+
+// Visibility, not permission (spec §5): a member sees the same environment
+// and prod-wired chrome an owner does. Only the promote control is
+// owner-gated — see TestLadderPromoteControlIsOwnerOnly.
+func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"})
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-envs")
+ status, _, body := get(t, cm, fx.u("/apps/kass"))
+ if status != http.StatusOK {
+ t.Fatalf("member app page = %d, want 200", status)
+ }
+ canaryBlock := rungBlock(t, body, "canary")
+ line := routeLine(t, canaryBlock, "rc.kass.carlosapp.dev")
+ if !strings.Contains(line, "chip-prod") {
+ t.Errorf("a member does not see the prod-wired badge:\n%s", line)
+ }
+ stableBlock := rungBlock(t, body, "stable")
+ if !strings.Contains(stableBlock, "chip-env") {
+ t.Errorf("a member does not see route environment chrome:\n%s", stableBlock)
+ }
+}
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index b7ade3e01..2f8da0342 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -22,6 +22,7 @@ touching what any host is wired to.</p>
<ul class="routes">
{{range .Routes}}
<li><a href="https://{{.Host}}">{{.Host}}</a>
+ <span class="chip chip-env">{{icon "layers"}} {{if .Environment}}{{.Environment}}{{else}}default{{end}}</span>
{{if .ProdWired}}<span class="chip chip-prod">{{icon "triangle-alert"}} canary in front of everybody</span>
{{else if .Canary}}<span class="chip chip-canary">{{icon "flask-conical"}} canary</span>{{end}}
</li>
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index e79152697..8d4185ec2 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -146,6 +146,10 @@
every user is the one thing on this page a reader must not skim past. */
.chip-canary { border: 1px solid var(--accent); color: var(--accent); }
.chip-prod { background: var(--bad); color: var(--bg); }
+ /* A route's bound environment — a fact, not a warning, so it is bordered
+ like .chip-canary but in a neutral tone: it sits on every route, not
+ just the ones worth a second look. */
+ .chip-env { border: 1px solid var(--rule); color: var(--muted); }
.chip .icon { vertical-align: -0.1em; margin-right: 0.2em; }
.chip-up { background: var(--accent-soft); color: #2f7a4f; }
.chip-down { background: var(--accent-soft); color: var(--bad); }
diff --git a/internal/console/view.go b/internal/console/view.go
index 76fb54068..4da58704c 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -107,6 +107,11 @@ type CanaryView struct {
type RouteView struct {
Host, Kind, Status, Version, Channel string
Hibernate bool
+ // Environment is the named config bundle this route is wired to — ""
+ // means the app's default bundle alone (registry.Route's own
+ // convention; see internal/appconfig.LoadMerged). Rendered as "default"
+ // rather than left blank, in ladder.html.
+ Environment string
}
// ChannelView is one row of the channels table.
@@ -178,6 +183,18 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
if err != nil {
return nil, err
}
+ // byApp is keyed on app NAME with no account filter, so two accounts that
+ // happen to name an app the same way share these buckets — pre-existing
+ // (Host/Version/Channel/Status already cross that boundary, which is why
+ // the RouteAccount mismatch machinery below exists) and gated: the app
+ // page runs session → membership → HasAppIn → byte-identical 404 before
+ // reaching here, so this is not a non-member leak. Recorded because the
+ // blast radius grew rather than the gate weakening (2026-08-02 review,
+ // MINOR 8): the Environment chip now shows one tenant's bundle NAMES to
+ // the other, and prodWiredCanaryHosts' stable-environment set reads
+ // across the same boundary, so one tenant's stable route can make
+ // another's canary badge prod-wired. The fix is at this source — filter
+ // by release.RouteAccount(rt) == acct — not further downstream.
byApp := map[string][]RouteView{}
domainsByApp := map[string][]DomainView{}
// What the ROUTES say each app's account is. A disagreement with the
@@ -199,6 +216,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
byApp[rt.App] = append(byApp[rt.App], RouteView{
Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,
+ Environment: rt.Environment,
})
domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs))
}
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index b41aadae8..fe691bea7 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -505,6 +505,20 @@ func firstReadyUpstream(routes []registry.Route, k appKey) *registry.Route {
// CHANNEL POINTER to find the shipped tar, so a channel-less static mint
// would serve nothing. Hence the asymmetry — same field, opposite verdicts,
// for reasons that live one layer down in internal/edge.
+//
+// Environment is never set here, by design (2026-08-02-plan-environments.md
+// Task 2): reconcile does not derive an environment binding for a minted
+// route, so `carlos route -environment` is allowed on a plain Platform apex.
+// It is NOT allowed on a Platform+Canary route (platformCanaryOwnedRow,
+// cli.go) — not because this function derives Environment, but because if
+// such a route's `followed` check (below) goes false again, the Upsert(nr)
+// that follows rebuilds the whole row from this function's values. That is
+// the same zero-value-on-re-mint caveat runRoute documents for
+// Channel/Version/Status/DNS/Cert. Since the 2026-08-02 review,
+// registry.Upsert preserves a non-empty existing environment through exactly
+// this path (IMPORTANT 3), so a binding is no longer silently lost here —
+// but a row this function rebuilds is still not one the route verb hands an
+// operator, which is why the CLI refusal exists on top of the preservation.
func mintedRoute(host, app, account string, src registry.Route) registry.Route {
rt := registry.Route{
Host: host,
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 7ee21ff91..d3431f19e 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -45,6 +45,29 @@ type Route struct {
Pool string // placement pool ("" = shared)
Region string // residency region code ("" = unconstrained)
Channel string // release channel this route follows ("" = stable)
+ // Environment is the named config bundle this route is wired to ("" =
+ // the app's default bundle, config.json, alone — see
+ // internal/appconfig.LoadMerged). Independent of Channel: channel is
+ // how mature a build is, environment is what glue it's wired to
+ // (2026-08-02-pipelines-design.md §2/§4a).
+ //
+ // Written INTENTIONALLY only by `carlos route -environment` (cli.go's
+ // runRoute), through the narrow setters below (SetEnvironment,
+ // SetEnvironments, SetChannelsAndEnvironments). An earlier revision of
+ // this comment claimed that was the only writer at all; it was wrong —
+ // every full-row Upsert (`carlos add`, reconcile.go's two mint paths)
+ // also writes this column, and did so with the zero value, silently
+ // unbinding a route on re-add or re-mint (2026-08-02 review, IMPORTANT
+ // 3). Upsert now preserves a non-empty existing value instead; see its
+ // doc comment. Clearing therefore has exactly one door: `-environment -`.
+ //
+ // Platform+Canary routes are refused by `carlos route -environment`
+ // (platformCanaryOwnedRow, cli.go) for the same structural reason
+ // Channel is: the reconciler owns those rows and re-Upserts them
+ // whenever its `followed` check goes false. Upsert's preservation makes
+ // that survivable rather than silent, but a column the reconciler
+ // rebuilds is not one this verb should be handing an operator.
+ Environment string
Hibernate bool
Canary bool
@@ -155,6 +178,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN dns_checked_at INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN cert_expires_at INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN account TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN environment TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -320,7 +344,7 @@ func (r *Registry) Accounts() ([]Account, error) {
const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
replication, next_due, backing, status_named, platform, dns_status,
-dns_checked_at, cert_expires_at, account, created_at`
+dns_checked_at, cert_expires_at, account, environment, created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -329,7 +353,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool,
&t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
&sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
- &t.CreatedAt)
+ &t.Environment, &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -389,7 +413,17 @@ func (r *Registry) Delete(host string) error {
return err
}
-// Upsert inserts or updates a route by host.
+// Upsert inserts or updates a route by host. One column is deliberately not
+// a plain overwrite: `environment` keeps the existing row's value when the
+// incoming Route carries the zero value (2026-08-02 review, IMPORTANT 3).
+// Every full-row Upsert caller — `carlos add` and the domains reconciler's
+// two mint paths — builds its Route without knowing anything about
+// environment bindings, so a plain excluded.environment silently unbound a
+// route on any re-add or re-mint, and the next config sync then rendered the
+// default bundle over that route's env file and restarted its unit. An
+// explicit non-empty value still wins, and clearing goes through
+// SetEnvironment (what `carlos route -environment -` calls), which is the
+// only place a clear is ever intended.
func (r *Registry) Upsert(t Route) error {
if t.CreatedAt == 0 {
t.CreatedAt = time.Now().Unix()
@@ -408,7 +442,7 @@ func (r *Registry) Upsert(t Route) error {
}
_, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr, status=excluded.status, last_error=excluded.last_error,
@@ -420,12 +454,13 @@ ON CONFLICT(host) DO UPDATE SET
backing=excluded.backing, status_named=excluded.status_named,
platform=excluded.platform, dns_status=excluded.dns_status,
dns_checked_at=excluded.dns_checked_at,
- cert_expires_at=excluded.cert_expires_at, account=excluded.account`,
+ cert_expires_at=excluded.cert_expires_at, account=excluded.account,
+ environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,
b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt,
- t.CertExpiresAt, t.Account, t.CreatedAt)
+ t.CertExpiresAt, t.Account, t.Environment, t.CreatedAt)
return err
}
@@ -505,6 +540,114 @@ func (r *Registry) SetChannels(hosts []string, channel string) error {
return tx.Commit()
}
+// SetEnvironment repoints a route's named-environment binding as a narrow
+// UPDATE — same rationale and same "error on an unmatched host" contract as
+// SetChannel (a human types a hostname into `carlos route -environment`
+// directly, and a silent no-op on a typo would be worse than a refusal).
+// env == "" clears the binding back to the app's default bundle alone; the
+// CLI's "-environment -" sentinel resolves to that before calling here, so
+// this function itself has no notion of "clear" beyond writing "".
+func (r *Registry) SetEnvironment(host, env string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET environment = ? WHERE host = ?`, env, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
+// SetEnvironments is SetEnvironment's bulk sibling, mirroring SetChannels:
+// one transaction across every host in hosts, so `carlos route -app
+// -environment` can never leave an app split across two environments the
+// moment one route mid-batch fails — the caller (cli.go's runRoute) runs
+// every route's existence-rail check to completion FIRST, and only once
+// every route has cleared it does it call this to write.
+func (r *Registry) SetEnvironments(hosts []string, env string) error {
+ if len(hosts) == 0 {
+ return nil
+ }
+ tx, err := r.sql.Begin()
+ if err != nil {
+ return err
+ }
+ stmt, err := tx.Prepare(`UPDATE routes SET environment = ? WHERE host = ?`)
+ if err != nil {
+ tx.Rollback()
+ return err
+ }
+ for _, h := range hosts {
+ res, err := stmt.Exec(env, h)
+ if err != nil {
+ stmt.Close()
+ tx.Rollback()
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ stmt.Close()
+ tx.Rollback()
+ return err
+ }
+ if n == 0 {
+ stmt.Close()
+ tx.Rollback()
+ return fmt.Errorf("no route for host %q", h)
+ }
+ }
+ stmt.Close()
+ return tx.Commit()
+}
+
+// SetChannelsAndEnvironments is SetChannels and SetEnvironments' combined
+// sibling for `carlos route -channel X -environment Y`: BOTH columns, for
+// every host in hosts, in ONE transaction — so a combined move can never
+// commit the channel repoint and then fail the environment repoint (or vice
+// versa), leaving a route split between the two writes the caller asked for
+// as a single atomic change. Same all-or-nothing contract as its two
+// single-column siblings; the caller (runRoute) has already run both rails'
+// checks to completion before this is called.
+func (r *Registry) SetChannelsAndEnvironments(hosts []string, channel, env string) error {
+ if len(hosts) == 0 {
+ return nil
+ }
+ tx, err := r.sql.Begin()
+ if err != nil {
+ return err
+ }
+ stmt, err := tx.Prepare(`UPDATE routes SET channel = ?, environment = ? WHERE host = ?`)
+ if err != nil {
+ tx.Rollback()
+ return err
+ }
+ for _, h := range hosts {
+ res, err := stmt.Exec(channel, env, h)
+ if err != nil {
+ stmt.Close()
+ tx.Rollback()
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ stmt.Close()
+ tx.Rollback()
+ return err
+ }
+ if n == 0 {
+ stmt.Close()
+ tx.Rollback()
+ return fmt.Errorf("no route for host %q", h)
+ }
+ }
+ stmt.Close()
+ return tx.Commit()
+}
+
// SetDNSStatus stamps a route's DNS health verdict + check time, narrowly
// (same rationale as SetAccount; this lands every few minutes on every host).
func (r *Registry) SetDNSStatus(host, status string, checkedAt int64) error {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index c6a5ebe9d..7a943f0fd 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -63,7 +63,8 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
Status: "ready", LastError: "prior fail",
DBPath: "/data/b.db", Version: "abc1234", PrevVersion: "def5678",
App: "kass", Pool: "shared", Region: "ie", Channel: "stable",
- Hibernate: true, Canary: true, Replication: "batched",
+ Environment: "demo",
+ Hibernate: true, Canary: true, Replication: "batched",
NextDue: 4200, Backing: BackingUnit, CreatedAt: 1700000000,
}
if err := reg.Upsert(want); err != nil {
@@ -553,3 +554,210 @@ func TestSetChannelsEmptyIsNoop(t *testing.T) {
t.Fatalf("SetChannels(nil, ...): %v, want no error", err)
}
}
+
+// SetEnvironment mirrors SetChannel exactly: a narrow UPDATE (so a
+// read-modify-write Upsert racing SetVersions/SetNextDue/SetChannel can
+// never clobber this column or be clobbered by it), erroring on an unknown
+// host so `carlos route -environment` can name it.
+func TestSetEnvironmentNarrow(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "demo.example.com", Kind: "instance", App: "tito",
+ Channel: "stable", Version: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetEnvironment("demo.example.com", "demo"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("demo.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Environment != "demo" {
+ t.Fatalf("environment = %q, want demo", got.Environment)
+ }
+ if got.App != "tito" || got.Version != "v1" || got.Channel != "stable" {
+ t.Fatalf("SetEnvironment clobbered unrelated columns: %+v", got)
+ }
+}
+
+func TestSetEnvironmentUnknownHostErrors(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetEnvironment("nope.example.com", "demo"); err == nil {
+ t.Fatal("SetEnvironment on an unknown host: got nil error, want one")
+ }
+}
+
+// SetEnvironment("", ...) clears back to default — a plain UPDATE to "" is
+// exactly that; no separate "clear" verb is needed at the registry layer,
+// only at the CLI layer (`-environment -`).
+func TestSetEnvironmentEmptyClearsToDefault(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "demo.example.com", Kind: "instance", App: "tito"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetEnvironment("demo.example.com", "demo"); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetEnvironment("demo.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("demo.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Environment != "" {
+ t.Fatalf("environment = %q, want empty (default) after clearing", got.Environment)
+ }
+}
+
+// SetEnvironments is the bulk sibling of SetEnvironment — one transaction
+// across every matched route, same all-or-nothing contract as SetChannels.
+func TestSetEnvironmentsAllOrNothing(t *testing.T) {
+ reg := openTestRegistry(t)
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "tito"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := reg.SetEnvironments([]string{"a.example.com", "nosuch.example.com", "b.example.com"}, "demo")
+ if err == nil {
+ t.Fatal("SetEnvironments with an unknown host in the batch: got nil error, want one")
+ }
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ got, gerr := reg.Get(h)
+ if gerr != nil {
+ t.Fatal(gerr)
+ }
+ if got.Environment != "" {
+ t.Fatalf("%s environment = %q after a failed batch, want untouched \"\" — the batch was not atomic", h, got.Environment)
+ }
+ }
+}
+
+func TestSetEnvironmentsUpdatesEveryHost(t *testing.T) {
+ reg := openTestRegistry(t)
+ for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
+ if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "tito"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := reg.SetEnvironments([]string{"a.example.com", "b.example.com", "c.example.com"}, "demo"); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
+ got, err := reg.Get(h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Environment != "demo" {
+ t.Fatalf("%s environment = %q, want demo", h, got.Environment)
+ }
+ }
+}
+
+func TestSetEnvironmentsEmptyIsNoop(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetEnvironments(nil, "demo"); err != nil {
+ t.Fatalf("SetEnvironments(nil, ...): %v, want no error", err)
+ }
+}
+
+// SetChannelsAndEnvironments is the combined write path `carlos route
+// -channel X -environment Y` uses: one transaction sets BOTH columns for
+// every host, so a channel-and-environment move can never leave a route
+// repointed on one axis but not the other — the same all-or-nothing
+// contract as SetChannels/SetEnvironments, but spanning two columns instead
+// of one.
+func TestSetChannelsAndEnvironmentsWritesBothColumnsAtomically(t *testing.T) {
+ reg := openTestRegistry(t)
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "tito", Channel: "canary/x"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := reg.SetChannelsAndEnvironments([]string{"a.example.com", "b.example.com"}, "stable", "demo"); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ got, err := reg.Get(h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Channel != "stable" || got.Environment != "demo" {
+ t.Fatalf("%s = channel %q environment %q, want stable/demo", h, got.Channel, got.Environment)
+ }
+ }
+}
+
+func TestSetChannelsAndEnvironmentsAllOrNothing(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "tito", Channel: "canary/x"}); err != nil {
+ t.Fatal(err)
+ }
+ err := reg.SetChannelsAndEnvironments([]string{"a.example.com", "nosuch.example.com"}, "stable", "demo")
+ if err == nil {
+ t.Fatal("SetChannelsAndEnvironments with an unknown host in the batch: got nil error, want one")
+ }
+ got, err := reg.Get("a.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Channel != "canary/x" || got.Environment != "" {
+ t.Fatalf("a.example.com = channel %q environment %q after a failed batch, want both untouched", got.Channel, got.Environment)
+ }
+}
+
+// Review IMPORTANT 3: Upsert must not clear an existing row's Environment.
+// `carlos add` (and any other full-row Upsert caller) does not know about
+// environment bindings and passes the zero value, so a re-run on a bound host
+// used to silently unbind it — and the next config sync then re-rendered the
+// default bundle over that route's env file and restarted the unit.
+func TestUpsertPreservesAnExistingEnvironmentBinding(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := Route{Host: "demo.example.com", Kind: "instance", App: "tito", Status: "ready"}
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetEnvironment("demo.example.com", "demo"); err != nil {
+ t.Fatal(err)
+ }
+ // The re-add: same row, no Environment in hand.
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("demo.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Environment != "demo" {
+ t.Fatalf("Environment = %q after a re-Upsert, want the binding preserved", got.Environment)
+ }
+ // An explicit non-empty value still wins — preservation is a floor, not a
+ // freeze, so a caller that genuinely knows the binding can still set it.
+ base.Environment = "staging"
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("demo.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Environment != "staging" {
+ t.Fatalf("Environment = %q, want an explicit Upsert value to win", got.Environment)
+ }
+ // Clearing stays possible, through the narrow setter the clear verb uses
+ // (`carlos route -environment -`), which is the only intentional clear.
+ if err := reg.SetEnvironment("demo.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("demo.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Environment != "" {
+ t.Fatalf("Environment = %q after an explicit clear, want it cleared", got.Environment)
+ }
+}
diff --git a/main.go b/main.go
index 056a62c44..379e3e791 100644
--- a/main.go
+++ b/main.go
@@ -80,11 +80,16 @@ commands:
agent run edge + activator + release adoption on this host
add | remove | instances manage routes
route -host <h> | -app <a> [-account <sqid|name|all>]
- -channel <c> [-dry-run]: change which release
- channel a route follows (-account defaults to
- ops; -app is all-or-nothing and skips
- platform-minted routes; refuses a version
- change unless -allow-version-change is passed)
+ [-channel <c>] [-environment <name>] [-dry-run]:
+ change which release channel and/or named
+ config environment a route follows — at least
+ one of -channel/-environment is required, both
+ may be combined (-account defaults to ops;
+ -app is all-or-nothing and skips
+ platform-minted routes; -channel refuses a
+ version change unless -allow-version-change is
+ passed; -environment refuses a bundle that
+ does not exist yet, and "-" clears to default)
auth login | whoami | logout — who this terminal is
(headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive