carlos / platform Public

route: a verb to move a route's channel subscription, safely

A route's channel column could only be changed with raw sqlite against a
production registry — the exact hand-edit this platform exists to
eliminate, and the classifier rightly blocked. `carlos route -host|-app
… -channel …` replaces it, and the safety rail is the point: a route
repoints only when the target channel serves the version the route is
already running, or the operator states the version change they intend
with -allow-version-change.

Sonnet built it; Opus reviewed it twice and earned its cost both times:

- Bulk (-app) is all-checks-then-one-transaction — a refusal on any
  route writes nothing, because a half-repointed app is worse than
  either whole state.
- The override covers exactly one fact: the target resolves to a
  DIFFERENT version. An unresolvable target, a missing pubkey and a bad
  signature refuse unconditionally — none of them proves the target
  resolves to anything, and a typo'd channel written with exit 0 is a
  route adoption silently never updates again.
- Platform+Canary routes own nothing: reconcile re-derives their channel
  and would revert a hand write while blanking version/status/dns/cert —
  the same two-owners-one-field class as this morning's DNS incident,
  refused here outright.
- The rail compares via the same RouteAccount + release.Channel pair the
  adoption pass uses, so it sees exactly what the runtime sees, legacy
  fallback included. A route with no stamped version (static apps)
  compares its current channel's pointer against the target's instead.

