// End-to-end smoke of the release/serve/rollback loop, AWS-free: a DirStore // deployment bucket, a plain-HTTP edge on a TCP port, a real compiled tenant // (testdata/echoapp) behind a TCP-addr route, a static site behind a // channel-tracked static route, and the fleet status page behind a // bucket-backed kind=status route. Host-side adoption (release.Pass, symlink // repointing) is internal/host territory and is deliberately NOT exercised // here — the loop this test proves is: // // 1. the edge routes to a live backend by Host header, // 2. a shipped+promoted static tree is served through the edge, // 3. promote advances the served content, rollback returns the pointer to // the previous version, and the static handler swaps back on re-check. // 4. one fleet status publish tick lands status/{index.html,fleet.json} in // the same deployment bucket, and the edge's kind=status route serves // both back out through the identical Host-routed path. package main_test import ( "context" "crypto/ed25519" "encoding/json" "fmt" "io" "net" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "reflect" "regexp" "runtime" "strings" "testing" "time" platform "github.com/carlosframework/platform" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" "github.com/carlosframework/platform/internal/vet" ) // e2eGet GETs the edge with an explicit Host header, the way a real client // reaches a routed hostname. func e2eGet(t *testing.T, base, host, path string) (int, string) { t.Helper() req, err := http.NewRequest("GET", base+path, nil) if err != nil { t.Fatalf("new request: %v", err) } req.Host = host resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("GET %s%s (host %s): %v", base, path, host, err) } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return resp.StatusCode, string(b) } // carlosTestEnv is os.Environ() with the operator's real login and console // state masked: CARLOS_CREDENTIALS points into the test's own tempdir where // no credential exists, HOME is a scratch dir (the binary reads // ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared. // Every subprocess that runs the carlos binary MUST build its env through // this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors // for .carlos/config, so a subprocess running from the real checkout reads // the developer's real config the moment one exists. A test that inherits // the real ~/.carlos is one `go test ./...` on a logged-in laptop away from // writing to a production console — which is exactly how 40+ test accounts // got minted on the flagship (2026-08-04). // // Duplicated in scopedkeys_e2e_test.go: this file is package main_test (an // external test package), that one is package main, and the two cannot // share an unexported helper across the package boundary. func carlosTestEnv(t *testing.T, extra ...string) []string { t.Helper() scratch := t.TempDir() // An inherited CARLOS_SERVICE_TOKEN (a CI shell that exports one) would // make any child that also sets CARLOS_DEPLOYMENT_DIR fail resolveMode's // token-plus-store refusal, so it is dropped here. It is filtered rather // than blanked: a test that passes its own token in extra still wins. var env []string for _, kv := range os.Environ() { if !strings.HasPrefix(kv, "CARLOS_SERVICE_TOKEN=") { env = append(env, kv) } } env = append(env, "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), "HOME="+scratch, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) return append(env, extra...) } // sharedCarlos is the one shared carlos build (auth_e2e_test.go), reached // through export_test.go: this file is package main_test and cannot see // package main's test helpers any other way. The binary is built at most once // per test process, whichever package asks first, and is read-only. func sharedCarlos(t testing.TB) string { t.Helper() return platform.SharedCarlosForTest(t) } // The external package gets the very file the internal one does: one build // for the process, whoever asked first, read-only. func TestSharedCarlosFromTheExternalPackage(t *testing.T) { if testing.Short() { t.Skip("builds a binary") } t.Chdir(t.TempDir()) first, second := sharedCarlos(t), sharedCarlos(t) if first != second { t.Fatalf("calls gave %s and %s, want one path", first, second) } if n := platform.SharedCarlosBuildsForTest(); n != 1 { t.Errorf("%d builds in this process, want 1", n) } info, err := os.Stat(first) if err != nil { t.Fatal(err) } if runtime.GOOS != "windows" && info.Mode().Perm() != 0o555 { t.Errorf("mode %v, want r-xr-xr-x", info.Mode().Perm()) } } func e2eShipSite(t *testing.T, store release.Store, version string, files map[string]string) { t.Helper() dir := t.TempDir() for name, body := range files { p := filepath.Join(dir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } } tarball, err := edge.PackDir(dir) if err != nil { t.Fatalf("PackDir: %v", err) } m := release.Manifest{ Schema: 1, App: "site", Version: version, Kind: "static", Artifacts: map[string]release.Artifact{"site.tar.gz": {}}, } if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{"site.tar.gz": tarball}); err != nil { t.Fatalf("Ship site %s: %v", version, err) } } func e2eShipBinary(t *testing.T, store release.Store, version string, bin []byte) { t.Helper() m := release.Manifest{ Schema: 1, App: "echoapp", Version: version, Kind: "binary", Artifacts: map[string]release.Artifact{"echoapp": {}}, } if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{"echoapp": bin}); err != nil { t.Fatalf("Ship echoapp %s: %v", version, err) } } // The carlos-app@.socket listener handoff, systemd-free: the test plays // root systemd — binds the tenant's unix socket itself, passes the fd as fd // 3 with LISTEN_FDS=1 — and proves the tenant serves on the inherited // listener without ever touching the socket path. This is the contract that // lets the socket file stay carlos-owned 0600 (only the edge connects, // tenants can't reach each other) while a DynamicUser tenant owns the // process. func TestTenantInheritsActivationSocket(t *testing.T) { goBin, err := exec.LookPath("go") if err != nil { t.Skip("go toolchain not found") } tmp := t.TempDir() appBin := filepath.Join(tmp, "echoapp") build := exec.Command(goBin, "build", "-o", appBin, "./testdata/echoapp") build.Env = os.Environ() if out, err := build.CombinedOutput(); err != nil { t.Fatalf("build echoapp: %v\n%s", err, out) } // Short path — sun_path is ~104 bytes and t.TempDir() can blow it. sock := filepath.Join(os.TempDir(), fmt.Sprintf("carlos-e2e-%d.sock", os.Getpid())) os.Remove(sock) t.Cleanup(func() { os.Remove(sock) }) ln, err := net.Listen("unix", sock) if err != nil { if strings.Contains(err.Error(), "operation not permitted") { t.Skipf("unix-socket listeners not permitted in this environment: %v", err) } t.Fatalf("listen unix: %v", err) } defer ln.Close() f, err := ln.(*net.UnixListener).File() if err != nil { t.Fatalf("listener fd: %v", err) } defer f.Close() app := exec.Command(appBin) // no -socket, no -addr: the fd is the listener app.ExtraFiles = []*os.File{f} // child fd 3, where sd_listen_fds starts app.Env = append(os.Environ(), "LISTEN_FDS=1", "BUILD=sockv1") if err := app.Start(); err != nil { t.Fatalf("start echoapp: %v", err) } t.Cleanup(func() { _ = app.Process.Kill() _, _ = app.Process.Wait() }) client := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", sock) }, }} get := func(path string) (int, string, error) { resp, err := client.Get("http://tenant.local" + path) if err != nil { return 0, "", err } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(b), nil } deadline := time.Now().Add(10 * time.Second) for { code, body, err := get("/healthz") if err == nil && code == 200 && body == "ok" { break } if time.Now().After(deadline) { t.Fatalf("tenant never healthy on the inherited listener: %d %q %v", code, body, err) } time.Sleep(100 * time.Millisecond) } if code, body, err := get("/"); err != nil || code != 200 || !strings.Contains(body, "BUILD=sockv1") { t.Fatalf("GET / over inherited socket = %d %q %v, want 200 containing BUILD=sockv1", code, body, err) } } func TestEndToEndSmoke(t *testing.T) { goBin, err := exec.LookPath("go") if err != nil { t.Skip("go toolchain not found; skipping e2e smoke") } ctx := context.Background() tmp := t.TempDir() // --- build the tenant --------------------------------------------------- appBin := filepath.Join(tmp, "echoapp") build := exec.Command(goBin, "build", "-o", appBin, "./testdata/echoapp") build.Env = os.Environ() if out, err := build.CombinedOutput(); err != nil { t.Fatalf("build echoapp: %v\n%s", err, out) } binBytes, err := os.ReadFile(appBin) if err != nil { t.Fatalf("read echoapp: %v", err) } // --- release plane: DirStore + signing keys ----------------------------- store := release.NewDirStore(filepath.Join(tmp, "store")) seedB64, pubB64, err := release.GenerateKey() if err != nil { t.Fatalf("GenerateKey: %v", err) } priv, err := release.ParseSigningKey(seedB64, "e2e") if err != nil { t.Fatalf("ParseSigningKey: %v", err) } var pub ed25519.PublicKey if pub, err = release.ParsePublicKey(pubB64, "e2e"); err != nil { t.Fatalf("ParsePublicKey: %v", err) } // Ship + promote the binary app and the static site to canary/x. e2eShipBinary(t, store, "v1", binBytes) if err := release.Promote(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x", "v1"); err != nil { t.Fatalf("Promote echoapp v1: %v", err) } e2eShipSite(t, store, "v1", map[string]string{"index.html": "site v1", "docs/page.html": "docs v1"}) e2eShipSite(t, store, "v2", map[string]string{"index.html": "site v2"}) if err := release.Promote(ctx, store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { t.Fatalf("Promote site v1: %v", err) } // The shipped binary release passes vet (boot check self-skips where the // sandbox forbids unix listeners). if probs := vet.Check(ctx, store, pub, release.OpsAccount, "echoapp", "v1", true); len(probs) != 0 { t.Fatalf("vet.Check echoapp v1 = %v, want clean", probs) } // --- backend process on a TCP port (NOT a unix socket: sandbox) --------- ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("pick port: %v", err) } backendAddr := ln.Addr().String() ln.Close() app := exec.Command(appBin, "-addr", backendAddr) app.Env = append(os.Environ(), "BUILD=v1") if err := app.Start(); err != nil { t.Fatalf("start echoapp: %v", err) } t.Cleanup(func() { _ = app.Process.Kill() _, _ = app.Process.Wait() }) waitUp := func() { deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { resp, err := http.Get("http://" + backendAddr + "/healthz") if err == nil { resp.Body.Close() if resp.StatusCode == 200 { return } } time.Sleep(100 * time.Millisecond) } t.Fatalf("echoapp never became healthy on %s", backendAddr) } waitUp() // --- registry + edge (dev shape: plain HTTP on a TCP port) -------------- reg, err := registry.Open(filepath.Join(tmp, "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } if err := reg.Upsert(registry.Route{Host: "app.local", Kind: "service", Addr: backendAddr, App: "echoapp", Channel: "canary/x", Status: "ready"}); err != nil { t.Fatalf("upsert app route: %v", err) } if err := reg.Upsert(registry.Route{Host: "site.local", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil { t.Fatalf("upsert site route: %v", err) } if err := reg.Upsert(registry.Route{Host: "status.local", Kind: "status", Status: "ready"}); err != nil { t.Fatalf("upsert status route: %v", err) } e, err := edge.New(reg, nil) if err != nil { t.Fatalf("edge.New: %v", err) } e.EnableStatic(store, pub, filepath.Join(tmp, "static-cache")) e.SetStaticRecheck(0) // re-check the channel pointer on every request if err := e.Reload(); err != nil { t.Fatalf("edge.Reload: %v", err) } srv := httptest.NewServer(e) defer srv.Close() // (1) The edge routes by Host header to the live backend. if code, body := e2eGet(t, srv.URL, "app.local", "/"); code != 200 || !strings.Contains(body, "BUILD=v1") { t.Fatalf("GET app.local / = %d %q, want 200 containing BUILD=v1", code, body) } if code, body := e2eGet(t, srv.URL, "app.local", "/healthz"); code != 200 || body != "ok" { t.Fatalf("GET app.local /healthz = %d %q, want 200 ok", code, body) } if code, _ := e2eGet(t, srv.URL, "nobody.local", "/"); code != http.StatusNotFound { t.Fatalf("GET unknown host = %d, want 404", code) } // (2) The static site is served from the shipped+promoted tree. if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v1" { t.Fatalf("GET site.local / = %d %q, want 200 %q", code, body, "site v1") } if code, body := e2eGet(t, srv.URL, "site.local", "/docs/page.html"); code != 200 || body != "docs v1" { t.Fatalf("GET site.local /docs/page.html = %d %q, want 200 %q", code, body, "docs v1") } if code, _ := e2eGet(t, srv.URL, "site.local", "/missing"); code != http.StatusNotFound { t.Fatalf("GET site.local /missing = %d, want 404", code) } // (3) Promote advances the served site; rollback swaps it back. if err := release.Promote(ctx, store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { t.Fatalf("Promote site v2: %v", err) } if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v2" { t.Fatalf("after promote, GET site.local / = %d %q, want 200 %q", code, body, "site v2") } if err := release.Rollback(ctx, store, priv, release.OpsAccount, "site", "canary/x"); err != nil { t.Fatalf("Rollback site: %v", err) } cp, err := release.Channel(ctx, store, pub, release.OpsAccount, "site", "canary/x") if err != nil { t.Fatalf("Channel site after rollback: %v", err) } if cp.Version != "v1" || len(cp.History) == 0 || cp.History[0] != "v2" { t.Fatalf("rolled-back pointer = %s (history %v), want v1 with v2 first in history", cp.Version, cp.History) } if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v1" { t.Fatalf("after rollback, GET site.local / = %d %q, want 200 %q", code, body, "site v1") } // (4) The fleet status page: one publish tick, then served through the // same edge by Host header. statusTick (internal/host/agent.go) is // unexported and lives in a different package, so this composes the // identical seam it runs on every 5-minute tick — CollectBox -> WriteBox // -> BuildFleet -> PublishFleet — directly over the same DirStore the // release plane above already writes to (EnableStatic wired that same // `store` in as the status route's backing store too). statusRoutes, err := reg.List() if err != nil { t.Fatalf("list routes for status tick: %v", err) } tickNow := time.Now() box := status.CollectBox(statusRoutes, status.Probes{MachineID: "e2e-box"}, tickNow) if err := status.WriteBox(ctx, store, box); err != nil { t.Fatalf("WriteBox: %v", err) } fleet, err := status.BuildFleet(ctx, store, statusRoutes, tickNow, 5*time.Minute) if err != nil { t.Fatalf("BuildFleet: %v", err) } if err := status.PublishFleet(ctx, store, fleet); err != nil { t.Fatalf("PublishFleet: %v", err) } if code, body := e2eGet(t, srv.URL, "status.local", "/"); code != 200 || !strings.Contains(body, "Fleet status") { t.Fatalf("GET status.local / = %d %q, want 200 containing %q", code, body, "Fleet status") } code, body := e2eGet(t, srv.URL, "status.local", "/fleet.json") if code != 200 { t.Fatalf("GET status.local /fleet.json = %d, want 200", code) } // DisallowUnknownFields turns this decode into a schema check too: a // stray top-level key (one Fleet doesn't declare) fails the test instead // of silently vanishing into an unused field. var decodedFleet status.Fleet dec := json.NewDecoder(strings.NewReader(body)) dec.DisallowUnknownFields() if err := dec.Decode(&decodedFleet); err != nil { t.Fatalf("decode status.local /fleet.json: %v\nbody: %s", err, body) } if decodedFleet.GeneratedAt.IsZero() { t.Errorf("fleet.json generated_at is zero, want the publish tick's timestamp") } wantLabel := status.Label("e2e-box") // deterministic from the fixture MachineID above if len(decodedFleet.Boxes) != 1 || decodedFleet.Boxes[0].Label != wantLabel { t.Errorf("fleet.json boxes = %+v, want exactly one box labeled %q", decodedFleet.Boxes, wantLabel) } // Every route CollectBox saw falls into exactly one of static/resident/ // hibernating, so instances.total is always the route count — app.local // (service), site.local (static), status.local (status): 3 routes, one box. wantTotal := len(statusRoutes) if got := decodedFleet.Instances["total"]; got == nil || *got != wantTotal { t.Errorf("fleet.json instances.total = %v, want %d (one box over %d routes)", got, wantTotal, wantTotal) } // (5) Domains: one reconcile pass mints the app's platform domain under // the account its BUCKET KEYS say it belongs to, and the edge serves it by // Host header over the same echo backend — every app is born addressable. // echoapp was shipped under ops above, so ops is where it mints; the // two-account case is TestMultiAccountEndToEnd. acctSqid := release.OpsAccount dcfg := domains.Config{ AppsDomain: "oncarlos.test", EdgeIPs: func() []string { return []string{"203.0.113.7"} }, Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, Now: time.Now, } if err := domains.Reconcile(ctx, store, reg, dcfg); err != nil { t.Fatalf("domains.Reconcile: %v", err) } // The per-box health pass follows the reconcile, as domainsLoop runs it (#303). if err := domains.StampHealth(ctx, reg, dcfg, nil); err != nil { t.Fatalf("domains.StampHealth: %v", err) } platformHost := "echoapp." + acctSqid + ".oncarlos.test" prt, err := reg.Get(platformHost) if err != nil { t.Fatalf("get platform route: %v", err) } if prt == nil { t.Fatalf("reconcile minted no platform route %s", platformHost) } if !prt.Platform || prt.Addr != backendAddr || prt.Account != acctSqid { t.Fatalf("platform route = %+v, want platform-flagged, echo upstream, account %s", prt, acctSqid) } if prt.DNSStatus != "pointing" { t.Fatalf("platform route dns_status = %q, want pointing (fake resolver says edge)", prt.DNSStatus) } // In production the fingerprint watcher reloads the edge; here the test // plays the watcher. if err := e.Reload(); err != nil { t.Fatalf("edge.Reload after reconcile: %v", err) } if code, body := e2eGet(t, srv.URL, platformHost, "/"); code != 200 || !strings.Contains(body, "BUILD=v1") { t.Fatalf("GET %s / = %d %q, want 200 containing BUILD=v1 (the platform domain serves the app)", platformHost, code, body) } // Binary channel pointer flow: ship v2 (same bytes — a rebuild), promote, // then roll back; the pointer must return to v1 with v2 as the rollback // target. (Applying the pointer on a box is host/agent territory.) e2eShipBinary(t, store, "v2", binBytes) if err := release.Promote(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x", "v2"); err != nil { t.Fatalf("Promote echoapp v2: %v", err) } cp, err = release.Channel(ctx, store, pub, release.OpsAccount, "echoapp", "canary/x") if err != nil { t.Fatalf("Channel echoapp: %v", err) } if cp.Version != "v2" || len(cp.History) == 0 || cp.History[0] != "v1" { t.Fatalf("promoted pointer = %s (history %v), want v2 over v1", cp.Version, cp.History) } if err := release.Rollback(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x"); err != nil { t.Fatalf("Rollback echoapp: %v", err) } cp, err = release.Channel(ctx, store, pub, release.OpsAccount, "echoapp", "canary/x") if err != nil { t.Fatalf("Channel echoapp after rollback: %v", err) } if cp.Version != "v1" || len(cp.History) == 0 || cp.History[0] != "v2" { t.Fatalf("rolled-back pointer = %s (history %v), want v1 with v2 first in history", cp.Version, cp.History) } } // TestStatusCLI exercises the actual `carlos status` command line, not just // the function underneath it: builds the real carlos binary the same way // this file already builds testdata/echoapp, then runs it as a subprocess // exactly the way an operator would from a shell. // // - `carlos status -dry-run ` must exit 0 and its stdout must carry // DryRun's "dry-run: no leaks" attestation — proving cli.go's RunStatus // is wired to internal/status.DryRun correctly. DryRun's own substance // (the fixture-needle leak scan) is already exercised directly by // TestDryRunSelfChecks in internal/status/status_test.go; duplicating // that here would just be the same assertion via a slower path, so this // test's job is only the wrapper's wiring and exit code. // - a bare `carlos status` (no -dry-run) must exit non-zero — RunStatus's // own input validation, not the pipeline it wraps. func TestStatusCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) dryRunDir := filepath.Join(tmp, "dryrun") if err := os.MkdirAll(dryRunDir, 0o755); err != nil { t.Fatalf("mkdir dry-run dir: %v", err) } // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd := exec.Command(carlosBin, "status", "-dry-run", dryRunDir) cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos status --dry-run %s: %v\n%s", dryRunDir, err, out) } if !strings.Contains(string(out), "dry-run: no leaks") { t.Errorf("carlos status --dry-run output = %q, want it to contain %q", out, "dry-run: no leaks") } cmd = exec.Command(carlosBin, "status") cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") cmd.Dir = tmp if err := cmd.Run(); err == nil { t.Error("carlos status with no --dry-run flag: want a non-zero exit, got 0") } } // e2eSiteTar packs a one-file site into the tarball a static release ships. func e2eSiteTar(t *testing.T, body string) []byte { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(body), 0o644); err != nil { t.Fatal(err) } tarball, err := edge.PackDir(dir) if err != nil { t.Fatalf("PackDir: %v", err) } return tarball } // TestMultiAccountEndToEnd is the whole point of the feature, end to end: two // accounts, an app of the SAME NAME in each, different builds, and the edge // serving each account's own bytes on its own host. Plus the migration seam: // a third app whose objects are still at the legacy, account-less keys serves // through the read-fallback, unchanged. func TestMultiAccountEndToEnd(t *testing.T) { ctx := context.Background() tmp := t.TempDir() store := release.NewDirStore(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) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } // Two accounts, in the shape minting leaves behind: an id, its frozen // sqid, and a bucket manifest the agent materializes from. The ids are // hand-picked (and deliberately large) rather than minted here — this test // is about the agent reading manifests, and a fixture that depended on the // allocator would move every time the allocator did. reg, err := registry.Open(filepath.Join(tmp, "registry.db")) if err != nil { t.Fatal(err) } defer reg.Close() accounts := map[string]string{} // name -> sqid for name, id := range map[string]int64{"Kass": 793481920157, "Woodstar": 481920157793} { sqid := domains.Sqid(id) accounts[name] = sqid body := fmt.Sprintf(`{"id":%d,"sqid":%q,"name":%q,"created_at":"2026-08-02T09:00:00Z"}`, id, sqid, name) if _, err := store.Put(ctx, "accounts/"+sqid+"/account.json", []byte(body), ""); err != nil { t.Fatal(err) } } if accounts["Kass"] == accounts["Woodstar"] { t.Fatal("fixture accounts collided") } // The same app name in both, shipped with different content. for name, content := range map[string]string{"Kass": "kass blog", "Woodstar": "woodstar blog"} { acct := accounts[name] m := release.Manifest{Schema: 1, App: "blog", Version: "v1", Kind: "static", Artifacts: map[string]release.Artifact{"site.tar.gz": {}}} if err := release.Ship(ctx, store, acct, m, map[string][]byte{"site.tar.gz": e2eSiteTar(t, content)}); err != nil { t.Fatalf("Ship blog for %s: %v", name, err) } if err := release.Promote(ctx, store, priv, acct, "blog", "stable", "v1"); err != nil { t.Fatalf("Promote blog for %s: %v", name, err) } } // And a legacy app: manifest, artifact and signed pointer at the // account-less keys, exactly as an unmigrated deployment's bucket has // them. Built in a scratch store and copied down, because a pointer's // signature covers app/channel/version rather than the key path — so // these are genuine legacy objects, signed by the same key a real // deployment used. scratch := release.NewDirStore(filepath.Join(tmp, "scratch")) oldm := release.Manifest{Schema: 1, App: "oldsite", Version: "v1", Kind: "static", Artifacts: map[string]release.Artifact{"site.tar.gz": {}}} if err := release.Ship(ctx, scratch, release.OpsAccount, oldm, map[string][]byte{"site.tar.gz": e2eSiteTar(t, "legacy site")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "oldsite", "stable", "v1"); err != nil { t.Fatal(err) } staged, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "oldsite")) if err != nil { t.Fatal(err) } for _, k := range staged { body, _, err := scratch.Get(ctx, k) if err != nil { t.Fatal(err) } legacy := release.LegacyAppPrefix("oldsite") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "oldsite")) if _, err := store.Put(ctx, legacy, body, ""); err != nil { t.Fatal(err) } } for _, rt := range []registry.Route{ {Host: "blog.kass.test", Kind: "static", App: "blog", Account: accounts["Kass"], Channel: "stable", Status: "ready"}, {Host: "blog.woodstar.test", Kind: "static", App: "blog", Account: accounts["Woodstar"], Channel: "stable", Status: "ready"}, {Host: "old.test", Kind: "static", App: "oldsite", Channel: "stable", Status: "ready"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } e, err := edge.New(reg, nil) if err != nil { t.Fatal(err) } e.EnableStatic(store, pub, filepath.Join(tmp, "static-cache")) e.SetStaticRecheck(0) if err := e.Reload(); err != nil { t.Fatal(err) } srv := httptest.NewServer(e) defer srv.Close() for host, want := range map[string]string{ "blog.kass.test": "kass blog", "blog.woodstar.test": "woodstar blog", } { if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want { t.Errorf("GET %s / = %d %q, want 200 %q", host, code, body, want) } } // The legacy app serves NOTHING. Its objects are all still at the // account-less keys, and since the read-fallback was retired no read path // resolves through them — the edge cannot find a pointer, so the route is // a route to nowhere until somebody runs `accounts migrate -from legacy`. // Serving it would be the stale-shadow risk the retirement removed. if code, body := e2eGet(t, srv.URL, "old.test", "/"); code == 200 { t.Errorf("GET old.test / = %d %q — a legacy-only app is still being served", code, body) } // One reconcile pass: accounts materialize with the bucket's ids, and each // app mints its platform domain under the account its KEY PATHS say it // belongs to — the legacy one under ops. if err := domains.Reconcile(ctx, store, reg, domains.Config{AppsDomain: "oncarlos.test"}); err != nil { t.Fatal(err) } for name, sqid := range accounts { id, got, _ := reg.AccountBySqid(sqid) if id == 0 || got != name { t.Errorf("account %s materialized as id=%d name=%q", sqid, id, got) } } if err := e.Reload(); err != nil { t.Fatal(err) } for host, want := range map[string]string{ "blog." + accounts["Kass"] + ".oncarlos.test": "kass blog", "blog." + accounts["Woodstar"] + ".oncarlos.test": "woodstar blog", } { if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want { t.Errorf("GET %s / = %d %q, want 200 %q — the platform domain must serve its own account's build", host, code, body, want) } } // oldsite's apex still mints: minting follows the operator's ROUTE, which // is a real record of intent, not the bucket objects. What it must not do // is serve — the account it mints under is where the app's objects OUGHT // to be, and the empty page is the honest report of them not being there. if code, body := e2eGet(t, srv.URL, "oldsite.bab.oncarlos.test", "/"); code == 200 { t.Errorf("GET oldsite.bab.oncarlos.test / = %d %q — a legacy-only app is still being served", code, body) } } // TestAccountsCLI drives the `carlos accounts` verbs as an operator would: // create mints identity, list shows it, and migrate moves an app's objects to // the account-qualified prefix WITHOUT ever deleting the legacy ones — // deletion is a separate operator step behind the verification gate. func TestAccountsCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) storeDir := filepath.Join(tmp, "store") regPath := filepath.Join(tmp, "registry.db") // The subprocess must never see the operator's real ~/.carlos — a genuine // login on the developer's machine makes ownerless `accounts create` // succeed (the logged-in identity becomes the owner, by design) and // flips this test's answer. carlosTestEnv masks credentials, HOME and // CARLOS_CONSOLE; cmd.Dir below closes the cwd-walk path. env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) run := func(args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // The binary now walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } // An app in the legacy, account-less shape — what every existing // deployment's bucket looks like before the migration. store := release.NewDirStore(storeDir) ctx := context.Background() for _, key := range []string{ "apps/demo/releases/v1/manifest.json", "apps/demo/releases/v1/demo", "apps/demo/channels/stable.json", } { if _, err := store.Put(ctx, key, []byte("body of "+key), ""); err != nil { t.Fatal(err) } } reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "demo.example.com", Kind: "instance", App: "demo", Status: "ready", Addr: "127.0.0.1:9000"}); err != nil { t.Fatal(err) } reg.Close() // FOUND IN PRODUCTION: create used to record an owner NOWHERE, so the // accounts kass and keymail were migrated into had no members — and // membership is authorization, so the operator could not open either one in // the console. -owner is required, and its absence has to name the flag // rather than print a shrug. failed := exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "kass") failed.Env = env failed.Dir = tmp refusal, err := failed.CombinedOutput() if err == nil { t.Fatalf("accounts create with no --owner succeeded — that is the ownerless account bug:\n%s", refusal) } if !strings.Contains(string(refusal), "-owner") { t.Errorf("refusal does not name the flag to pass:\n%s", refusal) } // And it refused BEFORE minting: a rejected create must not burn an id or // leave a half-made account in the bucket. if keys, err := store.List(ctx, "accounts/"); err != nil || len(keys) != 0 { t.Errorf("refused create left %v in the bucket", keys) } // A typo'd address is refused with the same rule the console's sign-in form // uses — an owner keymail could never authenticate is a door that does not // open either. failed = exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul") failed.Env = env failed.Dir = tmp if refusal, err := failed.CombinedOutput(); err == nil { t.Errorf("accounts create accepted the non-address owner %q:\n%s", "paul", refusal) } // create: a minted id, its frozen sqid printed for the operator to use. out := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "Paul@Keymail.dev") m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out) if m == nil { t.Fatalf("accounts create printed no sqid:\n%s", out) } sqid := m[1] if !regexp.MustCompile(`^[a-z]{3,16}$`).MatchString(sqid) { t.Fatalf("created sqid = %q, want the frozen encoder's alphabet", sqid) } // The manifest is in the bucket — that is where identity lives, and now // where the OWNER lives too: the console materializes it into an owner // membership, so the carlos binary never has to open console state. body, _, err := store.Get(ctx, "accounts/"+sqid+"/account.json") if err != nil { t.Fatalf("create wrote no account manifest: %v", err) } var manifest domains.AccountManifest if err := json.Unmarshal(body, &manifest); err != nil { t.Fatal(err) } if manifest.Owner != "paul@keymail.dev" { t.Errorf("manifest owner = %q, want the normalized address — the console's memberships "+ "key is literal text, so a mixed-case owner is an owner who cannot sign in", manifest.Owner) } // The operator has to be able to see, from the output alone, that the // account is reachable and by whom. if !strings.Contains(out, "paul@keymail.dev") { t.Errorf("create does not say who owns the account:\n%s", out) } if out := run("accounts", "list", "-registry", regPath); !strings.Contains(out, sqid) || !strings.Contains(out, "kass") || !strings.Contains(out, "ops") { t.Fatalf("accounts list = %q, want ops and the new account", out) } // migrate -dry-run: says what it would do, touches nothing. before, err := store.List(ctx, "") if err != nil { t.Fatal(err) } out = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid, "-dry-run") if !strings.Contains(out, "apps/demo/channels/stable.json") || !strings.Contains(out, "apps/"+sqid+"/demo/channels/stable.json") { t.Fatalf("dry run did not report the plan:\n%s", out) } // The move is not finished until the console's own app→account row moves, // and this binary deliberately will not reach into the console's DB to do // it — so it must tell the operator the exact command that does. wantFollowUp := "console migrate-app --app demo --to " + sqid if !strings.Contains(out, wantFollowUp) { t.Fatalf("dry run does not name the console follow-up %q:\n%s", wantFollowUp, out) } after, err := store.List(ctx, "") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(before, after) { t.Fatalf("dry run changed the store:\n%v\n%v", before, after) } // migrate for real: the qualified objects exist AND the legacy ones are // still there — nothing in this plan deletes a legacy object. if out := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); !strings.Contains(out, wantFollowUp) { t.Fatalf("migrate does not name the console follow-up %q:\n%s", wantFollowUp, out) } for _, key := range []string{ "apps/" + sqid + "/demo/releases/v1/manifest.json", "apps/" + sqid + "/demo/releases/v1/demo", "apps/" + sqid + "/demo/channels/stable.json", } { body, _, err := store.Get(ctx, key) if err != nil { t.Errorf("migrated object missing: %s: %v", key, err) continue } legacy := "apps/demo/" + strings.TrimPrefix(key, "apps/"+sqid+"/demo/") want, _, err := store.Get(ctx, legacy) if err != nil { t.Errorf("migrate deleted the legacy object %s: %v", legacy, err) continue } if string(body) != string(want) { t.Errorf("%s = %q, want the legacy bytes %q", key, body, want) } } // The route's account moved with it, so adopt builds the new keys. reg2, err := registry.Open(regPath) if err != nil { t.Fatal(err) } defer reg2.Close() rt, err := reg2.Get("demo.example.com") if err != nil || rt == nil || rt.Account != sqid { t.Fatalf("route account = %+v %v, want %s", rt, err, sqid) } // Re-running is idempotent: identical bytes at an immutable path are a // no-op, not a refusal. run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) } // TestAccountsMigrateTakesTheLiveObjects is the production-rollback trap. // // The moment this binary is adopted, every ship/promote writes to // apps/bab//… and every read resolves qualified-first — so the LEGACY // pointer goes stale the first time anyone deploys. A migrate that blindly // copied from the legacy prefix would move a stale pointer into the new // account, miss every post-adopt release, and the next agent tick would // silently downgrade the app to a pre-adopt build before any human looked at // it. // // So: with objects at BOTH prefixes, the verb must resolve them the way the // RUNTIME does (qualified wins per key), say that it saw both, and verify // afterwards that what the destination now serves is what the source served. func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) 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) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_RELEASE_PUBKEY="+pubB64, "HOME="+tmp, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() return string(out), err } // The pre-adopt world: v1 shipped and promoted, its objects living at the // legacy account-less keys. (Built by promoting under ops and copying the // bytes down — a pointer's signature covers app/channel/version, not the // key path, so those ARE valid legacy objects, signed by the same key a // real deployment used.) m1 := release.Manifest{Schema: 1, App: "demo", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} if err := release.Ship(ctx, store, release.OpsAccount, m1, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v1"); err != nil { t.Fatal(err) } qualified, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "demo")) if err != nil { t.Fatal(err) } for _, k := range qualified { body, _, err := store.Get(ctx, k) if err != nil { t.Fatal(err) } legacy := release.LegacyAppPrefix("demo") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "demo")) if _, err := store.Put(ctx, legacy, body, ""); err != nil { t.Fatal(err) } } // Then the deployment adopts this binary and someone ships again: v2 lands // ONLY at the qualified keys, and stable now points at it there. The // legacy pointer still says v1 — stale, and invisible to every reader. m2 := release.Manifest{Schema: 1, App: "demo", Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} if err := release.Ship(ctx, store, release.OpsAccount, m2, map[string][]byte{"linux-amd64": []byte("v2 bytes")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v2"); err != nil { t.Fatal(err) } // Sanity: this is genuinely the trap — the two prefixes disagree. if p, err := release.Channel(ctx, store, pub, release.OpsAccount, "demo", "stable"); err != nil || p.Version != "v2" { t.Fatalf("fixture: runtime resolves %+v %v, want v2", p, err) } legacyRaw, _, err := store.Get(ctx, release.LegacyChannelKey("demo", "stable")) if err != nil { t.Fatal(err) } if string(legacyRaw) == "" { t.Fatal("fixture: no legacy pointer") } reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "demo.example.com", Kind: "instance", App: "demo", Status: "ready", Addr: "127.0.0.1:9000"}); err != nil { t.Fatal(err) } reg.Close() out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") if err != nil { t.Fatalf("accounts create: %v\n%s", err, out) } sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] // The verb must SAY it saw both prefixes — an operator staring at a // half-migrated bucket needs to know that is what they are looking at. out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid, "-dry-run") if err != nil { t.Fatalf("dry run: %v\n%s", err, out) } if !strings.Contains(out, release.AppPrefix(release.OpsAccount, "demo")) || !strings.Contains(out, release.LegacyAppPrefix("demo")) { t.Errorf("dry run does not mention both source prefixes:\n%s", out) } // The live pointer was signed for ops, so it has to be re-signed for the // destination (discussion #220): without the root key the verb refuses // and says what it needs, rather than copying a pointer that would not // verify where it lands. out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) if err == nil || !strings.Contains(out, "CARLOS_RELEASE_KEY") { t.Fatalf("migrate of an account-bound pointer without the root key: err=%v\n%s", err, out) } env = append(env, "CARLOS_RELEASE_KEY="+seed) out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) if err != nil { t.Fatalf("migrate: %v\n%s", err, out) } // THE assertion: the destination serves what the runtime served, not the // stale legacy pointer. got, err := release.Channel(ctx, store, pub, sqid, "demo", "stable") if err != nil { t.Fatalf("resolve the migrated channel: %v", err) } if got.Version != "v2" { t.Fatalf("migrated pointer = %s, want v2 — the verb copied the STALE legacy pointer, "+ "which would downgrade the app on the next agent tick", got.Version) } // Both versions' release objects came across, so a rollback target still // resolves after the move. for _, ver := range []string{"v1", "v2"} { if _, err := release.GetManifest(ctx, store, sqid, "demo", ver); err != nil { t.Errorf("manifest %s missing after the move: %v", ver, err) } } // And nothing was deleted. if _, _, err := store.Get(ctx, release.LegacyChannelKey("demo", "stable")); err != nil { t.Errorf("migrate deleted a legacy object: %v", err) } } // The production flag does NOT travel. Its signed payload names the account, // so a byte copy is unverifiable at the destination forever — and an // unverifiable flag is exactly what every box reads as "keep baking", so the // migrated app's held heads would sit in a window with no expiry, silently // (verifyMigratedPointers checks channel pointers, not this). The verb skips // it and says so, loudly, because the alternative failure is invisible. func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) 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) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_RELEASE_PUBKEY="+pubB64, "CARLOS_RELEASE_KEY="+seed, // ops-bound pointers are re-signed for the destination "HOME="+tmp, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() return string(out), err } m := release.Manifest{Schema: 1, App: "demo", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v1"); err != nil { t.Fatal(err) } if err := release.SetProduction(ctx, store, priv, release.OpsAccount, "demo", true, "paul@keymail.dev"); err != nil { t.Fatal(err) } out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") if err != nil { t.Fatalf("accounts create: %v\n%s", err, out) } sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) if err != nil { t.Fatalf("migrate: %v\n%s", err, out) } // No flag object at the destination at all — a copied one would be worse // than none: none defaults to "not production", a copy bakes forever. if _, _, err := store.Get(ctx, release.ProductionKey(sqid, "demo")); err == nil { t.Fatal("the migration copied production.json — the copy names the source account and can never verify") } if f, err := release.GetProduction(ctx, store, pub, sqid, "demo"); err != nil || f != nil { t.Fatalf("destination flag = %+v, %v — want the clean default (no object, no error)", f, err) } // And the operator is TOLD, because nothing downstream will notice. want := "demo: carried a production declaration — re-declare it at the destination" if !strings.Contains(out, want) { t.Fatalf("migrate output does not name the flag it left behind (%q):\n%s", want, out) } // The source is untouched, as with every other object this verb moves. if f, err := release.GetProduction(ctx, store, pub, release.OpsAccount, "demo"); err != nil || f == nil || !f.Production { t.Fatalf("source flag = %+v, %v after the move, want it untouched and still on", f, err) } // The rest of the app still came across. if _, err := release.GetManifest(ctx, store, sqid, "demo", "v1"); err != nil { t.Errorf("manifest missing after the move: %v", err) } if p, err := release.Channel(ctx, store, pub, sqid, "demo", "stable"); err != nil || p.Version != "v1" { t.Errorf("migrated stable = %+v, %v, want v1", p, err) } } // A destination channel pointer that disagrees with the source must CONVERGE, // not strand the operator. Channel pointers are the one mutable thing in the // bucket — they move on every promote — so a stale or half-written one at the // destination is this migration's own leftover, not foreign data, and the // verb's job is to make the destination serve what the source serves. // // The alternative shape (refuse, and tell the operator to re-run) was a dead // end: the re-run would hit the create-only copy and die at "already exists // with different content", so the instruction forbade the only exit. func TestAccountsMigrateConvergesADivergedPointer(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) 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) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_RELEASE_PUBKEY="+pubB64, "CARLOS_RELEASE_KEY="+seed) // ops-bound pointers are re-signed for the destination run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env out, err := cmd.CombinedOutput() return string(out), err } for _, ver := range []string{"v1", "v2"} { m := release.Manifest{Schema: 1, App: "demo", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte(ver + " bytes")}); err != nil { t.Fatal(err) } } if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v2"); err != nil { t.Fatal(err) } reg, err := registry.Open(regPath) if err != nil { t.Fatal(err) } reg.Close() out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") if err != nil { t.Fatalf("accounts create: %v\n%s", err, out) } sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] // A half-finished earlier attempt: the destination already holds this // app's releases AND a pointer at the OLD version. (Copied the way the // verb copies — same bytes, so only the POINTER diverges.) srcKeys, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "demo")) if err != nil { t.Fatal(err) } for _, k := range srcKeys { if strings.Contains(k, "/channels/") { continue } body, _, err := store.Get(ctx, k) if err != nil { t.Fatal(err) } dstKey := release.AppPrefix(sqid, "demo") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "demo")) if _, err := store.Put(ctx, dstKey, body, ""); err != nil { t.Fatal(err) } } if err := release.Promote(ctx, store, priv, sqid, "demo", "stable", "v1"); err != nil { t.Fatal(err) } if p, _ := release.Channel(ctx, store, pub, sqid, "demo", "stable"); p == nil || p.Version != "v1" { t.Fatalf("fixture: destination pointer is %+v, want v1", p) } out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) if err != nil { t.Fatalf("migrate over a diverged destination pointer must converge, not strand:\n%s", out) } got, err := release.Channel(ctx, store, pub, sqid, "demo", "stable") if err != nil { t.Fatalf("resolve the migrated channel: %v", err) } if got.Version != "v2" { t.Fatalf("destination serves %s, want v2 — the migration did not converge the pointer", got.Version) } if !strings.Contains(out, "stable") { t.Errorf("the verb overwrote a pointer without saying so:\n%s", out) } // And it is re-runnable: that was the whole point. if out, err := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); err != nil { t.Fatalf("a second run must be a no-op:\n%s", out) } } // TestAddUnitBackingRequiresDBForHibernate pins the I1 fix's operator-facing // half: `carlos add -backing unit -hibernate` with no -db must refuse, // because ops.FleetDBs (the box-wide litestream unit) has no way to // discover a unit tenant's database path other than that flag, and a // hibernating unit tenant left unreplicated is a genuine data-loss risk the // moment systemd stops it for idleness. A non-hibernating unit route stays // unenforced (still the operator's call), and -db always persists onto the // route when given. func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) regPath := filepath.Join(tmp, "registry.db") // No -db: refused, non-zero exit, no row written. // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", "-backing", "unit", "-hibernate", "no-db.example.com") cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") cmd.Dir = tmp out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("add --backing unit --hibernate with no --db: want non-zero exit, got 0\n%s", out) } if !strings.Contains(string(out), "-db") { t.Errorf("add refusal output = %q, want it to mention --db", out) } // With -db: succeeds, and the route's DBPath is exactly what was given // (ops.FleetDBs reads this verbatim — no default synthesized here). dbPath := filepath.Join(tmp, "unit-state", "app.db") cmd = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com") cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") cmd.Dir = tmp out, err = cmd.CombinedOutput() if err != nil { t.Fatalf("add --backing unit --hibernate --db %s: %v\n%s", dbPath, err, out) } reg, err := registry.Open(regPath) if err != nil { t.Fatalf("open registry: %v", err) } routes, err := reg.List() if err != nil { t.Fatalf("list routes: %v", err) } var found bool for _, rt := range routes { if rt.Host != "with-db.example.com" { continue } found = true if rt.DBPath != dbPath { t.Errorf("with-db.example.com DBPath = %q, want %q", rt.DBPath, dbPath) } if rt.Backing != registry.BackingUnit { t.Errorf("with-db.example.com Backing = %q, want %q", rt.Backing, registry.BackingUnit) } } if !found { t.Fatal("with-db.example.com route missing after a successful add") } // The refused add must not have left a partial row behind. for _, rt := range routes { if rt.Host == "no-db.example.com" { t.Fatal("refused add left a route behind") } } } // A relative -db on an agent-exec route must be refused at add time. The // activation contract's "relative -db resolves inside $STATE_DIRECTORY" // convention is real for unit tenants (systemd sets it) but agent-exec // children are spawned without it and the activator consumes the path // verbatim (Restore, Seed, Start --db, Replicate) — so a relative path // resolves against an unwritable cwd and every wake dies as an edge 503, // discoverable only by correlating 503s with child stderr in the journal // (issue #70, vitogo's first deploy). func TestAddRefusesRelativeDBOnExecRoutes(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) regPath := filepath.Join(tmp, "registry.db") run := func(args ...string) (string, error) { cmd := exec.Command(carlosBin, args...) cmd.Env = carlosTestEnv(t) cmd.Dir = tmp out, err := cmd.CombinedOutput() return string(out), err } // The vitogo add, verbatim shape: exec backing (the default), relative -db. out, err := run("add", "-registry", regPath, "-app", "vitogo", "-kind", "instance", "-db", "vito.db", "-hibernate", "-channel", "canary/vitogo", "vito.example.com") if err == nil { t.Fatalf("add with relative --db on an exec route: want non-zero exit, got 0\n%s", out) } // The refusal must carry the diagnosis and the way out — not just "no". if !strings.Contains(out, "STATE_DIRECTORY") || !strings.Contains(out, "absolute") { t.Errorf("refusal = %q, want it to explain STATE_DIRECTORY and require an absolute path", out) } // And it must refuse BEFORE writing: no half-registered route. reg, err := registry.Open(regPath) if err != nil { t.Fatalf("open registry: %v", err) } defer reg.Close() rt, err := reg.Get("vito.example.com") if err != nil { t.Fatal(err) } if rt != nil { t.Fatalf("refused add still wrote a route: %+v", rt) } // A unit tenant keeps the contract: StateDirectory= is real there, a // relative -db is the documented convention, and this guard must not // take it away. out, err = run("add", "-registry", regPath, "-app", "vitogo", "-kind", "instance", "-backing", "unit", "-db", "vito.db", "unit.example.com") if err != nil { t.Fatalf("add --backing unit with relative --db: %v\n%s", err, out) } // An absolute -db on an exec route is untouched by the guard. out, err = run("add", "-registry", regPath, "-app", "vitogo", "-kind", "instance", "-db", "/data/vito.example.com.db", "vito.example.com") if err != nil { t.Fatalf("add with absolute --db: %v\n%s", err, out) } } // TestAddAccountIDRefusesAName pins the whole -account-id gate end to end, // on the verb where getting it wrong is quietest. `carlos add -account-id // ops` used to succeed: "ops" passes release.ValidAccount (^[a-z]{3,16}$), // so a bare shape check waved it through and stamped Account="ops" onto the // registry row — a phantom account (the real ops is Sqid(1) = "bab") that // every subsequent adopt and edge lookup would key off and find nothing // under. Exit 0, no error, nothing serving. The frozen alphabet has no i, l // or o, so domains.ValidSqid can refuse it for free; this test is the proof // the CLI actually does. // // The in-process half of this coverage is TestAccountIDShape // (accounts_resolve_test.go); the refusal itself is log.Fatal, so only a // real subprocess can observe the exit status and the absent row. func TestAddAccountIDRefusesAName(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) regPath := filepath.Join(tmp, "registry.db") out, err := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", "-account-id", "ops", "phantom.example.com").CombinedOutput() if err == nil { t.Fatalf("add --account-id ops: want non-zero exit, got 0\n%s", out) } // The message must send them to the right flag AND name the sqid they // actually wanted — "that's not a sqid" alone leaves them guessing. if !strings.Contains(string(out), "--account ops") || !strings.Contains(string(out), "bab") { t.Errorf("refusal = %q, want it to name --account ops and the real sqid bab", out) } // A name that is merely sqid-SHAPED is refused the same way when it // carries an i, l or o — the letters no id can ever encode to. out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", "-account-id", "oil", "phantom2.example.com").CombinedOutput() if err == nil { t.Fatalf("add --account-id oil: want non-zero exit, got 0\n%s", out) } if !strings.Contains(string(out), "-account") { t.Errorf("refusal = %q, want it to point at --account", out) } // A real sqid is accepted and stamped verbatim — never resolved against // the registry, which is the point of the flag: this one has no accounts // in it at all. out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", "-account-id", "bah", "real.example.com").CombinedOutput() if err != nil { t.Fatalf("add --account-id bah: %v\n%s", err, out) } reg, err := registry.Open(regPath) if err != nil { t.Fatalf("open registry: %v", err) } defer reg.Close() routes, err := reg.List() if err != nil { t.Fatalf("list routes: %v", err) } var seen int for _, rt := range routes { switch rt.Host { case "phantom.example.com", "phantom2.example.com": t.Errorf("refused add left a route behind (%s, account %q)", rt.Host, rt.Account) case "real.example.com": seen++ if rt.Account != "bah" { t.Errorf("route account = %q, want the --account-id sqid %q", rt.Account, "bah") } } } if seen != 1 { t.Errorf("found %d rows for real.example.com, want 1", seen) } } // carlos add on an existing host is an update and must say so — the vitogo // incident (issue #69) started with `added vito.platform.tito.io` printed // over what was actually a rewrite of an existing row. func TestAddOnExistingRouteSaysUpdated(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) regPath := filepath.Join(tmp, "registry.db") run := func(args ...string) (string, error) { cmd := exec.Command(carlosBin, args...) cmd.Env = carlosTestEnv(t) cmd.Dir = tmp out, err := cmd.CombinedOutput() return string(out), err } out, err := run("add", "-registry", regPath, "-app", "vitogo", "-db", "/data/first.db", "vito.example.com") if err != nil { t.Fatalf("first add: %v\n%s", err, out) } if !strings.Contains(out, "added vito.example.com") { t.Errorf("first add output = %q, want %q", out, "added vito.example.com") } out, err = run("add", "-registry", regPath, "-app", "vitogo", "-db", "/data/second.db", "vito.example.com") if err != nil { t.Fatalf("re-add: %v\n%s", err, out) } if !strings.Contains(out, "updated vito.example.com") { t.Errorf("re-add output = %q, want it to say updated, not added", out) } // And the rewrite must actually land — the row carries the new path. reg, err := registry.Open(regPath) if err != nil { t.Fatalf("open registry: %v", err) } defer reg.Close() rt, err := reg.Get("vito.example.com") if err != nil || rt == nil { t.Fatalf("get route: %v (rt=%v)", err, rt) } if rt.DBPath != "/data/second.db" { t.Errorf("db_path = %q, want /data/second.db", rt.DBPath) } } // An app still on the pre-accounts keys can be moved INTO ops. Until `-from // legacy` existed there was no way to say it: a bare move into ops is refused // (ops is also the default source, and the verb will not guess a direction), // and -from only took a sqid, which the account-less key space does not have. // That left every ops app stranded on legacy keys and made the read-fallback // impossible to retire. func TestAccountsMigrateLegacyIntoOps(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) 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) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_RELEASE_PUBKEY="+pubB64, "HOME="+tmp, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() return string(out), err } // A purely legacy app: the deployment store holds oldapp ONLY at the // account-less keys, which is the shape every deployment predating accounts // is in. Built in a scratch store and copied down, because a pointer's // signature covers app/channel/version rather than the key path — so these // are genuine legacy objects, signed by the same key a real deployment used. scratch := release.NewDirStore(filepath.Join(tmp, "scratch")) m := release.Manifest{Schema: 1, App: "oldapp", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "oldapp"}}} if err := release.Ship(ctx, scratch, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "oldapp", "stable", "v1"); err != nil { t.Fatal(err) } qualified, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "oldapp")) if err != nil { t.Fatal(err) } for _, k := range qualified { body, _, err := scratch.Get(ctx, k) if err != nil { t.Fatal(err) } legacy := release.LegacyAppPrefix("oldapp") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "oldapp")) if _, err := store.Put(ctx, legacy, body, ""); err != nil { t.Fatal(err) } } opsKey := release.ChannelKey(release.OpsAccount, "oldapp", "stable") if _, _, err := store.Get(ctx, opsKey); err == nil { t.Fatal("fixture: the qualified key already exists — the migration would prove nothing") } // Before the move, oldapp is INVISIBLE: the read-fallback is retired, so // nothing resolves it and the miss names the exact command that fixes it. // This is the state a deployment lands in by upgrading from a // pre-accounts binary — the reason this verb has to keep working forever. if _, err := release.Channel(ctx, store, pub, release.OpsAccount, "oldapp", "stable"); err == nil { t.Fatal("a legacy-only app resolved — the read-fallback is supposed to be retired") } else if !strings.Contains(err.Error(), "carlos accounts migrate --app oldapp --from legacy --to bab") { t.Errorf("the miss does not name the fix:\n%v", err) } // A bare move into ops is still refused — the ambiguity guard stays — and // the refusal now NAMES the way out. out, err := run("accounts", "migrate", "-registry", regPath, "-app", "oldapp", "-to", release.OpsAccount) if err == nil { t.Fatalf("a bare move into ops was accepted:\n%s", out) } if !strings.Contains(out, "--from legacy") { t.Errorf("the refusal does not name the fix:\n%s", out) } // Naming the legacy source works, and the destination then serves what the // source served. out, err = run("accounts", "migrate", "-registry", regPath, "-app", "oldapp", "-from", release.LegacySource, "-to", release.OpsAccount) if err != nil { t.Fatalf("legacy -> ops migration: %v\n%s", err, out) } if _, _, err := store.Get(ctx, opsKey); err != nil { t.Fatalf("the qualified pointer %s does not exist after the migration: %v\n%s", opsKey, err, out) } p, err := release.Channel(ctx, store, pub, release.OpsAccount, "oldapp", "stable") if err != nil { t.Fatalf("ops does not serve oldapp after the migration: %v\n%s", err, out) } if p.Version != "v1" { t.Errorf("ops serves %s, want v1", p.Version) } // The whole app moved, not just the pointer: the manifest and every // artifact must be at qualified keys too, or retiring the fallback would // take the app down. moved, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "oldapp")) if err != nil { t.Fatal(err) } if len(moved) != len(qualified) { t.Errorf("migrated %d objects, want all %d", len(moved), len(qualified)) } // "legacy" is a source, never a destination: the point of the verb is to // empty that key space, not to fill it. if out, err := run("accounts", "migrate", "-registry", regPath, "-app", "oldapp", "-to", release.LegacySource); err == nil { t.Fatalf("migrating INTO legacy was accepted:\n%s", out) } } func TestEnvCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) storeDir := filepath.Join(tmp, "store") regPath := filepath.Join(tmp, "registry.db") env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) run := func(args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } run("env", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "LOG_LEVEL=debug", "FEATURE_X=on") out := run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") for _, want := range []string{`(?m)^LOG_LEVEL\s+debug$`, `(?m)^FEATURE_X\s+on$`} { if !regexp.MustCompile(want).MatchString(out) { t.Fatalf("env list = %q, want a line matching %s", out, want) } } run("env", "unset", "-registry", regPath, "-account", "bab", "-app", "keymail", "FEATURE_X") out = run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") if strings.Contains(out, "FEATURE_X") { t.Fatalf("env list = %q, still shows unset FEATURE_X", out) } if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+debug$`).MatchString(out) { t.Fatalf("env list = %q, lost LOG_LEVEL after an unrelated unset", out) } // The manifest lands at the account-scoped key, matching every other // app key internal/release writes. store := release.NewDirStore(storeDir) if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil { t.Fatalf("config manifest not at the account-scoped key: %v", err) } } func TestSecretsCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) storeDir := filepath.Join(tmp, "store") regPath := filepath.Join(tmp, "registry.db") baseEnv := carlosTestEnv(t) run := func(env []string, args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } storeEnv := append(append([]string{}, baseEnv...), "CARLOS_DEPLOYMENT_DIR="+storeDir) keys := run(baseEnv, "secrets", "genkey") priv := regexp.MustCompile(`(?m)^CARLOS_SECRETS_KEY=(\S+)`).FindStringSubmatch(keys) pub := regexp.MustCompile(`(?m)^CARLOS_SECRETS_PUBKEY=(\S+)`).FindStringSubmatch(keys) if priv == nil || pub == nil { t.Fatalf("secrets genkey printed no key pair:\n%s", keys) } sealEnv := append(append([]string{}, storeEnv...), "CARLOS_SECRETS_PUBKEY="+pub[1]) run(sealEnv, "secrets", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "SES_SECRET=s3kr1t") out := run(sealEnv, "secrets", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") if !strings.Contains(out, "SES_SECRET") { t.Fatalf("secrets list = %q, want SES_SECRET listed", out) } if strings.Contains(out, "s3kr1t") { t.Fatalf("secrets list = %q, printed a secret value — it must only ever print key names", out) } // The value stored in the bucket is opaque ciphertext, not the plaintext. store := release.NewDirStore(storeDir) raw, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json") if err != nil { t.Fatalf("read config manifest: %v", err) } if strings.Contains(string(raw), "s3kr1t") { t.Fatalf("config.json contains the plaintext secret:\n%s", raw) } // And it decrypts back to the original plaintext with the private half — // the CLI's Seal and the library's Open agree on the wire format. var manifest appconfig.Manifest if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatalf("unmarshal manifest: %v", err) } privKey, err := appconfig.ParsePrivateKey(priv[1], "test") if err != nil { t.Fatalf("ParsePrivateKey: %v", err) } plain, err := appconfig.Open(privKey, appconfig.Binding{Account: "bab", App: "keymail"}, manifest.Vars["SES_SECRET"].Envelope) if err != nil { t.Fatalf("Open: %v", err) } if string(plain) != "s3kr1t" { t.Fatalf("decrypted secret = %q, want s3kr1t", plain) } } // TestEnvironmentBundlesCLI exercises `carlos env`/`carlos secrets -environment` // (2026-08-02-pipelines-design.md §4a, plan Task 4): named environment // bundles are the same manifest shape as config.json, at a sibling bucket // key, created implicitly on first `set` exactly like the default bundle — // and never touched by a write to the other. func TestEnvironmentBundlesCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) storeDir := filepath.Join(tmp, "store") env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) run := func(args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } runFails := func(args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out) } return string(out) } // Named-bundle round trip: set on a named environment, see it in that // environment's own `list`, unset removes it. run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL=trace") out := run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo") if !strings.Contains(out, "demo") { t.Fatalf("env list --environment demo = %q, want the bundle name in the header", out) } if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+trace$`).MatchString(out) { t.Fatalf("env list --environment demo = %q, want LOG_LEVEL=trace", out) } // The count is of REMOVALS, not of arguments (review MINOR 9): a key that // was never set must not read as a successful removal. out = run("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL", "NEVER_SET") if !strings.Contains(out, "unset 1 of 2") { t.Fatalf("env unset = %q, want it to report 1 of 2 keys actually removed", out) } out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo") if strings.Contains(out, "LOG_LEVEL") { t.Fatalf("env list --environment demo = %q, LOG_LEVEL survived unset", out) } // Review CRITICAL 2: unsetting from a bundle that does not exist is a // refusal, not a create — for a named bundle AND for the default one. // Creating it would mint a manifest that EXISTS with zero vars, which is // the state config sync converges every one of that app's env files to // empty for, as root. msg := runFails("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "neverexisted", "ANY") if !strings.Contains(msg, "nothing to unset") { t.Fatalf("unset on a nonexistent bundle = %q, want it to say there is nothing to unset", msg) } msg = runFails("secrets", "unset", "-account", "bab", "-app", "keymail-never-configured", "ANY") if !strings.Contains(msg, "nothing to unset") { t.Fatalf("secrets unset on a never-configured app = %q, want a refusal", msg) } store := release.NewDirStore(storeDir) if _, _, err := store.Get(context.Background(), "apps/bab/keymail-never-configured/config.json"); err == nil { t.Fatal("an unset against a never-configured app created its config.json") } // Default untouched by a named-bundle write: set something on demo, and // on a second, never-before-seen environment; the default bundle (no // -environment) still shows nothing. run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "STRIPE_MODE=sandbox") run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "staging", "STRIPE_MODE=staging-sandbox") out = run("env", "list", "-account", "bab", "-app", "keymail") if !strings.Contains(out, "default") { t.Fatalf("env list (no --environment) = %q, want the bundle header to say default", out) } if strings.Contains(out, "STRIPE_MODE") { t.Fatalf("env list (no --environment) = %q, a named-bundle write leaked into the default bundle", out) } // And the reverse: write the default bundle, and it does not appear // under the named ones. run("env", "set", "-account", "bab", "-app", "keymail", "BASE_ONLY=1") out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo") if strings.Contains(out, "BASE_ONLY") { t.Fatalf("env list --environment demo = %q, a default-bundle write leaked into a named bundle", out) } // `carlos env environments -app ` lists the bundle names created // implicitly above, sorted, and nothing else (no "default" entry — that // bundle isn't a named environment). out = run("env", "environments", "-account", "bab", "-app", "keymail") names := strings.Fields(strings.TrimSpace(out)) if want := []string{"demo", "staging"}; !reflect.DeepEqual(names, want) { t.Fatalf("env environments = %q, want %v", names, want) } // An app with no named environments lists none, not an error. out = run("env", "environments", "-account", "bab", "-app", "keymail-bare") if strings.TrimSpace(out) != "" { t.Fatalf("env environments for a bare app = %q, want empty", out) } // Reserved-name refusal: "default" is refused, with a message that // explains default already names the base bundle rather than a bare // "invalid name". An invalid-charset name is refused too. msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1") if !strings.Contains(msg, "default") || !strings.Contains(strings.ToLower(msg), "base bundle") { t.Fatalf("env set --environment default = %q, want a refusal naming default as the base bundle", msg) } msg = runFails("secrets", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1") if !strings.Contains(msg, "default") { t.Fatalf("secrets set --environment default = %q, want a refusal", msg) } msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "Not_Valid!", "X=1") if !strings.Contains(msg, "Not_Valid!") { t.Fatalf("env set --environment Not_Valid! = %q, want a refusal naming the bad value", msg) } // "-environment \"\"" is indistinguishable from omitting the flag — the // CLI's sentinel for "the default bundle", never a name to refuse. Not a // refusal case: it must succeed, against the default bundle. out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "") if !strings.Contains(out, "default") { t.Fatalf(`env list --environment "" = %q, want it treated as the default bundle`, out) } // The named bundle lands at the sibling bucket key the design specifies. if _, _, err := store.Get(context.Background(), "apps/bab/keymail/environments/demo.json"); err != nil { t.Fatalf("named environment manifest not at the sibling key: %v", err) } // And the default bundle is untouched in shape and key. if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil { t.Fatalf("default config manifest missing: %v", err) } } // TestExecDeliveryCLI exercises `carlos env exec-delivery` (issues #18, // #195): the opt-in that lets SyncExec materialize /data/.config.env // for TCP-addr exec routes — round-trip at app scope, round-trip at // environment scope, unknown-value refusal, and the two scope-specific // clearing words refusing each other's ("off" vs "inherit"), following // TestEnvCLI's build-a-real-binary pattern. func TestExecDeliveryCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) storeDir := filepath.Join(tmp, "store") env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) run := func(args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } runFails := func(args ...string) string { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out) } return string(out) } // Round trip: off by default (not mentioned in a plain list), "file" // turns it on and a plain `env list` mentions it, "off" clears it // again and the mention disappears. run("env", "set", "-account", "bab", "-app", "titogo", "K=1") out := run("env", "list", "-account", "bab", "-app", "titogo") if strings.Contains(out, "exec-delivery") { t.Fatalf("env list before opt-in = %q, must not mention exec-delivery", out) } out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "file") if !strings.Contains(out, "titogo") || !strings.Contains(out, "file") { t.Fatalf("env exec-delivery file = %q, want a confirmation naming the app and the value", out) } out = run("env", "list", "-account", "bab", "-app", "titogo") if !regexp.MustCompile(`(?m)^exec-delivery:\s*file$`).MatchString(out) { t.Fatalf("env list after opt-in = %q, want an exec-delivery: file line", out) } // The var set earlier survives — exec-delivery is a field write, not a // bundle replace. if !regexp.MustCompile(`(?m)^K\s+1$`).MatchString(out) { t.Fatalf("env list after opt-in = %q, lost K=1 (exec-delivery must not clobber existing vars)", out) } out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "off") if !strings.Contains(out, "off") { t.Fatalf("env exec-delivery off = %q, want a confirmation", out) } out = run("env", "list", "-account", "bab", "-app", "titogo") if strings.Contains(out, "exec-delivery") { t.Fatalf("env list after off = %q, must no longer mention exec-delivery", out) } // Unknown values are refused at set time, naming the bad value. msg := runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "grpc") if !strings.Contains(msg, "grpc") { t.Fatalf("env exec-delivery grpc = %q, want a refusal naming the bad value", msg) } msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "on") // not the spelling if !strings.Contains(msg, "on") { t.Fatalf("env exec-delivery on = %q, want a refusal naming the bad value", msg) } store := release.NewDirStore(storeDir) // Named-environment placement (issue #195): the flag lands in THAT // bundle, and a plain (default-bundle) listing does not mention it — // the whole point being that opting one bundle in must not read as // opting the app in. out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "file") if !strings.Contains(out, "demo") || !strings.Contains(out, "file") { t.Fatalf("env exec-delivery --environment demo file = %q, want a confirmation naming the bundle and the value", out) } out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") if !regexp.MustCompile(`(?m)^exec-delivery:\s*file$`).MatchString(out) { t.Fatalf("env list --environment demo = %q, want an exec-delivery: file line", out) } out = run("env", "list", "-account", "bab", "-app", "titogo") if strings.Contains(out, "exec-delivery") { t.Fatalf("default-bundle env list = %q, must not report a NAMED bundle's flag", out) } // "off" is refused for a named bundle, by name: clearing there means // inherit, and a verb that spelled it "off" would read as "stop // delivering" while doing the opposite. msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "off") if !strings.Contains(msg, "inherit") { t.Fatalf("env exec-delivery --environment demo off = %q, want a refusal naming \"inherit\"", msg) } out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") if !strings.Contains(out, "exec-delivery") { t.Fatalf("the refused \"off\" cleared the bundle's flag anyway: %q", out) } // "inherit" clears it. out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "inherit") if !strings.Contains(out, "inherit") { t.Fatalf("env exec-delivery --environment demo inherit = %q, want a confirmation", out) } out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") if strings.Contains(out, "exec-delivery") { t.Fatalf("env list --environment demo after inherit = %q, must no longer mention exec-delivery", out) } // And "inherit" is refused at APP scope, for the mirror-image reason: // the default bundle has nothing above it to inherit from. msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "inherit") if !strings.Contains(msg, "off") { t.Fatalf("env exec-delivery inherit (no environment) = %q, want a refusal naming \"off\"", msg) } // Turning off against an app that was never configured at all is // refused, not a silent create — same idiom as `env unset` on a // nonexistent bundle. msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo-never-configured", "off") if !strings.Contains(msg, "nothing to turn off") { t.Fatalf("env exec-delivery off on a never-configured app = %q, want a refusal", msg) } if _, _, err := store.Get(context.Background(), "apps/bab/titogo-never-configured/config.json"); err == nil { t.Fatal("env exec-delivery off on a never-configured app created its config.json") } } // TestEnvironmentMergeVisibleFromCLI is the merge-visible check the plan // asks for: set the default bundle plus a named override through the CLI // exactly as an operator would, then verify — via the library's LoadMerged, // the same function Sync calls per bound route — that the merged view a // route bound to that environment would actually receive has the override // applied and everything else inherited from default. func TestEnvironmentMergeVisibleFromCLI(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) storeDir := filepath.Join(tmp, "store") env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) run := func(args ...string) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) } } run("env", "set", "-account", "bab", "-app", "tito", "STRIPE_KEY=sk_base", "LOG_LEVEL=info", "REGION=eu") run("env", "set", "-account", "bab", "-app", "tito", "-environment", "demo", "STRIPE_KEY=sk_demo") store := release.NewDirStore(storeDir) merged, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "demo") if err != nil { t.Fatalf("LoadMerged: %v", err) } want := map[string]string{"STRIPE_KEY": "sk_demo", "LOG_LEVEL": "info", "REGION": "eu"} if len(merged.Vars) != len(want) { t.Fatalf("merged vars = %v, want exactly %v", merged.Vars, want) } for k, v := range want { got, ok := merged.Vars[k] if !ok || got.Value != v { t.Fatalf("merged.Vars[%s] = %+v, want value %q", k, got, v) } } // A route with no -environment binding — envName=="" — sees the default // bundle alone, the override absent. base, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "") if err != nil { t.Fatalf("LoadMerged (default): %v", err) } if base.Vars["STRIPE_KEY"].Value != "sk_base" { t.Fatalf("default-bound LoadMerged saw STRIPE_KEY=%q, want sk_base — the demo override leaked into the unbound route", base.Vars["STRIPE_KEY"].Value) } } // TestRouteCLI exercises `carlos route`, the verb that lets a route's channel // subscription change without raw sqlite against a production registry. The // safety rail IS the point of the verb: a route only repoints when the // 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) { tmp := t.TempDir() carlosBin := sharedCarlos(t) 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 := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_RELEASE_PUBKEY="+pubB64, "HOME="+tmp, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp 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 tmpDir string store release.Store priv ed25519.PrivateKey pubB64 string } func newRouteTestFixture(t *testing.T) *routeTestFixture { t.Helper() tmp := t.TempDir() bin := sharedCarlos(t) 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"), tmpDir: tmp, 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 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = f.tmpDir out, err := cmd.CombinedOutput() return string(out), err } func (f *routeTestFixture) fullEnv() []string { return carlosTestEnv(f.t, "CARLOS_DEPLOYMENT_DIR="+f.storeDir, "CARLOS_RELEASE_PUBKEY="+f.pubB64, "HOME="+f.tmpDir, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) } 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 } 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) 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 := carlosTestEnv(f.t, "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 := carlosTestEnv(f.t, "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 := carlosTestEnv(f.t, "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) } } // Design §5: once a route is Provisioned, its Channel and Environment are // the instance record's. A write here lands and is reverted within seconds // of the next serial bump, silently — the same class of bug as #130, // arriving from the other side. The refusal names the record as the door, // and no flag overrides it: -allow-version-change answers "what would this // change", not "who owns this column". func TestRouteChannelRefusedOnProvisionedRoute(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "site", "v1") f.promote(release.OpsAccount, "site", "stable", "v1") f.promote(release.OpsAccount, "site", "canary/preview", "v1") f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", Channel: "canary/preview", Status: "ready", Provisioned: true}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "acme.go.tito.io", "-channel", "stable", "-allow-version-change") if err == nil { t.Fatalf("route --channel on a Provisioned route was accepted:\n%s", out) } if !strings.Contains(out, "record") { t.Errorf("refusal does not name the instance record as the door:\n%s", out) } if got := f.channelOf("acme.go.tito.io"); got != "canary/preview" { t.Fatalf("channel = %q, want it unwritten after the refusal", got) } } func TestRouteEnvironmentRefusedOnProvisionedRoute(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "site", "v1") f.promote(release.OpsAccount, "site", "stable", "v1") f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", Channel: "stable", Status: "ready", Provisioned: true}) f.saveEnvironment(release.OpsAccount, "site", "demo") out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "acme.go.tito.io", "-environment", "demo") if err == nil { t.Fatalf("route --environment on a Provisioned route was accepted:\n%s", out) } if got := f.environmentOf("acme.go.tito.io"); got != "" { t.Fatalf("environment = %q, want it unwritten after the refusal", got) } // Clearing is equally not this verb's to do — the refusal is on the ROW, // not on one direction of travel, exactly as the Platform+Canary one is. f.addRoute(registry.Route{Host: "bound.go.tito.io", Kind: "instance", App: "site", Channel: "stable", Status: "ready", Provisioned: true, Environment: "demo"}) out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "bound.go.tito.io", "-environment", "-") if err == nil { t.Fatalf("clearing an environment on a Provisioned route was accepted:\n%s", out) } if got := f.environmentOf("bound.go.tito.io"); got != "demo" { t.Fatalf("environment = %q, want it still bound after the refusal", got) } } // Task 11 item 5 (carried from Task 9's review): the sidecar reconcile pass // (internal/provision/sidecar_reconcile.go, convergeSidecar) converges // Hibernate from the app's sidecars manifest on EVERY pass — unconditionally, // not the ordinary instance record's tri-state (absent-means-free) column a // Provisioned instance route carries. A `-hibernate` write on such a row // lands and is reverted within seconds of the next serial bump, with nothing // said anywhere — the same class of bug #130 was, on the axis this repo has // not yet paid the lesson for. // // The reserved sidecar suffix (suffixRail) already refuses every genuine // sidecar row through a different door — every one is minted under // provision.SidecarSuffix by construction — so this test uses a host OUTSIDE // that suffix to isolate the NEW check from that existing one: this is // deliberately the shape suffixRail's own doc comment calls out as not // hypothetical (an ordinary-kind row's Provisioned+Kind combination is what // this rail reads, not the hostname). func TestRouteHibernateRefusedOnProvisionedSidecarRow(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "bot.eleven.bdm.example.com", Kind: "sidecar", App: "eleven", Account: "bdm", Status: "ready", Provisioned: true}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "bot.eleven.bdm.example.com", "-hibernate=false") if err == nil { t.Fatalf("route -hibernate on a provisioned sidecar row was accepted:\n%s", out) } if !strings.Contains(out, "sidecar") || !strings.Contains(out, provision.SidecarManifestKey("bdm", "eleven")) { t.Errorf("refusal does not name the sidecar manifest as the door:\n%s", out) } if got := f.routeOf("bot.eleven.bdm.example.com").Hibernate; got != false { t.Fatalf("hibernate = %v, want it unwritten after the refusal", got) } } // TestRouteHibernateRefusedOnGenuineSidecarSuffixRow is the end-to-end proof // against a REAL sidecar host shape (under provision.SidecarSuffix): both // rails agree, and the operator-facing message names the reconcile pass, not // just "reserved suffix". func TestRouteHibernateRefusedOnGenuineSidecarSuffixRow(t *testing.T) { f := newRouteTestFixture(t) host := provision.SidecarHost("bot", "eleven", "bdm") f.addRoute(registry.Route{Host: host, Kind: "sidecar", App: "eleven", Account: "bdm", Status: "ready", Provisioned: true}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", host, "-hibernate") if err == nil { t.Fatalf("route -hibernate on a genuine sidecar row was accepted:\n%s", out) } if got := f.routeOf(host).Hibernate; got != false { t.Fatalf("hibernate = %v, want it unwritten after the refusal", got) } } // The box-scoped axes are untouched: they name resources that exist on one // machine, the record does not own them, and an operator must still be able // to grant a port or stamp a unit on a provisioned route. func TestRouteBoxScopedAxesStillWorkOnProvisionedRoute(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", Channel: "stable", Status: "ready", Provisioned: true, Addr: "127.0.0.1:8090"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "acme.go.tito.io", "-unit", "console.service") if err != nil { t.Fatalf("route --unit on a Provisioned route was refused:\n%s", out) } if got := f.routeOf("acme.go.tito.io").Unit; got != "console.service" { t.Errorf("unit = %q, want console.service", got) } // -unit short-circuits before the verdict machinery this change touched // (the grantAxis/hibernateSet/backingSet/unitSet block returns early, well // above chanVerdicts/envVerdicts). -tunnel does not: it runs through the // same candidates slice and sits downstream of the refusal-collection // loops recordOwnedRow feeds, so it is the axis a future recordOwnedRow // case could plausibly catch by mistake. Exercise it directly. out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "acme.go.tito.io", "-tunnel", "relay1") if err != nil { t.Fatalf("route --tunnel on a Provisioned route was refused:\n%s", out) } if got := f.routeOf("acme.go.tito.io").Tunnel; got != "relay1" { t.Errorf("tunnel = %q, want relay1", 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) } } // --- Task 2: Route.Environment + `carlos route -environment` -------------- // Bind then clear: -environment requires the bundle to exist in the // bucket (the existence rail), and -environment - clears back to default // with no bucket check at all. func TestRouteEnvironmentBindClearRoundTrip(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) f.saveEnvironment(release.OpsAccount, "tito", "demo") env := f.fullEnv() out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "demo") if err != nil { t.Fatalf("route --environment demo: %v\n%s", err, out) } if !strings.Contains(out, "default -> demo") { t.Errorf("output does not show the bind:\n%s", out) } if got := f.environmentOf("demo.tito.example.com"); got != "demo" { t.Fatalf("environment = %q, want demo", got) } out, err = f.run(env, "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "-") if err != nil { t.Fatalf("route --environment -: %v\n%s", err, out) } if !strings.Contains(out, "demo -> default") { t.Errorf("output does not show the clear:\n%s", out) } if got := f.environmentOf("demo.tito.example.com"); got != "" { t.Fatalf("environment = %q, want empty (default) after clearing", got) } } // A bundle that was never created (or was created for a different app) is // refused, not silently bound — the whole point of the existence rail (spec // §4a: "binding a bundle that does not exist in the bucket is refused, not // recorded"). The refusal names the fix. func TestRouteEnvironmentNonexistentBundleRefused(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "nosuchenv") if err == nil { t.Fatalf("binding a nonexistent environment was accepted:\n%s", out) } if !strings.Contains(out, "nosuchenv") || !strings.Contains(out, "carlos env set") { t.Errorf("refusal does not name the bundle or the fix:\n%s", out) } if got := f.environmentOf("demo.tito.example.com"); got != "" { t.Fatalf("environment changed despite the refusal: %q", got) } } // An invalid environment NAME (bad shape, or the reserved "default") is // refused before any bucket check — no CARLOS_DEPLOYMENT_DIR/BUCKET is even // required for this refusal to fire. func TestRouteEnvironmentInvalidNameRefused(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) for _, bad := range []string{"Has-Upper", "has_underscore", strings.Repeat("a", 33)} { out, err := f.run(carlosTestEnv(f.t), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", bad) if err == nil { t.Fatalf("invalid environment name %q was accepted:\n%s", bad, out) } if !strings.Contains(out, "not a valid environment name") { t.Errorf("refusal of %q does not name the reason:\n%s", bad, out) } } // "default" is refused for its OWN reason — it names the base bundle, it // is not merely malformed (review MINOR 7). Asserting the reason is what // keeps this pinned to the reservation: with the reservation removed, the // existence rail would still refuse "default", for an unrelated reason, // and an assertion on err alone would stay green. out, err := f.run(carlosTestEnv(f.t), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "default") if err == nil { t.Fatalf("--environment default was accepted:\n%s", out) } if !strings.Contains(out, "base bundle") { t.Errorf("refusal of \"default\" does not explain that it already names the base bundle:\n%s", out) } } // -environment alone (no -channel) requires neither -channel's pubkey nor // its version-comparison rail — the two axes are independent all the way // down to which env vars a run needs. func TestRouteEnvironmentAloneNeedsNoPubkey(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) f.saveEnvironment(release.OpsAccount, "tito", "demo") env := carlosTestEnv(f.t, "CARLOS_DEPLOYMENT_DIR="+f.storeDir) // no CARLOS_RELEASE_PUBKEY out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "demo") if err != nil { t.Fatalf("route --environment with no CARLOS_RELEASE_PUBKEY: %v\n%s", err, out) } if got := f.environmentOf("demo.tito.example.com"); got != "demo" { t.Fatalf("environment = %q, want demo", got) } } // REGRESSION PIN: -channel alone must never touch Environment — the two // columns are independent, and a channel-only invocation is the common // case (every existing `carlos route -channel` caller). If SetChannels ever // grew a write to the environment column, this would catch it. func TestRouteChannelAloneNeverTouchesEnvironment(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "tito", "v1") f.promote(release.OpsAccount, "tito", "canary/x", "v1") f.promote(release.OpsAccount, "tito", "stable", "v1") f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Channel: "canary/x", Version: "v1", Status: "ready", Environment: "demo"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-channel", "stable") if err != nil { t.Fatalf("route --channel: %v\n%s", err, out) } if got := f.channelOf("demo.tito.example.com"); got != "stable" { t.Fatalf("channel = %q, want stable", got) } if got := f.environmentOf("demo.tito.example.com"); got != "demo" { t.Fatalf("environment = %q after a --channel-only run, want untouched demo", got) } } // -app bulk atomicity for -environment: within one -account all sweep, one // account's app has the named bundle and the other does not — the whole // batch must refuse, and NEITHER account's route may move, even the one // whose bundle genuinely exists. func TestRouteEnvironmentAppBulkAllOrNothingAcrossAccounts(t *testing.T) { f := newRouteTestFixture(t) accOut, err := f.run(f.fullEnv(), "accounts", "create", "-registry", f.regPath, "-name", "second", "-owner", "second@keymail.dev") if err != nil { t.Fatalf("accounts create: %v\n%s", err, accOut) } m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(accOut) if m == nil { t.Fatalf("accounts create printed no sqid:\n%s", accOut) } sqid := m[1] f.saveEnvironment(release.OpsAccount, "acctapp", "demo") // only the ops account has it f.addRoute(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance", App: "acctapp", Account: release.OpsAccount, Status: "ready"}) f.addRoute(registry.Route{Host: "second-acctapp.example.com", Kind: "instance", App: "acctapp", Account: sqid, Status: "ready"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-app", "acctapp", "-environment", "demo", "-account", "all") if err == nil { t.Fatalf("a batch spanning an account without the bundle was accepted:\n%s", out) } if !strings.Contains(out, "second-acctapp.example.com") { t.Errorf("output does not name the failing route:\n%s", out) } if got := f.environmentOf("ops-acctapp.example.com"); got != "" { t.Fatalf("ops-account route moved despite the refused batch (not atomic): %q", got) } if got := f.environmentOf("second-acctapp.example.com"); got != "" { t.Fatalf("second-account route environment = %q, want untouched empty", got) } } // Combined -channel/-environment: both rails clear, both columns move in // one write. func TestRouteCombinedChannelAndEnvironmentBothWrite(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "tito", "v1") f.promote(release.OpsAccount, "tito", "canary/x", "v1") f.promote(release.OpsAccount, "tito", "stable", "v1") f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Channel: "canary/x", Version: "v1", Status: "ready"}) f.saveEnvironment(release.OpsAccount, "tito", "demo") out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-channel", "stable", "-environment", "demo") if err != nil { t.Fatalf("route --channel --environment: %v\n%s", err, out) } if got := f.channelOf("demo.tito.example.com"); got != "stable" { t.Fatalf("channel = %q, want stable", got) } if got := f.environmentOf("demo.tito.example.com"); got != "demo" { t.Fatalf("environment = %q, want demo", got) } } // Combined atomicity: the environment bundle exists (that rail would pass // cleanly) but the channel rail refuses (a genuine version change without // -allow-version-change) — NEITHER column may move. Proves the two rails // share one gate before either axis writes, not two independent writes that // happen to usually succeed together. func TestRouteCombinedAtomicityChannelRefusalBlocksEnvironmentToo(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "widget", "v1") f.ship(release.OpsAccount, "widget", "v2") f.promote(release.OpsAccount, "widget", "canary/widget", "v1") f.promote(release.OpsAccount, "widget", "stable", "v2") f.addRoute(registry.Route{Host: "widget.example.com", Kind: "instance", App: "widget", Channel: "canary/widget", Version: "v1", Status: "ready"}) f.saveEnvironment(release.OpsAccount, "widget", "demo") out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "widget.example.com", "-channel", "stable", "-environment", "demo") if err == nil { t.Fatalf("a combined write with a genuine version change was accepted without --allow-version-change:\n%s", out) } if got := f.channelOf("widget.example.com"); got != "canary/widget" { t.Fatalf("channel changed despite the refusal: %q", got) } if got := f.environmentOf("widget.example.com"); got != "" { t.Fatalf("environment moved despite the channel rail's refusal (not atomic): %q", got) } } // Unlike Channel, -environment is allowed on a Platform+Canary route — the // domains reconciler never derives Environment for a minted route (only // Channel, for static mints), so this verb owns the column unconditionally // on every route kind. // Review IMPORTANT 4: a Platform+Canary route's whole ROW is the domains // reconciler's, not this verb's — its canary-minting pass re-Upserts the row // from mintedRoute's zero values whenever its `followed` check goes false. // Upsert now preserves a non-empty Environment (IMPORTANT 3), which makes the // binding survivable rather than silently reverted, but a column the // reconciler rebuilds is still not one this verb hands an operator. The // refusal is structural, exactly like the Channel one, and shares its // function (platformCanaryOwnedRow). func TestRouteEnvironmentRefusedOnPlatformCanaryRoute(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "site", "v1") f.promote(release.OpsAccount, "site", "canary/preview", "v1") f.addRoute(registry.Route{Host: "preview.site.bab.apps.example.com", Kind: "static", App: "site", Channel: "canary/preview", Status: "ready", Platform: true, Canary: true}) f.saveEnvironment(release.OpsAccount, "site", "demo") out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "preview.site.bab.apps.example.com", "-environment", "demo") if err == nil { t.Fatalf("route --environment on a Platform+Canary route was accepted:\n%s", out) } if !strings.Contains(out, "reconciler") { t.Errorf("refusal does not say who owns the row:\n%s", out) } if got := f.environmentOf("preview.site.bab.apps.example.com"); got != "" { t.Fatalf("environment = %q, want it unwritten after the refusal", got) } // The refusal is on the ROW, not on one direction of travel: a binding // made before this rail existed (or by an older binary) is equally not // this verb's to CLEAR. Only a genuine no-op stays quiet, matching the // channel axis, which also refuses moving back to stable. f.addRoute(registry.Route{Host: "legacy.site.bab.apps.example.com", Kind: "static", App: "site", Channel: "canary/preview", Status: "ready", Platform: true, Canary: true, Environment: "demo"}) out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "legacy.site.bab.apps.example.com", "-environment", "-") if err == nil { t.Fatalf("clearing an environment on a Platform+Canary route was accepted:\n%s", out) } if got := f.environmentOf("legacy.site.bab.apps.example.com"); got != "demo" { t.Fatalf("environment = %q, want the pre-existing binding left alone by the refusal", got) } // A no-op is not a refusal — nothing is being written. if out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "preview.site.bab.apps.example.com", "-environment", "-"); err != nil { t.Fatalf("a no-op clear on an unbound Platform+Canary route was refused: %v\n%s", err, out) } } // Review IMPORTANT 3: `carlos add` on an existing host must not unbind it. // add has no -environment flag, so a plain re-add (a routine operation — it // is how a route's socket, backing or db path is corrected) used to clear the // binding, and the next config sync then rendered the default bundle over // that route's env file and restarted the unit. func TestAddOnABoundHostPreservesItsEnvironment(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) f.saveEnvironment(release.OpsAccount, "tito", "demo") if out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "demo"); err != nil { t.Fatalf("route --environment demo: %v\n%s", err, out) } if out, err := f.run(f.fullEnv(), "add", "-registry", f.regPath, "-app", "tito", "demo.tito.example.com"); err != nil { t.Fatalf("re-add: %v\n%s", err, out) } if got := f.environmentOf("demo.tito.example.com"); got != "demo" { t.Fatalf("environment = %q after `carlos add` on a bound host, want demo", got) } } // Review CRITICAL 2, end-to-end: a typo'd `env unset -environment` must not // mint the bundle it names. A phantom empty bundle satisfies the route // existence rail — whose entire purpose is refusing a bundle that is not // there — and a route bound to one silently loses the environment it was on. func TestRouteEnvironmentRailNotSatisfiableByATypoedUnset(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) out, err := f.run(f.fullEnv(), "env", "unset", "-app", "tito", "-environment", "typoo", "NOTHING") if err == nil { t.Fatalf("env unset against a nonexistent bundle succeeded:\n%s", out) } if !strings.Contains(out, "nothing to unset") { t.Errorf("refusal does not say there was nothing to unset:\n%s", out) } out, err = f.run(f.fullEnv(), "env", "environments", "-app", "tito") if err != nil { t.Fatalf("env environments: %v\n%s", err, out) } if strings.Contains(out, "typoo") { t.Fatalf("the unset minted a phantom bundle:\n%s", out) } out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "typoo") if err == nil { t.Fatalf("the existence rail accepted a bundle that was never created:\n%s", out) } if got := f.environmentOf("demo.tito.example.com"); got != "" { t.Fatalf("environment = %q, want it unwritten", got) } } // -dry-run on -environment runs the full existence rail and prints the // plan, but writes nothing — same contract as -channel's -dry-run. func TestRouteEnvironmentDryRunWritesNothing(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) f.saveEnvironment(release.OpsAccount, "tito", "demo") out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com", "-environment", "demo", "-dry-run") if err != nil { t.Fatalf("route --environment --dry-run: %v\n%s", err, out) } if !strings.Contains(out, "dry run") { t.Errorf("output does not say this was a dry run:\n%s", out) } if got := f.environmentOf("demo.tito.example.com"); got != "" { t.Fatalf("dry run wrote an environment change: %q", got) } } // Neither -channel nor -environment given is a usage error naming the // route-creation "" convention, not a silent no-op. func TestRouteNeitherChannelNorEnvironmentIsUsageError(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com") if err == nil { t.Fatalf("neither --channel nor --environment was accepted:\n%s", out) } if !strings.Contains(out, "stable-by-default") { t.Errorf("refusal does not explain \"\" at route creation vs. this verb:\n%s", out) } } // A legacy-source migration must never move a QUALIFIED pointer backwards. // The legacy copy of a channel pointer is stale the moment anything promotes // on the qualified key, and "-from legacy" taking legacy as truth converged // the flagship console's canary pointer from 3c7c7bc back to a day-old // 771c2f0 on 2026-08-03 — a production rollback armed by a routine cleanup, // caught only because the next adopt pass printed the downgrade. The verb // now keeps a differing qualified pointer and says so. func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) { tmp := t.TempDir() carlosBin := sharedCarlos(t) 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) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_RELEASE_PUBKEY="+pubB64, "HOME="+tmp, "CARLOS_CONSOLE=", "CARLOS_DESTINATION=", ) run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(carlosBin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() return string(out), err } // The console shape: v1 exists at LEGACY keys (built via scratch store, // same signed-bytes trick as TestAccountsMigrateLegacyIntoOps), and v2 has // since been shipped and promoted on the QUALIFIED key — live state. scratch := release.NewDirStore(filepath.Join(tmp, "scratch")) m1 := release.Manifest{Schema: 1, App: "consoleish", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "consoleish"}}} if err := release.Ship(ctx, scratch, release.OpsAccount, m1, map[string][]byte{"linux-amd64": []byte("v1")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "consoleish", "canary/main", "v1"); err != nil { t.Fatal(err) } qualified, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "consoleish")) if err != nil { t.Fatal(err) } for _, k := range qualified { // No legacy-era ship wrote a kind marker (rootfs apps Plan 1), and the // qualified ship of v2 below writes its own, so the legacy copy has none. if k == release.AppKindMarkerKey(release.OpsAccount, "consoleish") { continue } body, _, err := scratch.Get(ctx, k) if err != nil { t.Fatal(err) } legacy := release.LegacyAppPrefix("consoleish") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "consoleish")) if _, err := store.Put(ctx, legacy, body, ""); err != nil { t.Fatal(err) } } m2 := release.Manifest{Schema: 1, App: "consoleish", Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "consoleish"}}} if err := release.Ship(ctx, store, release.OpsAccount, m2, map[string][]byte{"linux-amd64": []byte("v2")}); err != nil { t.Fatal(err) } if err := release.Promote(ctx, store, priv, release.OpsAccount, "consoleish", "canary/main", "v2"); err != nil { t.Fatal(err) } out, err := run("accounts", "migrate", "-registry", regPath, "-app", "consoleish", "-from", release.LegacySource, "-to", release.OpsAccount) if err != nil { t.Fatalf("migrate: %v\n%s", err, out) } if !strings.Contains(out, "KEPT") { t.Errorf("the kept qualified pointer was not announced:\n%s", out) } // THE assertion: the qualified pointer still serves v2. Before the fix it // was converged back to v1 and this resolves the rollback. p, err := release.Channel(ctx, store, pub, release.OpsAccount, "consoleish", "canary/main") if err != nil { t.Fatal(err) } if p.Version != "v2" { t.Fatalf("qualified pointer rolled back to %s — the legacy copy won", p.Version) } // And v1's artifacts still migrated (rollback targets preserved). if _, _, err := store.Get(ctx, release.AppPrefix(release.OpsAccount, "consoleish")+"releases/v1/manifest.json"); err != nil { t.Errorf("v1's manifest did not migrate: %v", err) } } // routeOf returns a whole registry row, for the assertions that need more // than one column of it. func (f *routeTestFixture) routeOf(host string) registry.Route { 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 } // TestRouteBackingRefusesAnUnknownValue is the 2026-08-03 review's I1: // runRouteBacking's doc comment already claimed the value was checked "at the // flag before the registry is even opened", and nothing performed the check. // registry.SetBacking writes the column verbatim, and release.Adoptable // compares against BackingUnit EXACTLY — so `-backing unti` was accepted, // stored "unti", and left the route neither unit-backed nor visibly wrong: the // migration's one write, silently a no-op that only shows up as an app that // never gets its capabilities. // // The refusal has to NAME both legal values, because the empty one is a real // value of this column and an operator cannot guess that from a rejection. func TestRouteBackingRefusesAnUnknownValue(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Channel: "stable", Status: "ready"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unti") if err == nil { t.Fatalf("a misspelled --backing value was accepted:\n%s", out) } for _, want := range []string{"unti", "unit", `""`} { if !strings.Contains(out, want) { t.Errorf("refusal does not name %q:\n%s", want, out) } } if got := f.routeOf("keymail.dev").Backing; got != "" { t.Fatalf("backing column = %q after a refusal, want it untouched", got) } } // TestRouteBackingIsExclusiveWithTheOtherAxes is I2: -backing is a // single-route axis exactly like -grant/-revoke and -hibernate, and it was not // held to their exclusions. Every combination below was a SILENT PARTIAL — // runRoute's switch runs grantAxis, then hibernateSet, then -backing, and the // channel/environment branch is unreachable once any of them is set — so the // command did SOME of what was typed and reported success for all of it. And // `-app x -backing unit` fell through to a registry lookup of the empty host, // answering `no route for host ""` instead of the require-host message. func TestRouteBackingIsExclusiveWithTheOtherAxes(t *testing.T) { f := newRouteTestFixture(t) f.ship(release.OpsAccount, "keymail", "v1") f.promote(release.OpsAccount, "keymail", "stable", "v1") f.promote(release.OpsAccount, "keymail", "canary/x", "v1") f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Channel: "stable", Version: "v1", Status: "ready"}) cases := []struct { name string args []string want string }{ { // The repoint would never have happened: the axis branch returns // before the channel machinery is reached. name: "--backing with --channel", args: []string{"-host", "keymail.dev", "-backing", "unit", "-channel", "canary/x"}, want: "-channel", }, { name: "--backing with --environment", args: []string{"-host", "keymail.dev", "-backing", "unit", "-environment", "eu"}, want: "-environment", }, { // The dangerous ordering: -hibernate is evaluated FIRST, so this // used to write the hibernate column and drop the backing stamp. name: "--hibernate=false with --backing", args: []string{"-host", "keymail.dev", "-hibernate=false", "-backing", "unit"}, want: "mutually exclusive", }, { name: "--grant with --backing", args: []string{"-host", "keymail.dev", "-grant", "port:25", "-backing", "unit"}, want: "mutually exclusive", }, { name: "--app with --backing gets the require-host message", args: []string{"-app", "keymail", "-backing", "unit"}, want: "require --host", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { args := append([]string{"route", "-registry", f.regPath}, c.args...) out, err := f.run(f.fullEnv(), args...) if err == nil { t.Fatalf("%s was accepted:\n%s", c.name, out) } if !strings.Contains(out, c.want) { t.Errorf("refusal does not contain %q:\n%s", c.want, out) } if strings.Contains(out, `no route for host ""`) { t.Errorf("refusal is the empty-host lookup, not an axis rail:\n%s", out) } // Nothing written, on any axis, by any of them. rt := f.routeOf("keymail.dev") if rt.Backing != "" || rt.Hibernate || rt.Caps != "" || rt.Channel != "stable" || rt.Environment != "" { t.Fatalf("a refused command still wrote: %+v", rt) } }) } } // The other half of the same rail: -backing ALONE, on its own route, still // works — and prints what the next adopt pass will do with the new value. // Without this the exclusions above could be satisfied by a verb that refuses // everything. func TestRouteBackingStampsAndSaysWhatHappensNext(t *testing.T) { f := newRouteTestFixture(t) f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Channel: "stable", Status: "ready"}) out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unit") if err != nil { t.Fatalf("route --backing unit: %v\n%s", err, out) } if got := f.routeOf("keymail.dev").Backing; got != registry.BackingUnit { t.Fatalf("backing column = %q, want %q", got, registry.BackingUnit) } if !strings.Contains(out, "capability drop-ins") { t.Errorf("the stamp does not say what the next adopt pass will do:\n%s", out) } // And back again — the migration's reversibility depends on it. out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "") if err != nil { t.Fatalf(`route --backing "": %v\n%s`, err, out) } if got := f.routeOf("keymail.dev").Backing; got != "" { t.Fatalf("backing column = %q after clearing, want empty", got) } }