| 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) |
| + } |
| +} |