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