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>
19 files changed,
+3149
−254
appconfig_cli.go+158 −23cli.go+320 −102e2e_test.go+602 −0internal/appconfig/environments.go+194 −0internal/appconfig/environments_test.go+333 −0internal/appconfig/store.go+86 −32internal/appconfig/store_test.go+49 −1internal/appconfig/sync.go+214 −34internal/appconfig/sync_test.go+588 −2internal/console/icons.go+8 −0internal/console/ladder.go+58 −32internal/console/ladder_test.go+134 −16internal/console/templates/ladder.html+1 −0internal/console/templates/layout.html+4 −0internal/console/view.go+18 −0internal/domains/reconcile.go+14 −0internal/registry/registry.go+149 −6internal/registry/registry_test.go+209 −1main.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 |