Rebased over the env/secrets landing (#9); both sessions appended their
e2e test at the same seam, resolved by keeping each whole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell de07dac9e01e926697837c1bf4ba1ef33354fcee parent 68d0662
5 files changed, +1254 −0
  • cli.go +323 −0
  • e2e_test.go +764 −0
  • internal/registry/registry.go +68 −0
  • internal/registry/registry_test.go +91 −0
  • main.go +8 −0
diff --git a/cli.go b/cli.go
index 59bc6d033..f393bf274 100644
--- a/cli.go
+++ b/cli.go
@@ -163,6 +163,329 @@ 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.
+//
+// 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."
+//
+// -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.
+//
+// A Platform+Canary route's Channel is a harder boundary: the domains
+// reconciler's canary-minting pass (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 (a claim this comment got wrong in an earlier
+// revision; re-review NEW 1 caught it). So ANY change to a Platform+Canary
+// route's channel — not only a move off canary/*, moving BETWEEN two
+// 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
+// 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.
+// (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
+// make that canary's own `followed` check false, which mints a fresh canary
+// host next pass. Additive, not reverted, but worth knowing.)
+func runRoute(args []string) {
+ fs := flag.NewFlagSet("route", flag.ExitOnError)
+ regPath := fs.String("registry", defaultRegistry(), "registry database")
+ host := fs.String("host", "", "the one route to repoint")
+ 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")
+ 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]"
+ 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 !release.ValidChannel(*channel) {
+ log.Fatalf("route: %q is not a valid channel", *channel)
+ }
+
+ r, err := registry.Open(*regPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer r.Close()
+
+ var candidates []registry.Route
+ if *host != "" {
+ rt, err := r.Get(*host)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if rt == nil {
+ log.Fatalf("route: no route for host %q", *host)
+ }
+ candidates = []registry.Route{*rt}
+ } else {
+ routes, err := r.List()
+ if err != nil {
+ log.Fatal(err)
+ }
+ var wantAcct string
+ if !strings.EqualFold(*acctFilter, "all") {
+ wantAcct = resolveAccount(*regPath, *acctFilter)
+ }
+ for _, rt := range routes {
+ if rt.App != *app {
+ continue
+ }
+ if wantAcct != "" && release.RouteAccount(rt) != wantAcct {
+ continue
+ }
+ if rt.Platform {
+ // A bulk sweep changing every tenant's platform-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. (Platform+Canary routes are
+ // refused even via -host — see platformCanaryOwnsChannel,
+ // 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.)
+ fmt.Printf("%s: skipped — platform-minted route; use -host %s to change it explicitly\n", rt.Host, rt.Host)
+ continue
+ }
+ candidates = append(candidates, rt)
+ }
+ if len(candidates) == 0 {
+ log.Fatalf("route: no non-platform routes for app %q (with the given -account filter, if any)", *app)
+ }
+ }
+
+ // Pass 1: resolve every candidate's rail 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.
+ //
+ // The Platform+Canary refusal is decided HERE, for every candidate,
+ // rather than only in the -host branch above — a shared function both
+ // forms go through — so it holds structurally even though only -host can
+ // 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/*.
+ type verdictKind int
+ const (
+ vNeedsCheck verdictKind = iota // routeVersionCheck resolves this below
+ vNoop // already on the target channel
+ vSafe // resolved: proceed unconditionally
+ vVersionChange // resolved, differs: overridable
+ vUnproven // resolved, but nothing proves what would change: never overridable
+ vPlatformOwned // Platform+Canary: reconciler owns this column, never overridable
+ )
+ type verdict struct {
+ rt registry.Route
+ from 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
+ }
+ }
+ 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 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
+ switch {
+ case safe:
+ verdicts[i].kind = vSafe
+ case overridable:
+ verdicts[i].kind = vVersionChange
+ default:
+ verdicts[i].kind = vUnproven
+ }
+ }
+ }
+
+ // Pass 2: any refusal aborts the WHOLE batch — print every one, write
+ // nothing.
+ var refusals []string
+ var toWrite []string
+ for _, v := range verdicts {
+ switch v.kind {
+ case vNoop:
+ // handled in the final print loop
+ case vSafe:
+ toWrite = append(toWrite, v.rt.Host)
+ case vVersionChange:
+ if *allowVersionChange {
+ toWrite = append(toWrite, v.rt.Host)
+ } else {
+ refusals = append(refusals, fmt.Sprintf("%s: %s (use -allow-version-change to override)", v.rt.Host, v.note))
+ }
+ case vUnproven:
+ refusals = append(refusals, fmt.Sprintf(
+ "%s: %s — refusing regardless of -allow-version-change: the comparison itself is unproven", v.rt.Host, v.note))
+ case vPlatformOwned:
+ refusals = append(refusals, fmt.Sprintf(
+ "%s: %s — refusing regardless of -allow-version-change: no flag overrides a column 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))
+ 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)
+ }
+ }
+ 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
+ }
+ 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 {
+ return rt.Platform && rt.Canary
+}
+
+// routeVersionCheck resolves the rail for one route: does moving it from
+// fromChannel to toChannel change what gets served?
+//
+// - safe=true: the comparison resolved and the two sides agree — proceed
+// unconditionally, nothing changes.
+// - safe=false, overridable=true: the comparison resolved and the two
+// sides genuinely disagree — -allow-version-change is the intended door.
+// - safe=false, overridable=false: nothing here PROVES what would change
+// (no pubkey, an unresolvable channel, a bad signature) — never
+// overridable.
+//
+// rt.Version — stamped by the last adopt pass — is the honest "what is this
+// route actually serving" for an instance route. SetVersions is only ever
+// called for Kind == instance, though, so a static route (or an instance
+// never yet adopted) always has Version == "" — comparing that against a
+// resolved target would either garble the message or force
+// -allow-version-change unconditionally. For those, the only available proxy
+// is what the route's CURRENT channel resolves to, compared against the
+// target — both sides read through the identical release.Channel path the
+// runtime itself uses.
+func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct string, rt registry.Route, fromChannel, toChannel string) (note string, safe, overridable bool) {
+ if pub == nil {
+ return "cannot verify what the target channel serves: CARLOS_RELEASE_PUBKEY is not set", false, false
+ }
+ target, err := release.Channel(ctx, store, pub, acct, rt.App, toChannel)
+ if err != nil {
+ return fmt.Sprintf("target channel %s (%s) does not resolve: %v",
+ toChannel, release.ChannelKey(acct, rt.App, toChannel), err), false, false
+ }
+ if rt.Version != "" {
+ if target.Version == rt.Version {
+ return fmt.Sprintf("version unchanged: %s", target.Version), true, false
+ }
+ return fmt.Sprintf("version changing: %s -> %s (route is running %s, %s currently serves %s)",
+ rt.Version, target.Version, rt.Version, toChannel, target.Version), false, true
+ }
+ current, err := release.Channel(ctx, store, pub, acct, rt.App, fromChannel)
+ if err != nil {
+ return fmt.Sprintf("route has no recorded version, and its current channel %s (%s) does not resolve\n"+
+ "either, so there is nothing to compare against: %v",
+ fromChannel, release.ChannelKey(acct, rt.App, fromChannel), err), false, false
+ }
+ if current.Version == target.Version {
+ return fmt.Sprintf("version unchanged: %s (route has no adopted Version; compared %s's pointer against %s's)",
+ target.Version, fromChannel, toChannel), true, false
+ }
+ return fmt.Sprintf("version changing: %s -> %s (route has no adopted Version; %s currently serves %s, %s currently serves %s)",
+ current.Version, target.Version, fromChannel, current.Version, toChannel, target.Version), false, true
+}
+
// accountFlags registers the -account (and its -registry seam) every app verb
// carries. -account takes a sqid OR a name, resolved against the registry's
// materialized accounts; it defaults to the built-in ops account, so a
diff --git a/e2e_test.go b/e2e_test.go
index 9b81a25e3..5bd2f63ea 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -1431,3 +1431,767 @@ func TestSecretsCLI(t *testing.T) {
t.Fatalf("decrypted secret = %q, want s3kr1t", plain)
}
}
+
+// 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
+// target channel currently serves the exact version the route is running, or
+// when the operator explicitly says -allow-version-change — because a
+// mismatch means the next adopt pass changes what production serves, and
+// that must never happen by accident (the keymail.dev canary/keymail ->
+// stable move this verb was built for).
+func TestRouteCLI(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")
+ regPath := filepath.Join(tmp, "registry.db")
+ store := release.NewDirStore(storeDir)
+ ctx := context.Background()
+
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ env := append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+storeDir,
+ "CARLOS_RELEASE_PUBKEY="+pubB64)
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+ ship := func(store release.Store, acct, app, version string) {
+ t.Helper()
+ m := release.Manifest{Schema: 1, App: app, Version: version, Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}}}
+ if err := release.Ship(ctx, store, acct, m, map[string][]byte{"linux-amd64": []byte(version + " bytes")}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ promote := func(acct, app, channel, version string) {
+ t.Helper()
+ if err := release.Promote(ctx, store, priv, acct, app, channel, version); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // keymail.dev: canary/keymail and stable both already hold 5457379 — the
+ // no-op-safe case.
+ ship(store, release.OpsAccount, "keymail", "5457379")
+ promote(release.OpsAccount, "keymail", "canary/keymail", "5457379")
+ promote(release.OpsAccount, "keymail", "stable", "5457379")
+ if err := reg.Upsert(registry.Route{Host: "keymail.dev", Kind: "instance",
+ App: "keymail", Channel: "canary/keymail", Version: "5457379", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // widget: canary/widget (what the route is running) and stable (the
+ // target) disagree — the refusal case.
+ ship(store, release.OpsAccount, "widget", "v1")
+ ship(store, release.OpsAccount, "widget", "v2")
+ promote(release.OpsAccount, "widget", "canary/widget", "v1")
+ promote(release.OpsAccount, "widget", "stable", "v2")
+ if err := reg.Upsert(registry.Route{Host: "widget.example.com", Kind: "instance",
+ App: "widget", Channel: "canary/widget", Version: "v1", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ reg.Close()
+
+ t.Run("unknown host is a hard error naming the host", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "nosuch.example.com", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("unknown host was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "nosuch.example.com") {
+ t.Errorf("refusal does not name the host:\n%s", out)
+ }
+ })
+
+ t.Run("neither -host nor -app is a usage error", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-channel", "stable")
+ if err == nil {
+ t.Fatalf("neither -host nor -app was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "usage") {
+ t.Errorf("refusal is not a usage message:\n%s", out)
+ }
+ })
+
+ t.Run("-host and -app together is an error", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-app", "keymail", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("both -host and -app was accepted:\n%s", out)
+ }
+ })
+
+ t.Run("empty -channel explains the difference from route creation", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-channel", "")
+ if err == nil {
+ t.Fatalf("empty -channel 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)
+ }
+ })
+
+ t.Run("invalid channel is refused", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-channel", "not a channel")
+ if err == nil {
+ t.Fatalf("invalid channel was accepted:\n%s", out)
+ }
+ })
+
+ t.Run("versions match: proceeds and says so", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("route: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "canary/keymail -> stable") || !strings.Contains(out, "version unchanged: 5457379") {
+ t.Errorf("success line missing expected shape:\n%s", out)
+ }
+ r, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ rt, err := r.Get("keymail.dev")
+ if err != nil || rt == nil || rt.Channel != "stable" {
+ t.Fatalf("route channel = %+v, %v, want stable", rt, err)
+ }
+ })
+
+ t.Run("versions differ: refused, both versions printed", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "widget.example.com", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("a differing-version repoint was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "v1") || !strings.Contains(out, "v2") {
+ t.Errorf("refusal does not print both versions:\n%s", out)
+ }
+ if !strings.Contains(out, "-allow-version-change") {
+ t.Errorf("refusal does not name the override:\n%s", out)
+ }
+ r, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ rt, err := r.Get("widget.example.com")
+ if err != nil || rt == nil || rt.Channel != "canary/widget" {
+ t.Fatalf("route channel changed despite the refusal: %+v, %v", rt, err)
+ }
+ })
+
+ t.Run("-allow-version-change overrides", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-host", "widget.example.com",
+ "-channel", "stable", "-allow-version-change")
+ if err != nil {
+ t.Fatalf("route -allow-version-change: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "canary/widget -> stable") || !strings.Contains(out, "v1") || !strings.Contains(out, "v2") {
+ t.Errorf("success line does not show the version change:\n%s", out)
+ }
+ r, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ rt, err := r.Get("widget.example.com")
+ if err != nil || rt == nil || rt.Channel != "stable" {
+ t.Fatalf("route channel = %+v, %v, want stable", rt, err)
+ }
+ })
+
+ t.Run("-app form moves every matching route", func(t *testing.T) {
+ ship(store, release.OpsAccount, "fleet", "f1")
+ promote(release.OpsAccount, "fleet", "canary/platform-adoption", "f1")
+ promote(release.OpsAccount, "fleet", "stable", "f1")
+ r, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Upsert(registry.Route{Host: "fleet-a.example.com", Kind: "instance",
+ App: "fleet", Channel: "canary/platform-adoption", Version: "f1", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Upsert(registry.Route{Host: "fleet-b.example.com", Kind: "instance",
+ App: "fleet", Channel: "canary/platform-adoption", Version: "f1", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ r.Close()
+
+ out, err := run("route", "-registry", regPath, "-app", "fleet", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("route -app: %v\n%s", err, out)
+ }
+ for _, host := range []string{"fleet-a.example.com", "fleet-b.example.com"} {
+ if !strings.Contains(out, host) {
+ t.Errorf("output does not mention %s:\n%s", host, out)
+ }
+ }
+ r2, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r2.Close()
+ for _, host := range []string{"fleet-a.example.com", "fleet-b.example.com"} {
+ rt, err := r2.Get(host)
+ if err != nil || rt == nil || rt.Channel != "stable" {
+ t.Fatalf("%s channel = %+v, %v, want stable", host, rt, err)
+ }
+ }
+ })
+
+ t.Run("-app form with zero matching routes errors, naming the app", func(t *testing.T) {
+ out, err := run("route", "-registry", regPath, "-app", "nosuchapp", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("zero matching routes was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "nosuchapp") {
+ t.Errorf("refusal does not name the app:\n%s", out)
+ }
+ })
+
+ t.Run("-app form optionally filtered by -account", func(t *testing.T) {
+ accOut, err := run("accounts", "create", "-registry", 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]
+
+ ship(store, release.OpsAccount, "acctapp", "a1")
+ promote(release.OpsAccount, "acctapp", "canary/x", "a1")
+ promote(release.OpsAccount, "acctapp", "stable", "a1")
+ ship(store, sqid, "acctapp", "a1")
+ promote(sqid, "acctapp", "canary/x", "a1")
+ promote(sqid, "acctapp", "stable", "a1")
+
+ r, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Upsert(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance",
+ App: "acctapp", Account: release.OpsAccount, Channel: "canary/x", Version: "a1", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Upsert(registry.Route{Host: "second-acctapp.example.com", Kind: "instance",
+ App: "acctapp", Account: sqid, Channel: "canary/x", Version: "a1", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ r.Close()
+
+ out, err := run("route", "-registry", regPath, "-app", "acctapp", "-channel", "stable", "-account", sqid)
+ if err != nil {
+ t.Fatalf("route -app -account: %v\n%s", err, out)
+ }
+ if strings.Contains(out, "ops-acctapp.example.com") {
+ t.Errorf("-account filter still touched the ops route:\n%s", out)
+ }
+ if !strings.Contains(out, "second-acctapp.example.com") {
+ t.Errorf("-account filter missed the target route:\n%s", out)
+ }
+
+ r2, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r2.Close()
+ if rt, err := r2.Get("ops-acctapp.example.com"); err != nil || rt == nil || rt.Channel != "canary/x" {
+ t.Fatalf("unfiltered ops route changed: %+v, %v", rt, err)
+ }
+ if rt, err := r2.Get("second-acctapp.example.com"); err != nil || rt == nil || rt.Channel != "stable" {
+ t.Fatalf("filtered route did not change: %+v, %v", rt, err)
+ }
+ })
+}
+
+// routeTestFixture builds a carlos binary once and gives each caller a fresh
+// dir-store + registry + signing key to drive it against — the shared setup
+// under the fix-round-1 regression tests below (review findings on
+// route-verb, 2026-08-02).
+type routeTestFixture struct {
+ t *testing.T
+ bin string
+ storeDir string
+ regPath string
+ store release.Store
+ priv ed25519.PrivateKey
+ pubB64 string
+}
+
+func newRouteTestFixture(t *testing.T) *routeTestFixture {
+ t.Helper()
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ bin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", bin, ".")
+ 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")
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &routeTestFixture{
+ t: t, bin: bin, storeDir: storeDir, regPath: filepath.Join(tmp, "registry.db"),
+ store: release.NewDirStore(storeDir), priv: priv, pubB64: pubB64,
+ }
+}
+
+// run invokes carlos with env built from base (nil = the full, correct env:
+// deployment dir + pubkey), so a test can knock one variable out to exercise
+// a fail-closed path.
+func (f *routeTestFixture) run(env []string, args ...string) (string, error) {
+ f.t.Helper()
+ cmd := exec.Command(f.bin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func (f *routeTestFixture) fullEnv() []string {
+ return append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+f.storeDir,
+ "CARLOS_RELEASE_PUBKEY="+f.pubB64)
+}
+
+func (f *routeTestFixture) ship(acct, app, version string) {
+ f.t.Helper()
+ m := release.Manifest{Schema: 1, App: app, Version: version, Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}}}
+ if err := release.Ship(context.Background(), f.store, acct, m, map[string][]byte{"linux-amd64": []byte(version + " bytes")}); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+func (f *routeTestFixture) promote(acct, app, channel, version string) {
+ f.t.Helper()
+ if err := release.Promote(context.Background(), f.store, f.priv, acct, app, channel, version); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+func (f *routeTestFixture) addRoute(rt registry.Route) {
+ f.t.Helper()
+ reg, err := registry.Open(f.regPath)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ defer reg.Close()
+ if err := reg.Upsert(rt); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+func (f *routeTestFixture) channelOf(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.Channel
+}
+
+// CRITICAL 2 (review): a no-pubkey run must refuse — never a blind write.
+func TestRouteRefusesWithoutPubkey(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "demo", "v1")
+ f.promote(release.OpsAccount, "demo", "canary/x", "v1")
+ f.promote(release.OpsAccount, "demo", "stable", "v1")
+ f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance",
+ App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"})
+
+ // -allow-version-change is included deliberately (re-review NEW 2): the
+ // three refusal tests in this file previously never passed it, so
+ // nothing pinned that a missing pubkey stays refused EVEN WITH the
+ // override — which is the entire content of the CRITICAL 2 widening.
+ // Mutation-proven gap: flipping the pub==nil branch back to
+ // overridable=true left the whole suite green until this flag was added
+ // here.
+ env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+f.storeDir) // no CARLOS_RELEASE_PUBKEY
+ out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com",
+ "-channel", "stable", "-allow-version-change")
+ if err == nil {
+ t.Fatalf("route with no CARLOS_RELEASE_PUBKEY was accepted even with -allow-version-change:\n%s", out)
+ }
+ if !strings.Contains(out, "CARLOS_RELEASE_PUBKEY") {
+ t.Errorf("refusal does not name the missing var:\n%s", out)
+ }
+ if got := f.channelOf("demo.example.com"); got != "canary/x" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// CRITICAL 2 / IMPORTANT 6: no deployment store configured must also refuse,
+// before any write — openStore's existing log.Fatal, pinned so a refactor
+// that hoists or short-circuits it would be caught.
+func TestRouteRefusesWithoutStore(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "demo", "v1")
+ f.promote(release.OpsAccount, "demo", "canary/x", "v1")
+ f.promote(release.OpsAccount, "demo", "stable", "v1")
+ f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance",
+ App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"})
+
+ env := append(os.Environ(), "CARLOS_RELEASE_PUBKEY="+f.pubB64) // no store env at all
+ out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("route with no deployment store configured was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "CARLOS_DEPLOYMENT_BUCKET") && !strings.Contains(out, "CARLOS_DEPLOYMENT_DIR") {
+ t.Errorf("refusal does not name the missing store env:\n%s", out)
+ }
+ if got := f.channelOf("demo.example.com"); got != "canary/x" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// IMPORTANT 6: a pointer that fails signature verification (signed by a
+// DIFFERENT key than CARLOS_RELEASE_PUBKEY names) must refuse, not be
+// treated as absent or as trusted.
+func TestRouteRefusesOnBadSignature(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "demo", "v1")
+ f.promote(release.OpsAccount, "demo", "canary/x", "v1")
+ f.promote(release.OpsAccount, "demo", "stable", "v1")
+ f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance",
+ App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"})
+
+ _, otherPub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // -allow-version-change included for the same reason as
+ // TestRouteRefusesWithoutPubkey (re-review NEW 2): a bad signature is a
+ // worse state than an unresolvable target (suspect, not merely unknown),
+ // and must refuse even with the override.
+ env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+f.storeDir, "CARLOS_RELEASE_PUBKEY="+otherPub)
+ out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com",
+ "-channel", "stable", "-allow-version-change")
+ if err == nil {
+ t.Fatalf("route with a wrong-key pubkey was accepted even with -allow-version-change:\n%s", out)
+ }
+ if !strings.Contains(out, "signature") {
+ t.Errorf("refusal does not mention signature verification:\n%s", out)
+ }
+ if got := f.channelOf("demo.example.com"); got != "canary/x" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// CRITICAL 2: an unresolvable target channel is refused UNCONDITIONALLY —
+// -allow-version-change only ever means "the target resolves and holds a
+// different version, do it anyway", never "skip finding out". A typo'd
+// channel name must never be write-able by any flag.
+func TestRouteAllowVersionChangeNeverOverridesUnresolvableTarget(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "demo", "v1")
+ f.promote(release.OpsAccount, "demo", "canary/x", "v1")
+ f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance",
+ App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"})
+ // "stable" is never promoted — the target has no pointer at all.
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com",
+ "-channel", "stable", "-allow-version-change")
+ if err == nil {
+ t.Fatalf("an unresolvable target was accepted even with -allow-version-change:\n%s", out)
+ }
+ if !strings.Contains(out, "does not resolve") {
+ t.Errorf("refusal does not say the target is unresolvable:\n%s", out)
+ }
+ if got := f.channelOf("demo.example.com"); got != "canary/x" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// CRITICAL 1: the -app bulk form must be all-or-nothing. One route that
+// fails the rail must leave EVERY route in the batch untouched, and the
+// output must name every failure, not just the first.
+func TestRouteAppBulkIsAllOrNothing(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "fleet", "v1")
+ f.ship(release.OpsAccount, "fleet", "v2")
+ f.promote(release.OpsAccount, "fleet", "canary/platform-adoption", "v1")
+ f.promote(release.OpsAccount, "fleet", "stable", "v1")
+ // Three routes agree with the target (stable == v1); one (fleet-c) is
+ // running v9, which stable does not serve.
+ for _, h := range []string{"fleet-a.example.com", "fleet-b.example.com", "fleet-d.example.com"} {
+ f.addRoute(registry.Route{Host: h, Kind: "instance", App: "fleet",
+ Channel: "canary/platform-adoption", Version: "v1", Status: "ready"})
+ }
+ f.addRoute(registry.Route{Host: "fleet-c.example.com", Kind: "instance", App: "fleet",
+ Channel: "canary/platform-adoption", Version: "v9", Status: "ready"})
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath, "-app", "fleet", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("a batch with one failing route was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "fleet-c.example.com") {
+ t.Errorf("output does not name the failing route:\n%s", out)
+ }
+ for _, h := range []string{"fleet-a.example.com", "fleet-b.example.com", "fleet-c.example.com", "fleet-d.example.com"} {
+ if got := f.channelOf(h); got != "canary/platform-adoption" {
+ t.Errorf("%s channel = %q after a refused batch — the batch was not atomic (want it untouched)", h, got)
+ }
+ }
+}
+
+// CRITICAL 3: a route the domains reconciler minted (Platform: true) is
+// excluded from the -app bulk form, with a reason printed, but -host may
+// still target it explicitly — reconcile only stamps a Platform route's
+// Channel ONCE at mint time and never re-derives it on a later pass (see
+// domains/reconcile.go's byHost/"already minted" idempotency guards), so
+// this verb changing it is not fighting reconcile for ownership.
+func TestRouteAppBulkExcludesPlatformRoutesButHostAllowsExplicit(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "site", "v1")
+ f.promote(release.OpsAccount, "site", "canary/x", "v1")
+ f.promote(release.OpsAccount, "site", "stable", "v1")
+ f.addRoute(registry.Route{Host: "site.example.com", Kind: "static", App: "site",
+ Channel: "canary/x", Status: "ready"}) // custom domain: NOT platform-minted
+ f.addRoute(registry.Route{Host: "site.bab.apps.example.com", Kind: "static", App: "site",
+ Channel: "canary/x", Status: "ready", Platform: true}) // platform-minted apex
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath, "-app", "site", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("route -app: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "site.bab.apps.example.com") || !strings.Contains(out, "skip") {
+ t.Errorf("output does not say the platform route was skipped:\n%s", out)
+ }
+ if got := f.channelOf("site.example.com"); got != "stable" {
+ t.Fatalf("the non-platform route did not move: %q", got)
+ }
+ if got := f.channelOf("site.bab.apps.example.com"); got != "canary/x" {
+ t.Fatalf("the -app bulk form touched a platform route: %q", got)
+ }
+
+ // -host, naming it explicitly, is allowed.
+ out, err = f.run(env, "route", "-registry", f.regPath, "-host", "site.bab.apps.example.com", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("route -host on a platform route: %v\n%s", err, out)
+ }
+ if got := f.channelOf("site.bab.apps.example.com"); got != "stable" {
+ t.Fatalf("explicit -host did not move the platform route: %q", got)
+ }
+}
+
+// CRITICAL 3 / re-review NEW 1: a Platform+Canary route must never have its
+// channel changed AT ALL by this verb — not just "moved off canary/*". The
+// domains reconciler's canary-minting loop (reconcile.go) is idempotent via
+// `followed`, a check derived from the route's OWN Channel column, not from
+// the `byHost` map it also sets but never reads for that purpose. So ANY
+// channel change on a Platform+Canary route (including canary/a ->
+// canary/b) makes `followed` false for the channel it just left; the next
+// reconcile pass re-Upserts the SAME host from `firstReadyUpstream`, which
+// silently reverts this verb's write and blanks version/status/dns/cert
+// columns to mintedRoute's zero values. Refuses even via explicit -host and
+// even with -allow-version-change — there is no override for a column this
+// verb does not own.
+func TestRoutePlatformCanaryNeverLeavesCanary(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "site", "v1")
+ f.promote(release.OpsAccount, "site", "canary/preview", "v1")
+ f.promote(release.OpsAccount, "site", "stable", "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})
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath,
+ "-host", "preview.site.bab.apps.example.com", "-channel", "stable", "-allow-version-change")
+ if err == nil {
+ t.Fatalf("moving a Platform+Canary route off canary/* was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "canary") {
+ t.Errorf("refusal does not explain the canary retirement hazard:\n%s", out)
+ }
+ if got := f.channelOf("preview.site.bab.apps.example.com"); got != "canary/preview" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// re-review NEW 1, the reachable gap: moving a Platform+Canary route BETWEEN
+// two canary channels (not off canary/* at all) was previously accepted,
+// because the round-1 guard only fired when the target left the canary/*
+// prefix. Confirmed live against the pre-fix binary: the write succeeded,
+// and — per the doc comment above — the next reconcile pass would silently
+// revert it. Must refuse this too, unconditionally.
+func TestRoutePlatformCanaryRefusesMoveBetweenCanaryChannels(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "site", "v1")
+ f.promote(release.OpsAccount, "site", "canary/a", "v1")
+ f.promote(release.OpsAccount, "site", "canary/b", "v1")
+ f.addRoute(registry.Route{Host: "preview.site.bab.apps.example.com", Kind: "static", App: "site",
+ Channel: "canary/a", Status: "ready", Platform: true, Canary: true})
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath,
+ "-host", "preview.site.bab.apps.example.com", "-channel", "canary/b", "-allow-version-change")
+ if err == nil {
+ t.Fatalf("moving a Platform+Canary route between canary channels was accepted:\n%s", out)
+ }
+ if got := f.channelOf("preview.site.bab.apps.example.com"); got != "canary/a" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// IMPORTANT 4: -account defaults to "ops" like every other account-taking
+// verb, so a bare -app does not silently cross tenant boundaries; -account
+// all opts into the cross-account sweep Tito's eighteen routes need.
+func TestRouteAccountDefaultsToOpsAndAllOptsIn(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.ship(release.OpsAccount, "acctapp", "a1")
+ f.promote(release.OpsAccount, "acctapp", "canary/x", "a1")
+ f.promote(release.OpsAccount, "acctapp", "stable", "a1")
+ f.ship(sqid, "acctapp", "a1")
+ f.promote(sqid, "acctapp", "canary/x", "a1")
+ f.promote(sqid, "acctapp", "stable", "a1")
+ f.addRoute(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance",
+ App: "acctapp", Account: release.OpsAccount, Channel: "canary/x", Version: "a1", Status: "ready"})
+ f.addRoute(registry.Route{Host: "second-acctapp.example.com", Kind: "instance",
+ App: "acctapp", Account: sqid, Channel: "canary/x", Version: "a1", Status: "ready"})
+
+ env := f.fullEnv()
+ // Bare -app: only the ops-account route moves.
+ out, err := f.run(env, "route", "-registry", f.regPath, "-app", "acctapp", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("route -app (default account): %v\n%s", err, out)
+ }
+ if got := f.channelOf("ops-acctapp.example.com"); got != "stable" {
+ t.Fatalf("the ops-account route did not move under the default: %q", got)
+ }
+ if got := f.channelOf("second-acctapp.example.com"); got != "canary/x" {
+ t.Fatalf("the default -app crossed into another account's route: %q", got)
+ }
+
+ // -account all: the other account's route moves too.
+ out, err = f.run(env, "route", "-registry", f.regPath, "-app", "acctapp", "-channel", "stable", "-account", "all")
+ if err != nil {
+ t.Fatalf("route -app -account all: %v\n%s", err, out)
+ }
+ if got := f.channelOf("second-acctapp.example.com"); got != "stable" {
+ t.Fatalf("-account all did not move the other account's route: %q, out:\n%s", got, out)
+ }
+}
+
+// IMPORTANT 5: a route with no adopted Version (a static route — SetVersions
+// is only ever called for Kind == instance — or an instance never adopted)
+// must not garble the rail message or force -allow-version-change
+// unconditionally. When the route's CURRENT channel and the TARGET channel
+// point at the same version, it is exactly as safe as the normal case.
+func TestRouteStaticRouteWithEmptyVersionComparesChannelPointers(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "site", "v1")
+ f.promote(release.OpsAccount, "site", "canary/x", "v1")
+ f.promote(release.OpsAccount, "site", "stable", "v1")
+ f.addRoute(registry.Route{Host: "site.example.com", Kind: "static", App: "site",
+ Channel: "canary/x", Status: "ready"}) // Version left "" — never set for static
+
+ env := f.fullEnv()
+ out, err := f.run(env, "route", "-registry", f.regPath, "-host", "site.example.com", "-channel", "stable")
+ if err != nil {
+ t.Fatalf("a static route whose current and target channels agree was refused: %v\n%s", err, out)
+ }
+ if strings.Contains(out, "route is running ") || strings.Contains(out, "running , ") {
+ t.Errorf("output still shows the garbled empty-version message:\n%s", out)
+ }
+ if got := f.channelOf("site.example.com"); got != "stable" {
+ t.Fatalf("channel did not move: %q", got)
+ }
+
+ // Now make the current and target channels genuinely disagree, and
+ // confirm it refuses (rather than either garbling or silently passing).
+ f.ship(release.OpsAccount, "site", "v2")
+ f.promote(release.OpsAccount, "site", "canary/y", "v2")
+ f.addRoute(registry.Route{Host: "site2.example.com", Kind: "static", App: "site",
+ Channel: "canary/y", Status: "ready"})
+ out, err = f.run(env, "route", "-registry", f.regPath, "-host", "site2.example.com", "-channel", "stable")
+ if err == nil {
+ t.Fatalf("a static route whose current/target channels disagree was accepted without -allow-version-change:\n%s", out)
+ }
+ if got := f.channelOf("site2.example.com"); got != "canary/y" {
+ t.Fatalf("channel changed despite the refusal: %q", got)
+ }
+}
+
+// MINOR 9 (review): -dry-run runs the full check pass and prints what would
+// happen, but writes nothing — the two-pass CRITICAL 1 fix produces this
+// almost for free, and an operator moving Tito's eighteen routes should be
+// able to preview before committing.
+func TestRouteDryRunWritesNothing(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "demo", "v1")
+ f.promote(release.OpsAccount, "demo", "canary/x", "v1")
+ f.promote(release.OpsAccount, "demo", "stable", "v1")
+ f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance",
+ App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "demo.example.com", "-channel", "stable", "-dry-run")
+ if err != nil {
+ t.Fatalf("route -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 !strings.Contains(out, "canary/x -> stable") {
+ t.Errorf("dry run does not show the plan:\n%s", out)
+ }
+ if got := f.channelOf("demo.example.com"); got != "canary/x" {
+ t.Fatalf("dry run wrote a channel change: %q", got)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 3ac50d731..7ee21ff91 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -11,6 +11,7 @@ package registry
import (
"database/sql"
+ "fmt"
"strings"
"time"
@@ -437,6 +438,73 @@ func (r *Registry) SetAccount(host, account string) error {
return err
}
+// SetChannel repoints a route's release-channel subscription as a narrow
+// UPDATE (same rationale as SetAccount: a read-modify-write Upsert racing a
+// deploy's SetVersions or the activator's SetNextDue would clobber those
+// columns). Unlike SetAccount it errors when no row matched, so `carlos
+// route` can report an unknown host rather than silently doing nothing — this
+// is the one setter a human types a hostname into directly.
+func (r *Registry) SetChannel(host, channel string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET channel = ? WHERE host = ?`, channel, 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
+}
+
+// SetChannels repoints every host in hosts to channel as ONE transaction —
+// every row moves or none do. `carlos route -app` uses this for its bulk
+// form: the caller runs every route's safety-rail check to completion FIRST
+// (see cli.go's runRoute), and only once every route has cleared it does it
+// call this to write. Without that, a check-then-write-per-route loop can
+// leave an app split across two channels the moment one route mid-batch
+// fails the rail (or the process dies mid-loop) — an app that is meant to
+// move as one unit (Tito's eighteen routes off canary/platform-adoption)
+// must never be left half-moved. Same "error on an unmatched host" contract
+// as SetChannel, but any single failure rolls the whole batch back.
+func (r *Registry) SetChannels(hosts []string, channel 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 = ? WHERE host = ?`)
+ if err != nil {
+ tx.Rollback()
+ return err
+ }
+ for _, h := range hosts {
+ res, err := stmt.Exec(channel, 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 953a0e730..c6a5ebe9d 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -462,3 +462,94 @@ func TestStatusNamedRoundTrip(t *testing.T) {
t.Error("status_named did not round-trip")
}
}
+
+// SetChannel is the narrow UPDATE `carlos route` writes through: same
+// rationale as SetAccount (a read-modify-write Upsert racing a deploy's
+// SetVersions or the activator's SetNextDue would clobber those columns), and
+// it must fail loudly on an unknown host so the CLI can name it rather than
+// silently doing nothing.
+func TestSetChannelNarrow(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "keymail.dev", Kind: "instance", App: "keymail",
+ Channel: "canary/keymail", Version: "5457379"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetChannel("keymail.dev", "stable"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Channel != "stable" {
+ t.Fatalf("channel = %q, want stable", got.Channel)
+ }
+ if got.App != "keymail" || got.Version != "5457379" {
+ t.Fatalf("SetChannel clobbered unrelated columns: %+v", got)
+ }
+}
+
+func TestSetChannelUnknownHostErrors(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetChannel("nope.example.com", "stable"); err == nil {
+ t.Fatal("SetChannel on an unknown host: got nil error, want one")
+ }
+}
+
+// SetChannels is `carlos route -app`'s write path: one transaction across
+// every matched route, so a bad host mid-batch (or a crash mid-write) can
+// never leave an app split across two channels — the CRITICAL 1 finding in
+// the route-verb review. A check-then-write-per-route loop had already
+// proven this fails: with a caller doing the checking up front and this
+// doing the writing, either every row moves or none do.
+func TestSetChannelsAllOrNothing(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: "demo", Channel: "canary/x"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := reg.SetChannels([]string{"a.example.com", "nosuch.example.com", "b.example.com"}, "stable")
+ if err == nil {
+ t.Fatal("SetChannels 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.Channel != "canary/x" {
+ t.Fatalf("%s channel = %q after a failed batch, want untouched canary/x — the batch was not atomic", h, got.Channel)
+ }
+ }
+}
+
+func TestSetChannelsUpdatesEveryHost(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: "demo", Channel: "canary/x"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := reg.SetChannels([]string{"a.example.com", "b.example.com", "c.example.com"}, "stable"); 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.Channel != "stable" {
+ t.Fatalf("%s channel = %q, want stable", h, got.Channel)
+ }
+ }
+}
+
+// A no-op call must not open a transaction it never uses (harmless either
+// way, but it should not error).
+func TestSetChannelsEmptyIsNoop(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetChannels(nil, "stable"); err != nil {
+ t.Fatalf("SetChannels(nil, ...): %v, want no error", err)
+ }
+}
diff --git a/main.go b/main.go
index 5f76ae4bb..056a62c44 100644
--- a/main.go
+++ b/main.go
@@ -29,6 +29,8 @@ func main() {
runAdd(args)
case "remove":
runRemove(args)
+ case "route":
+ runRoute(args)
case "instances":
runInstances(args)
case "ship":
@@ -77,6 +79,12 @@ commands:
edge run the mesh front door (router)
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)
auth login | whoami | logout — who this terminal is
(headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive