route: Route.Environment + carlos route -environment binding
Task 2 of the environments plan (pipelines slice 2). Additive registry column environment TEXT NOT NULL DEFAULT '' (registry.migrate's ALTER list), with SetEnvironment (narrow UPDATE mirroring SetChannel), SetEnvironments (bulk sibling of SetChannels), and SetChannelsAndEnvironments (one transaction writing both columns, for the combined-flags invocation). runRoute grows -environment alongside -channel: at least one of the two is now required (previously -channel alone was mandatory); either flag works alone, or combined. The environment rail is an existence check against the bucket via appconfig.ListEnvironments (same Store the channel rail uses, same fail-closed stance on a missing store), refusing a bind to a bundle that doesn't exist and naming `carlos env set -app <a> -environment <name> ...` as the fix; "-environment -" clears to default with no bucket check. Pass 1 resolves both rails independently before any write; a refusal on EITHER axis aborts the WHOLE batch (single-host or -app bulk) before EITHER writes, and a combined invocation commits both columns in one transaction. Verified against internal/domains/reconcile.go: mintedRoute never sets Environment, so -environment binding is allowed on Platform-minted routes (including Platform+Canary, unlike -channel, which platformCanaryOwnsChannel refuses there) — documented at mintedRoute and in runRoute's doc comment, alongside the existing Channel/Version/Status/DNS/Cert note about re-mint reverting a Platform+Canary route to mintedRoute's zero values, which now includes Environment too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+871
−103
cli.go+268 −92e2e_test.go+296 −0internal/domains/reconcile.go+12 −0internal/registry/registry.go+131 −5internal/registry/registry_test.go+154 −1main.go+10 −5
diff --git a/cli.go b/cli.go| index f393bf274..29d56746a 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,16 @@ 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. |
| +// blanking version/status/dns/cert/ENVIRONMENT columns to mintedRoute's |
| +// zero values (mintedRoute never sets Environment — see its doc comment). |
| // platformCanaryOwnsChannel 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. |
| +// This refusal is CHANNEL-specific, though: -environment on its own is |
| +// allowed on ANY Platform route, including Platform+Canary, because |
| +// reconcile does not derive Environment the way it derives Channel for |
| +// static mints — the only thing that can still blank it is a Channel change |
| +// on the SAME route later reviving the revert above. |
| // (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 +246,46 @@ 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 == "-" |
| + if *environment != "" && !envClear && !appconfig.ValidEnvironmentName(*environment) { |
| + log.Fatalf("route: %q is not a valid environment name", *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 { |
| @@ -290,10 +338,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 +350,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 +367,141 @@ 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 platformCanaryOwnsChannel(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 |
| + ) |
| + 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() |
| - } |
| + // existsByAcct caches one ListEnvironments call per distinct account |
| + // among the candidates (an -app -account all sweep can span several), |
| + // 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). |
| + existsByAcct := 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 envClear: |
| + envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite} |
| default: |
| - verdicts[i].kind = vUnproven |
| + acct := release.RouteAccount(rt) |
| + names, cached := existsByAcct[acct] |
| + 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 |
| + } |
| + existsByAcct[acct] = 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,29 +522,82 @@ 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)) |
| + } |
| + } |
| 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) |
| } |
| } |
diff --git a/e2e_test.go b/e2e_test.go| index 5bd2f63ea..b28ab5116 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -1833,6 +1833,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 +2223,271 @@ 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{"default", "Has-Upper", "has_underscore"} { |
| + 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) |
| + } |
| + } |
| +} |
| + |
| +// -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. |
| +func TestRouteEnvironmentAllowedOnPlatformCanaryRoute(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: %v\n%s", err, out) |
| + } |
| + if got := f.environmentOf("preview.site.bab.apps.example.com"); got != "demo" { |
| + t.Fatalf("environment = %q, want demo", got) |
| + } |
| + // Its channel is untouched, since -channel was not passed. |
| + if got := f.channelOf("preview.site.bab.apps.example.com"); got != "canary/preview" { |
| + t.Fatalf("channel = %q, want untouched canary/preview", 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/domains/reconcile.go b/internal/domains/reconcile.go| index b41aadae8..b9c49609a 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -505,6 +505,18 @@ 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 Platform routes, |
| +// unlike Channel, which platformCanaryOwnsChannel refuses on a |
| +// Platform+Canary route because THIS function re-derives Channel for static |
| +// mints. The same zero-value-on-re-mint caveat cli.go's runRoute documents |
| +// for Channel/Version/Status/DNS/Cert applies here too, though: if a |
| +// Platform+Canary route's `followed` check (below) goes false again, the |
| +// Upsert(nr) that follows rebuilds the row from this function's zero |
| +// values, silently clearing a previously-bound Environment along with |
| +// everything else that isn't copied above. |
| 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..5e0795124 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -45,6 +45,22 @@ 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). Set only by `carlos route |
| + // -environment` (cli.go's runRoute) — reconcile.go's mintedRoute never |
| + // sets this field, so a platform-minted route's Environment starts at |
| + // its zero value "" and IS eligible for `-environment` binding (unlike |
| + // Channel, which platformCanaryOwnsChannel refuses on a Platform+Canary |
| + // route). One caveat inherited from that same column: if a |
| + // Platform+Canary route's `followed` check ever goes false again, |
| + // reconcile's Upsert(nr) re-derives every column from mintedRoute's |
| + // zero values, Environment included — so a bound environment on such a |
| + // route is subject to the identical silent-revert risk runRoute's doc |
| + // comment already documents for Channel/Version/Status/DNS/Cert. |
| + Environment string |
| Hibernate bool |
| Canary bool |
| @@ -155,6 +171,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 +337,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 +346,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 |
| @@ -408,7 +425,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 +437,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=excluded.environment`, |
| 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 +523,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..e24c52951 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,155 @@ 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) |
| + } |
| +} |
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 |