| 1 | // End-to-end smoke of the release/serve/rollback loop, AWS-free: a DirStore |
| 2 | // deployment bucket, a plain-HTTP edge on a TCP port, a real compiled tenant |
| 3 | // (testdata/echoapp) behind a TCP-addr route, a static site behind a |
| 4 | // channel-tracked static route, and the fleet status page behind a |
| 5 | // bucket-backed kind=status route. Host-side adoption (release.Pass, symlink |
| 6 | // repointing) is internal/host territory and is deliberately NOT exercised |
| 7 | // here — the loop this test proves is: |
| 8 | // |
| 9 | // 1. the edge routes to a live backend by Host header, |
| 10 | // 2. a shipped+promoted static tree is served through the edge, |
| 11 | // 3. promote advances the served content, rollback returns the pointer to |
| 12 | // the previous version, and the static handler swaps back on re-check. |
| 13 | // 4. one fleet status publish tick lands status/{index.html,fleet.json} in |
| 14 | // the same deployment bucket, and the edge's kind=status route serves |
| 15 | // both back out through the identical Host-routed path. |
| 16 | package main_test |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "crypto/ed25519" |
| 21 | "encoding/json" |
| 22 | "fmt" |
| 23 | "io" |
| 24 | "net" |
| 25 | "net/http" |
| 26 | "net/http/httptest" |
| 27 | "os" |
| 28 | "os/exec" |
| 29 | "path/filepath" |
| 30 | "reflect" |
| 31 | "regexp" |
| 32 | "runtime" |
| 33 | "strings" |
| 34 | "testing" |
| 35 | "time" |
| 36 | |
| 37 | platform "github.com/carlosframework/platform" |
| 38 | "github.com/carlosframework/platform/internal/appconfig" |
| 39 | "github.com/carlosframework/platform/internal/domains" |
| 40 | "github.com/carlosframework/platform/internal/edge" |
| 41 | "github.com/carlosframework/platform/internal/provision" |
| 42 | "github.com/carlosframework/platform/internal/registry" |
| 43 | "github.com/carlosframework/platform/internal/release" |
| 44 | "github.com/carlosframework/platform/internal/status" |
| 45 | "github.com/carlosframework/platform/internal/vet" |
| 46 | ) |
| 47 | |
| 48 | // e2eGet GETs the edge with an explicit Host header, the way a real client |
| 49 | // reaches a routed hostname. |
| 50 | func e2eGet(t *testing.T, base, host, path string) (int, string) { |
| 51 | t.Helper() |
| 52 | req, err := http.NewRequest("GET", base+path, nil) |
| 53 | if err != nil { |
| 54 | t.Fatalf("new request: %v", err) |
| 55 | } |
| 56 | req.Host = host |
| 57 | resp, err := http.DefaultClient.Do(req) |
| 58 | if err != nil { |
| 59 | t.Fatalf("GET %s%s (host %s): %v", base, path, host, err) |
| 60 | } |
| 61 | defer resp.Body.Close() |
| 62 | b, err := io.ReadAll(resp.Body) |
| 63 | if err != nil { |
| 64 | t.Fatalf("read body: %v", err) |
| 65 | } |
| 66 | return resp.StatusCode, string(b) |
| 67 | } |
| 68 | |
| 69 | // carlosTestEnv is os.Environ() with the operator's real login and console |
| 70 | // state masked: CARLOS_CREDENTIALS points into the test's own tempdir where |
| 71 | // no credential exists, HOME is a scratch dir (the binary reads |
| 72 | // ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared. |
| 73 | // Every subprocess that runs the carlos binary MUST build its env through |
| 74 | // this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors |
| 75 | // for .carlos/config, so a subprocess running from the real checkout reads |
| 76 | // the developer's real config the moment one exists. A test that inherits |
| 77 | // the real ~/.carlos is one `go test ./...` on a logged-in laptop away from |
| 78 | // writing to a production console — which is exactly how 40+ test accounts |
| 79 | // got minted on the flagship (2026-08-04). |
| 80 | // |
| 81 | // Duplicated in scopedkeys_e2e_test.go: this file is package main_test (an |
| 82 | // external test package), that one is package main, and the two cannot |
| 83 | // share an unexported helper across the package boundary. |
| 84 | func carlosTestEnv(t *testing.T, extra ...string) []string { |
| 85 | t.Helper() |
| 86 | scratch := t.TempDir() |
| 87 | // An inherited CARLOS_SERVICE_TOKEN (a CI shell that exports one) would |
| 88 | // make any child that also sets CARLOS_DEPLOYMENT_DIR fail resolveMode's |
| 89 | // token-plus-store refusal, so it is dropped here. It is filtered rather |
| 90 | // than blanked: a test that passes its own token in extra still wins. |
| 91 | var env []string |
| 92 | for _, kv := range os.Environ() { |
| 93 | if !strings.HasPrefix(kv, "CARLOS_SERVICE_TOKEN=") { |
| 94 | env = append(env, kv) |
| 95 | } |
| 96 | } |
| 97 | env = append(env, |
| 98 | "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| 99 | "HOME="+scratch, |
| 100 | "CARLOS_CONSOLE=", |
| 101 | "CARLOS_DESTINATION=", |
| 102 | ) |
| 103 | return append(env, extra...) |
| 104 | } |
| 105 | |
| 106 | // sharedCarlos is the one shared carlos build (auth_e2e_test.go), reached |
| 107 | // through export_test.go: this file is package main_test and cannot see |
| 108 | // package main's test helpers any other way. The binary is built at most once |
| 109 | // per test process, whichever package asks first, and is read-only. |
| 110 | func sharedCarlos(t testing.TB) string { |
| 111 | t.Helper() |
| 112 | return platform.SharedCarlosForTest(t) |
| 113 | } |
| 114 | |
| 115 | // The external package gets the very file the internal one does: one build |
| 116 | // for the process, whoever asked first, read-only. |
| 117 | func TestSharedCarlosFromTheExternalPackage(t *testing.T) { |
| 118 | if testing.Short() { |
| 119 | t.Skip("builds a binary") |
| 120 | } |
| 121 | t.Chdir(t.TempDir()) |
| 122 | first, second := sharedCarlos(t), sharedCarlos(t) |
| 123 | if first != second { |
| 124 | t.Fatalf("calls gave %s and %s, want one path", first, second) |
| 125 | } |
| 126 | if n := platform.SharedCarlosBuildsForTest(); n != 1 { |
| 127 | t.Errorf("%d builds in this process, want 1", n) |
| 128 | } |
| 129 | info, err := os.Stat(first) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o555 { |
| 134 | t.Errorf("mode %v, want r-xr-xr-x", info.Mode().Perm()) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func e2eShipSite(t *testing.T, store release.Store, version string, files map[string]string) { |
| 139 | t.Helper() |
| 140 | dir := t.TempDir() |
| 141 | for name, body := range files { |
| 142 | p := filepath.Join(dir, filepath.FromSlash(name)) |
| 143 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 144 | t.Fatalf("mkdir: %v", err) |
| 145 | } |
| 146 | if err := os.WriteFile(p, []byte(body), 0o644); err != nil { |
| 147 | t.Fatalf("write %s: %v", name, err) |
| 148 | } |
| 149 | } |
| 150 | tarball, err := edge.PackDir(dir) |
| 151 | if err != nil { |
| 152 | t.Fatalf("PackDir: %v", err) |
| 153 | } |
| 154 | m := release.Manifest{ |
| 155 | Schema: 1, App: "site", Version: version, Kind: "static", |
| 156 | Artifacts: map[string]release.Artifact{"site.tar.gz": {}}, |
| 157 | } |
| 158 | if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{"site.tar.gz": tarball}); err != nil { |
| 159 | t.Fatalf("Ship site %s: %v", version, err) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func e2eShipBinary(t *testing.T, store release.Store, version string, bin []byte) { |
| 164 | t.Helper() |
| 165 | m := release.Manifest{ |
| 166 | Schema: 1, App: "echoapp", Version: version, Kind: "binary", |
| 167 | Artifacts: map[string]release.Artifact{"echoapp": {}}, |
| 168 | } |
| 169 | if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{"echoapp": bin}); err != nil { |
| 170 | t.Fatalf("Ship echoapp %s: %v", version, err) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // The carlos-app@.socket listener handoff, systemd-free: the test plays |
| 175 | // root systemd — binds the tenant's unix socket itself, passes the fd as fd |
| 176 | // 3 with LISTEN_FDS=1 — and proves the tenant serves on the inherited |
| 177 | // listener without ever touching the socket path. This is the contract that |
| 178 | // lets the socket file stay carlos-owned 0600 (only the edge connects, |
| 179 | // tenants can't reach each other) while a DynamicUser tenant owns the |
| 180 | // process. |
| 181 | func TestTenantInheritsActivationSocket(t *testing.T) { |
| 182 | goBin, err := exec.LookPath("go") |
| 183 | if err != nil { |
| 184 | t.Skip("go toolchain not found") |
| 185 | } |
| 186 | tmp := t.TempDir() |
| 187 | appBin := filepath.Join(tmp, "echoapp") |
| 188 | build := exec.Command(goBin, "build", "-o", appBin, "./testdata/echoapp") |
| 189 | build.Env = os.Environ() |
| 190 | if out, err := build.CombinedOutput(); err != nil { |
| 191 | t.Fatalf("build echoapp: %v\n%s", err, out) |
| 192 | } |
| 193 | |
| 194 | // Short path — sun_path is ~104 bytes and t.TempDir() can blow it. |
| 195 | sock := filepath.Join(os.TempDir(), fmt.Sprintf("carlos-e2e-%d.sock", os.Getpid())) |
| 196 | os.Remove(sock) |
| 197 | t.Cleanup(func() { os.Remove(sock) }) |
| 198 | ln, err := net.Listen("unix", sock) |
| 199 | if err != nil { |
| 200 | if strings.Contains(err.Error(), "operation not permitted") { |
| 201 | t.Skipf("unix-socket listeners not permitted in this environment: %v", err) |
| 202 | } |
| 203 | t.Fatalf("listen unix: %v", err) |
| 204 | } |
| 205 | defer ln.Close() |
| 206 | f, err := ln.(*net.UnixListener).File() |
| 207 | if err != nil { |
| 208 | t.Fatalf("listener fd: %v", err) |
| 209 | } |
| 210 | defer f.Close() |
| 211 | |
| 212 | app := exec.Command(appBin) // no -socket, no -addr: the fd is the listener |
| 213 | app.ExtraFiles = []*os.File{f} // child fd 3, where sd_listen_fds starts |
| 214 | app.Env = append(os.Environ(), "LISTEN_FDS=1", "BUILD=sockv1") |
| 215 | if err := app.Start(); err != nil { |
| 216 | t.Fatalf("start echoapp: %v", err) |
| 217 | } |
| 218 | t.Cleanup(func() { |
| 219 | _ = app.Process.Kill() |
| 220 | _, _ = app.Process.Wait() |
| 221 | }) |
| 222 | |
| 223 | client := &http.Client{Transport: &http.Transport{ |
| 224 | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| 225 | return (&net.Dialer{}).DialContext(ctx, "unix", sock) |
| 226 | }, |
| 227 | }} |
| 228 | get := func(path string) (int, string, error) { |
| 229 | resp, err := client.Get("http://tenant.local" + path) |
| 230 | if err != nil { |
| 231 | return 0, "", err |
| 232 | } |
| 233 | defer resp.Body.Close() |
| 234 | b, _ := io.ReadAll(resp.Body) |
| 235 | return resp.StatusCode, string(b), nil |
| 236 | } |
| 237 | deadline := time.Now().Add(10 * time.Second) |
| 238 | for { |
| 239 | code, body, err := get("/healthz") |
| 240 | if err == nil && code == 200 && body == "ok" { |
| 241 | break |
| 242 | } |
| 243 | if time.Now().After(deadline) { |
| 244 | t.Fatalf("tenant never healthy on the inherited listener: %d %q %v", code, body, err) |
| 245 | } |
| 246 | time.Sleep(100 * time.Millisecond) |
| 247 | } |
| 248 | if code, body, err := get("/"); err != nil || code != 200 || !strings.Contains(body, "BUILD=sockv1") { |
| 249 | t.Fatalf("GET / over inherited socket = %d %q %v, want 200 containing BUILD=sockv1", code, body, err) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestEndToEndSmoke(t *testing.T) { |
| 254 | goBin, err := exec.LookPath("go") |
| 255 | if err != nil { |
| 256 | t.Skip("go toolchain not found; skipping e2e smoke") |
| 257 | } |
| 258 | ctx := context.Background() |
| 259 | tmp := t.TempDir() |
| 260 | |
| 261 | // --- build the tenant --------------------------------------------------- |
| 262 | appBin := filepath.Join(tmp, "echoapp") |
| 263 | build := exec.Command(goBin, "build", "-o", appBin, "./testdata/echoapp") |
| 264 | build.Env = os.Environ() |
| 265 | if out, err := build.CombinedOutput(); err != nil { |
| 266 | t.Fatalf("build echoapp: %v\n%s", err, out) |
| 267 | } |
| 268 | binBytes, err := os.ReadFile(appBin) |
| 269 | if err != nil { |
| 270 | t.Fatalf("read echoapp: %v", err) |
| 271 | } |
| 272 | |
| 273 | // --- release plane: DirStore + signing keys ----------------------------- |
| 274 | store := release.NewDirStore(filepath.Join(tmp, "store")) |
| 275 | seedB64, pubB64, err := release.GenerateKey() |
| 276 | if err != nil { |
| 277 | t.Fatalf("GenerateKey: %v", err) |
| 278 | } |
| 279 | priv, err := release.ParseSigningKey(seedB64, "e2e") |
| 280 | if err != nil { |
| 281 | t.Fatalf("ParseSigningKey: %v", err) |
| 282 | } |
| 283 | var pub ed25519.PublicKey |
| 284 | if pub, err = release.ParsePublicKey(pubB64, "e2e"); err != nil { |
| 285 | t.Fatalf("ParsePublicKey: %v", err) |
| 286 | } |
| 287 | |
| 288 | // Ship + promote the binary app and the static site to canary/x. |
| 289 | e2eShipBinary(t, store, "v1", binBytes) |
| 290 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x", "v1"); err != nil { |
| 291 | t.Fatalf("Promote echoapp v1: %v", err) |
| 292 | } |
| 293 | e2eShipSite(t, store, "v1", map[string]string{"index.html": "site v1", "docs/page.html": "docs v1"}) |
| 294 | e2eShipSite(t, store, "v2", map[string]string{"index.html": "site v2"}) |
| 295 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| 296 | t.Fatalf("Promote site v1: %v", err) |
| 297 | } |
| 298 | |
| 299 | // The shipped binary release passes vet (boot check self-skips where the |
| 300 | // sandbox forbids unix listeners). |
| 301 | if probs := vet.Check(ctx, store, pub, release.OpsAccount, "echoapp", "v1", true); len(probs) != 0 { |
| 302 | t.Fatalf("vet.Check echoapp v1 = %v, want clean", probs) |
| 303 | } |
| 304 | |
| 305 | // --- backend process on a TCP port (NOT a unix socket: sandbox) --------- |
| 306 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 307 | if err != nil { |
| 308 | t.Fatalf("pick port: %v", err) |
| 309 | } |
| 310 | backendAddr := ln.Addr().String() |
| 311 | ln.Close() |
| 312 | app := exec.Command(appBin, "-addr", backendAddr) |
| 313 | app.Env = append(os.Environ(), "BUILD=v1") |
| 314 | if err := app.Start(); err != nil { |
| 315 | t.Fatalf("start echoapp: %v", err) |
| 316 | } |
| 317 | t.Cleanup(func() { |
| 318 | _ = app.Process.Kill() |
| 319 | _, _ = app.Process.Wait() |
| 320 | }) |
| 321 | waitUp := func() { |
| 322 | deadline := time.Now().Add(10 * time.Second) |
| 323 | for time.Now().Before(deadline) { |
| 324 | resp, err := http.Get("http://" + backendAddr + "/healthz") |
| 325 | if err == nil { |
| 326 | resp.Body.Close() |
| 327 | if resp.StatusCode == 200 { |
| 328 | return |
| 329 | } |
| 330 | } |
| 331 | time.Sleep(100 * time.Millisecond) |
| 332 | } |
| 333 | t.Fatalf("echoapp never became healthy on %s", backendAddr) |
| 334 | } |
| 335 | waitUp() |
| 336 | |
| 337 | // --- registry + edge (dev shape: plain HTTP on a TCP port) -------------- |
| 338 | reg, err := registry.Open(filepath.Join(tmp, "registry.db")) |
| 339 | if err != nil { |
| 340 | t.Fatalf("open registry: %v", err) |
| 341 | } |
| 342 | if err := reg.Upsert(registry.Route{Host: "app.local", Kind: "service", Addr: backendAddr, App: "echoapp", Channel: "canary/x", Status: "ready"}); err != nil { |
| 343 | t.Fatalf("upsert app route: %v", err) |
| 344 | } |
| 345 | if err := reg.Upsert(registry.Route{Host: "site.local", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil { |
| 346 | t.Fatalf("upsert site route: %v", err) |
| 347 | } |
| 348 | if err := reg.Upsert(registry.Route{Host: "status.local", Kind: "status", Status: "ready"}); err != nil { |
| 349 | t.Fatalf("upsert status route: %v", err) |
| 350 | } |
| 351 | e, err := edge.New(reg, nil) |
| 352 | if err != nil { |
| 353 | t.Fatalf("edge.New: %v", err) |
| 354 | } |
| 355 | e.EnableStatic(store, pub, filepath.Join(tmp, "static-cache")) |
| 356 | e.SetStaticRecheck(0) // re-check the channel pointer on every request |
| 357 | if err := e.Reload(); err != nil { |
| 358 | t.Fatalf("edge.Reload: %v", err) |
| 359 | } |
| 360 | srv := httptest.NewServer(e) |
| 361 | defer srv.Close() |
| 362 | |
| 363 | // (1) The edge routes by Host header to the live backend. |
| 364 | if code, body := e2eGet(t, srv.URL, "app.local", "/"); code != 200 || !strings.Contains(body, "BUILD=v1") { |
| 365 | t.Fatalf("GET app.local / = %d %q, want 200 containing BUILD=v1", code, body) |
| 366 | } |
| 367 | if code, body := e2eGet(t, srv.URL, "app.local", "/healthz"); code != 200 || body != "ok" { |
| 368 | t.Fatalf("GET app.local /healthz = %d %q, want 200 ok", code, body) |
| 369 | } |
| 370 | if code, _ := e2eGet(t, srv.URL, "nobody.local", "/"); code != http.StatusNotFound { |
| 371 | t.Fatalf("GET unknown host = %d, want 404", code) |
| 372 | } |
| 373 | |
| 374 | // (2) The static site is served from the shipped+promoted tree. |
| 375 | if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v1" { |
| 376 | t.Fatalf("GET site.local / = %d %q, want 200 %q", code, body, "site v1") |
| 377 | } |
| 378 | if code, body := e2eGet(t, srv.URL, "site.local", "/docs/page.html"); code != 200 || body != "docs v1" { |
| 379 | t.Fatalf("GET site.local /docs/page.html = %d %q, want 200 %q", code, body, "docs v1") |
| 380 | } |
| 381 | if code, _ := e2eGet(t, srv.URL, "site.local", "/missing"); code != http.StatusNotFound { |
| 382 | t.Fatalf("GET site.local /missing = %d, want 404", code) |
| 383 | } |
| 384 | |
| 385 | // (3) Promote advances the served site; rollback swaps it back. |
| 386 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { |
| 387 | t.Fatalf("Promote site v2: %v", err) |
| 388 | } |
| 389 | if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v2" { |
| 390 | t.Fatalf("after promote, GET site.local / = %d %q, want 200 %q", code, body, "site v2") |
| 391 | } |
| 392 | if err := release.Rollback(ctx, store, priv, release.OpsAccount, "site", "canary/x"); err != nil { |
| 393 | t.Fatalf("Rollback site: %v", err) |
| 394 | } |
| 395 | cp, err := release.Channel(ctx, store, pub, release.OpsAccount, "site", "canary/x") |
| 396 | if err != nil { |
| 397 | t.Fatalf("Channel site after rollback: %v", err) |
| 398 | } |
| 399 | if cp.Version != "v1" || len(cp.History) == 0 || cp.History[0] != "v2" { |
| 400 | t.Fatalf("rolled-back pointer = %s (history %v), want v1 with v2 first in history", cp.Version, cp.History) |
| 401 | } |
| 402 | if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v1" { |
| 403 | t.Fatalf("after rollback, GET site.local / = %d %q, want 200 %q", code, body, "site v1") |
| 404 | } |
| 405 | |
| 406 | // (4) The fleet status page: one publish tick, then served through the |
| 407 | // same edge by Host header. statusTick (internal/host/agent.go) is |
| 408 | // unexported and lives in a different package, so this composes the |
| 409 | // identical seam it runs on every 5-minute tick — CollectBox -> WriteBox |
| 410 | // -> BuildFleet -> PublishFleet — directly over the same DirStore the |
| 411 | // release plane above already writes to (EnableStatic wired that same |
| 412 | // `store` in as the status route's backing store too). |
| 413 | statusRoutes, err := reg.List() |
| 414 | if err != nil { |
| 415 | t.Fatalf("list routes for status tick: %v", err) |
| 416 | } |
| 417 | tickNow := time.Now() |
| 418 | box := status.CollectBox(statusRoutes, status.Probes{MachineID: "e2e-box"}, tickNow) |
| 419 | if err := status.WriteBox(ctx, store, box); err != nil { |
| 420 | t.Fatalf("WriteBox: %v", err) |
| 421 | } |
| 422 | fleet, err := status.BuildFleet(ctx, store, statusRoutes, tickNow, 5*time.Minute) |
| 423 | if err != nil { |
| 424 | t.Fatalf("BuildFleet: %v", err) |
| 425 | } |
| 426 | if err := status.PublishFleet(ctx, store, fleet); err != nil { |
| 427 | t.Fatalf("PublishFleet: %v", err) |
| 428 | } |
| 429 | |
| 430 | if code, body := e2eGet(t, srv.URL, "status.local", "/"); code != 200 || !strings.Contains(body, "Fleet status") { |
| 431 | t.Fatalf("GET status.local / = %d %q, want 200 containing %q", code, body, "Fleet status") |
| 432 | } |
| 433 | code, body := e2eGet(t, srv.URL, "status.local", "/fleet.json") |
| 434 | if code != 200 { |
| 435 | t.Fatalf("GET status.local /fleet.json = %d, want 200", code) |
| 436 | } |
| 437 | // DisallowUnknownFields turns this decode into a schema check too: a |
| 438 | // stray top-level key (one Fleet doesn't declare) fails the test instead |
| 439 | // of silently vanishing into an unused field. |
| 440 | var decodedFleet status.Fleet |
| 441 | dec := json.NewDecoder(strings.NewReader(body)) |
| 442 | dec.DisallowUnknownFields() |
| 443 | if err := dec.Decode(&decodedFleet); err != nil { |
| 444 | t.Fatalf("decode status.local /fleet.json: %v\nbody: %s", err, body) |
| 445 | } |
| 446 | if decodedFleet.GeneratedAt.IsZero() { |
| 447 | t.Errorf("fleet.json generated_at is zero, want the publish tick's timestamp") |
| 448 | } |
| 449 | wantLabel := status.Label("e2e-box") // deterministic from the fixture MachineID above |
| 450 | if len(decodedFleet.Boxes) != 1 || decodedFleet.Boxes[0].Label != wantLabel { |
| 451 | t.Errorf("fleet.json boxes = %+v, want exactly one box labeled %q", decodedFleet.Boxes, wantLabel) |
| 452 | } |
| 453 | // Every route CollectBox saw falls into exactly one of static/resident/ |
| 454 | // hibernating, so instances.total is always the route count — app.local |
| 455 | // (service), site.local (static), status.local (status): 3 routes, one box. |
| 456 | wantTotal := len(statusRoutes) |
| 457 | if got := decodedFleet.Instances["total"]; got == nil || *got != wantTotal { |
| 458 | t.Errorf("fleet.json instances.total = %v, want %d (one box over %d routes)", got, wantTotal, wantTotal) |
| 459 | } |
| 460 | |
| 461 | // (5) Domains: one reconcile pass mints the app's platform domain under |
| 462 | // the account its BUCKET KEYS say it belongs to, and the edge serves it by |
| 463 | // Host header over the same echo backend — every app is born addressable. |
| 464 | // echoapp was shipped under ops above, so ops is where it mints; the |
| 465 | // two-account case is TestMultiAccountEndToEnd. |
| 466 | acctSqid := release.OpsAccount |
| 467 | dcfg := domains.Config{ |
| 468 | AppsDomain: "oncarlos.test", |
| 469 | EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| 470 | Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| 471 | Now: time.Now, |
| 472 | } |
| 473 | if err := domains.Reconcile(ctx, store, reg, dcfg); err != nil { |
| 474 | t.Fatalf("domains.Reconcile: %v", err) |
| 475 | } |
| 476 | // The per-box health pass follows the reconcile, as domainsLoop runs it (#303). |
| 477 | if err := domains.StampHealth(ctx, reg, dcfg, nil); err != nil { |
| 478 | t.Fatalf("domains.StampHealth: %v", err) |
| 479 | } |
| 480 | platformHost := "echoapp." + acctSqid + ".oncarlos.test" |
| 481 | prt, err := reg.Get(platformHost) |
| 482 | if err != nil { |
| 483 | t.Fatalf("get platform route: %v", err) |
| 484 | } |
| 485 | if prt == nil { |
| 486 | t.Fatalf("reconcile minted no platform route %s", platformHost) |
| 487 | } |
| 488 | if !prt.Platform || prt.Addr != backendAddr || prt.Account != acctSqid { |
| 489 | t.Fatalf("platform route = %+v, want platform-flagged, echo upstream, account %s", prt, acctSqid) |
| 490 | } |
| 491 | if prt.DNSStatus != "pointing" { |
| 492 | t.Fatalf("platform route dns_status = %q, want pointing (fake resolver says edge)", prt.DNSStatus) |
| 493 | } |
| 494 | // In production the fingerprint watcher reloads the edge; here the test |
| 495 | // plays the watcher. |
| 496 | if err := e.Reload(); err != nil { |
| 497 | t.Fatalf("edge.Reload after reconcile: %v", err) |
| 498 | } |
| 499 | if code, body := e2eGet(t, srv.URL, platformHost, "/"); code != 200 || !strings.Contains(body, "BUILD=v1") { |
| 500 | t.Fatalf("GET %s / = %d %q, want 200 containing BUILD=v1 (the platform domain serves the app)", platformHost, code, body) |
| 501 | } |
| 502 | |
| 503 | // Binary channel pointer flow: ship v2 (same bytes — a rebuild), promote, |
| 504 | // then roll back; the pointer must return to v1 with v2 as the rollback |
| 505 | // target. (Applying the pointer on a box is host/agent territory.) |
| 506 | e2eShipBinary(t, store, "v2", binBytes) |
| 507 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x", "v2"); err != nil { |
| 508 | t.Fatalf("Promote echoapp v2: %v", err) |
| 509 | } |
| 510 | cp, err = release.Channel(ctx, store, pub, release.OpsAccount, "echoapp", "canary/x") |
| 511 | if err != nil { |
| 512 | t.Fatalf("Channel echoapp: %v", err) |
| 513 | } |
| 514 | if cp.Version != "v2" || len(cp.History) == 0 || cp.History[0] != "v1" { |
| 515 | t.Fatalf("promoted pointer = %s (history %v), want v2 over v1", cp.Version, cp.History) |
| 516 | } |
| 517 | if err := release.Rollback(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x"); err != nil { |
| 518 | t.Fatalf("Rollback echoapp: %v", err) |
| 519 | } |
| 520 | cp, err = release.Channel(ctx, store, pub, release.OpsAccount, "echoapp", "canary/x") |
| 521 | if err != nil { |
| 522 | t.Fatalf("Channel echoapp after rollback: %v", err) |
| 523 | } |
| 524 | if cp.Version != "v1" || len(cp.History) == 0 || cp.History[0] != "v2" { |
| 525 | t.Fatalf("rolled-back pointer = %s (history %v), want v1 with v2 first in history", cp.Version, cp.History) |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | // TestStatusCLI exercises the actual `carlos status` command line, not just |
| 530 | // the function underneath it: builds the real carlos binary the same way |
| 531 | // this file already builds testdata/echoapp, then runs it as a subprocess |
| 532 | // exactly the way an operator would from a shell. |
| 533 | // |
| 534 | // - `carlos status -dry-run <dir>` must exit 0 and its stdout must carry |
| 535 | // DryRun's "dry-run: no leaks" attestation — proving cli.go's RunStatus |
| 536 | // is wired to internal/status.DryRun correctly. DryRun's own substance |
| 537 | // (the fixture-needle leak scan) is already exercised directly by |
| 538 | // TestDryRunSelfChecks in internal/status/status_test.go; duplicating |
| 539 | // that here would just be the same assertion via a slower path, so this |
| 540 | // test's job is only the wrapper's wiring and exit code. |
| 541 | // - a bare `carlos status` (no -dry-run) must exit non-zero — RunStatus's |
| 542 | // own input validation, not the pipeline it wraps. |
| 543 | func TestStatusCLI(t *testing.T) { |
| 544 | tmp := t.TempDir() |
| 545 | carlosBin := sharedCarlos(t) |
| 546 | |
| 547 | dryRunDir := filepath.Join(tmp, "dryrun") |
| 548 | if err := os.MkdirAll(dryRunDir, 0o755); err != nil { |
| 549 | t.Fatalf("mkdir dry-run dir: %v", err) |
| 550 | } |
| 551 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 552 | cmd := exec.Command(carlosBin, "status", "-dry-run", dryRunDir) |
| 553 | cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| 554 | cmd.Dir = tmp |
| 555 | out, err := cmd.CombinedOutput() |
| 556 | if err != nil { |
| 557 | t.Fatalf("carlos status --dry-run %s: %v\n%s", dryRunDir, err, out) |
| 558 | } |
| 559 | if !strings.Contains(string(out), "dry-run: no leaks") { |
| 560 | t.Errorf("carlos status --dry-run output = %q, want it to contain %q", out, "dry-run: no leaks") |
| 561 | } |
| 562 | |
| 563 | cmd = exec.Command(carlosBin, "status") |
| 564 | cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| 565 | cmd.Dir = tmp |
| 566 | if err := cmd.Run(); err == nil { |
| 567 | t.Error("carlos status with no --dry-run flag: want a non-zero exit, got 0") |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | // e2eSiteTar packs a one-file site into the tarball a static release ships. |
| 572 | func e2eSiteTar(t *testing.T, body string) []byte { |
| 573 | t.Helper() |
| 574 | dir := t.TempDir() |
| 575 | if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(body), 0o644); err != nil { |
| 576 | t.Fatal(err) |
| 577 | } |
| 578 | tarball, err := edge.PackDir(dir) |
| 579 | if err != nil { |
| 580 | t.Fatalf("PackDir: %v", err) |
| 581 | } |
| 582 | return tarball |
| 583 | } |
| 584 | |
| 585 | // TestMultiAccountEndToEnd is the whole point of the feature, end to end: two |
| 586 | // accounts, an app of the SAME NAME in each, different builds, and the edge |
| 587 | // serving each account's own bytes on its own host. Plus the migration seam: |
| 588 | // a third app whose objects are still at the legacy, account-less keys serves |
| 589 | // through the read-fallback, unchanged. |
| 590 | func TestMultiAccountEndToEnd(t *testing.T) { |
| 591 | ctx := context.Background() |
| 592 | tmp := t.TempDir() |
| 593 | store := release.NewDirStore(filepath.Join(tmp, "store")) |
| 594 | seed, pubB64, err := release.GenerateKey() |
| 595 | if err != nil { |
| 596 | t.Fatal(err) |
| 597 | } |
| 598 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 599 | if err != nil { |
| 600 | t.Fatal(err) |
| 601 | } |
| 602 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 603 | if err != nil { |
| 604 | t.Fatal(err) |
| 605 | } |
| 606 | |
| 607 | // Two accounts, in the shape minting leaves behind: an id, its frozen |
| 608 | // sqid, and a bucket manifest the agent materializes from. The ids are |
| 609 | // hand-picked (and deliberately large) rather than minted here — this test |
| 610 | // is about the agent reading manifests, and a fixture that depended on the |
| 611 | // allocator would move every time the allocator did. |
| 612 | reg, err := registry.Open(filepath.Join(tmp, "registry.db")) |
| 613 | if err != nil { |
| 614 | t.Fatal(err) |
| 615 | } |
| 616 | defer reg.Close() |
| 617 | accounts := map[string]string{} // name -> sqid |
| 618 | for name, id := range map[string]int64{"Kass": 793481920157, "Woodstar": 481920157793} { |
| 619 | sqid := domains.Sqid(id) |
| 620 | accounts[name] = sqid |
| 621 | body := fmt.Sprintf(`{"id":%d,"sqid":%q,"name":%q,"created_at":"2026-08-02T09:00:00Z"}`, id, sqid, name) |
| 622 | if _, err := store.Put(ctx, "accounts/"+sqid+"/account.json", []byte(body), ""); err != nil { |
| 623 | t.Fatal(err) |
| 624 | } |
| 625 | } |
| 626 | if accounts["Kass"] == accounts["Woodstar"] { |
| 627 | t.Fatal("fixture accounts collided") |
| 628 | } |
| 629 | |
| 630 | // The same app name in both, shipped with different content. |
| 631 | for name, content := range map[string]string{"Kass": "kass blog", "Woodstar": "woodstar blog"} { |
| 632 | acct := accounts[name] |
| 633 | m := release.Manifest{Schema: 1, App: "blog", Version: "v1", Kind: "static", |
| 634 | Artifacts: map[string]release.Artifact{"site.tar.gz": {}}} |
| 635 | if err := release.Ship(ctx, store, acct, m, map[string][]byte{"site.tar.gz": e2eSiteTar(t, content)}); err != nil { |
| 636 | t.Fatalf("Ship blog for %s: %v", name, err) |
| 637 | } |
| 638 | if err := release.Promote(ctx, store, priv, acct, "blog", "stable", "v1"); err != nil { |
| 639 | t.Fatalf("Promote blog for %s: %v", name, err) |
| 640 | } |
| 641 | } |
| 642 | // And a legacy app: manifest, artifact and signed pointer at the |
| 643 | // account-less keys, exactly as an unmigrated deployment's bucket has |
| 644 | // them. Built in a scratch store and copied down, because a pointer's |
| 645 | // signature covers app/channel/version rather than the key path — so |
| 646 | // these are genuine legacy objects, signed by the same key a real |
| 647 | // deployment used. |
| 648 | scratch := release.NewDirStore(filepath.Join(tmp, "scratch")) |
| 649 | oldm := release.Manifest{Schema: 1, App: "oldsite", Version: "v1", Kind: "static", |
| 650 | Artifacts: map[string]release.Artifact{"site.tar.gz": {}}} |
| 651 | if err := release.Ship(ctx, scratch, release.OpsAccount, oldm, |
| 652 | map[string][]byte{"site.tar.gz": e2eSiteTar(t, "legacy site")}); err != nil { |
| 653 | t.Fatal(err) |
| 654 | } |
| 655 | if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "oldsite", "stable", "v1"); err != nil { |
| 656 | t.Fatal(err) |
| 657 | } |
| 658 | staged, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "oldsite")) |
| 659 | if err != nil { |
| 660 | t.Fatal(err) |
| 661 | } |
| 662 | for _, k := range staged { |
| 663 | body, _, err := scratch.Get(ctx, k) |
| 664 | if err != nil { |
| 665 | t.Fatal(err) |
| 666 | } |
| 667 | legacy := release.LegacyAppPrefix("oldsite") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "oldsite")) |
| 668 | if _, err := store.Put(ctx, legacy, body, ""); err != nil { |
| 669 | t.Fatal(err) |
| 670 | } |
| 671 | } |
| 672 | |
| 673 | for _, rt := range []registry.Route{ |
| 674 | {Host: "blog.kass.test", Kind: "static", App: "blog", Account: accounts["Kass"], Channel: "stable", Status: "ready"}, |
| 675 | {Host: "blog.woodstar.test", Kind: "static", App: "blog", Account: accounts["Woodstar"], Channel: "stable", Status: "ready"}, |
| 676 | {Host: "old.test", Kind: "static", App: "oldsite", Channel: "stable", Status: "ready"}, |
| 677 | } { |
| 678 | if err := reg.Upsert(rt); err != nil { |
| 679 | t.Fatal(err) |
| 680 | } |
| 681 | } |
| 682 | |
| 683 | e, err := edge.New(reg, nil) |
| 684 | if err != nil { |
| 685 | t.Fatal(err) |
| 686 | } |
| 687 | e.EnableStatic(store, pub, filepath.Join(tmp, "static-cache")) |
| 688 | e.SetStaticRecheck(0) |
| 689 | if err := e.Reload(); err != nil { |
| 690 | t.Fatal(err) |
| 691 | } |
| 692 | srv := httptest.NewServer(e) |
| 693 | defer srv.Close() |
| 694 | |
| 695 | for host, want := range map[string]string{ |
| 696 | "blog.kass.test": "kass blog", |
| 697 | "blog.woodstar.test": "woodstar blog", |
| 698 | } { |
| 699 | if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want { |
| 700 | t.Errorf("GET %s / = %d %q, want 200 %q", host, code, body, want) |
| 701 | } |
| 702 | } |
| 703 | // The legacy app serves NOTHING. Its objects are all still at the |
| 704 | // account-less keys, and since the read-fallback was retired no read path |
| 705 | // resolves through them — the edge cannot find a pointer, so the route is |
| 706 | // a route to nowhere until somebody runs `accounts migrate -from legacy`. |
| 707 | // Serving it would be the stale-shadow risk the retirement removed. |
| 708 | if code, body := e2eGet(t, srv.URL, "old.test", "/"); code == 200 { |
| 709 | t.Errorf("GET old.test / = %d %q — a legacy-only app is still being served", code, body) |
| 710 | } |
| 711 | |
| 712 | // One reconcile pass: accounts materialize with the bucket's ids, and each |
| 713 | // app mints its platform domain under the account its KEY PATHS say it |
| 714 | // belongs to — the legacy one under ops. |
| 715 | if err := domains.Reconcile(ctx, store, reg, domains.Config{AppsDomain: "oncarlos.test"}); err != nil { |
| 716 | t.Fatal(err) |
| 717 | } |
| 718 | for name, sqid := range accounts { |
| 719 | id, got, _ := reg.AccountBySqid(sqid) |
| 720 | if id == 0 || got != name { |
| 721 | t.Errorf("account %s materialized as id=%d name=%q", sqid, id, got) |
| 722 | } |
| 723 | } |
| 724 | if err := e.Reload(); err != nil { |
| 725 | t.Fatal(err) |
| 726 | } |
| 727 | for host, want := range map[string]string{ |
| 728 | "blog." + accounts["Kass"] + ".oncarlos.test": "kass blog", |
| 729 | "blog." + accounts["Woodstar"] + ".oncarlos.test": "woodstar blog", |
| 730 | } { |
| 731 | if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want { |
| 732 | t.Errorf("GET %s / = %d %q, want 200 %q — the platform domain must serve its own account's build", |
| 733 | host, code, body, want) |
| 734 | } |
| 735 | } |
| 736 | // oldsite's apex still mints: minting follows the operator's ROUTE, which |
| 737 | // is a real record of intent, not the bucket objects. What it must not do |
| 738 | // is serve — the account it mints under is where the app's objects OUGHT |
| 739 | // to be, and the empty page is the honest report of them not being there. |
| 740 | if code, body := e2eGet(t, srv.URL, "oldsite.bab.oncarlos.test", "/"); code == 200 { |
| 741 | t.Errorf("GET oldsite.bab.oncarlos.test / = %d %q — a legacy-only app is still being served", code, body) |
| 742 | } |
| 743 | } |
| 744 | |
| 745 | // TestAccountsCLI drives the `carlos accounts` verbs as an operator would: |
| 746 | // create mints identity, list shows it, and migrate moves an app's objects to |
| 747 | // the account-qualified prefix WITHOUT ever deleting the legacy ones — |
| 748 | // deletion is a separate operator step behind the verification gate. |
| 749 | func TestAccountsCLI(t *testing.T) { |
| 750 | tmp := t.TempDir() |
| 751 | carlosBin := sharedCarlos(t) |
| 752 | storeDir := filepath.Join(tmp, "store") |
| 753 | regPath := filepath.Join(tmp, "registry.db") |
| 754 | // The subprocess must never see the operator's real ~/.carlos — a genuine |
| 755 | // login on the developer's machine makes ownerless `accounts create` |
| 756 | // succeed (the logged-in identity becomes the owner, by design) and |
| 757 | // flips this test's answer. carlosTestEnv masks credentials, HOME and |
| 758 | // CARLOS_CONSOLE; cmd.Dir below closes the cwd-walk path. |
| 759 | env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| 760 | run := func(args ...string) string { |
| 761 | t.Helper() |
| 762 | cmd := exec.Command(carlosBin, args...) |
| 763 | cmd.Env = env |
| 764 | // The binary now walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 765 | cmd.Dir = tmp |
| 766 | out, err := cmd.CombinedOutput() |
| 767 | if err != nil { |
| 768 | t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| 769 | } |
| 770 | return string(out) |
| 771 | } |
| 772 | |
| 773 | // An app in the legacy, account-less shape — what every existing |
| 774 | // deployment's bucket looks like before the migration. |
| 775 | store := release.NewDirStore(storeDir) |
| 776 | ctx := context.Background() |
| 777 | for _, key := range []string{ |
| 778 | "apps/demo/releases/v1/manifest.json", |
| 779 | "apps/demo/releases/v1/demo", |
| 780 | "apps/demo/channels/stable.json", |
| 781 | } { |
| 782 | if _, err := store.Put(ctx, key, []byte("body of "+key), ""); err != nil { |
| 783 | t.Fatal(err) |
| 784 | } |
| 785 | } |
| 786 | reg, err := registry.Open(regPath) |
| 787 | if err != nil { |
| 788 | t.Fatal(err) |
| 789 | } |
| 790 | if err := reg.Upsert(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 791 | App: "demo", Status: "ready", Addr: "127.0.0.1:9000"}); err != nil { |
| 792 | t.Fatal(err) |
| 793 | } |
| 794 | reg.Close() |
| 795 | |
| 796 | // FOUND IN PRODUCTION: create used to record an owner NOWHERE, so the |
| 797 | // accounts kass and keymail were migrated into had no members — and |
| 798 | // membership is authorization, so the operator could not open either one in |
| 799 | // the console. -owner is required, and its absence has to name the flag |
| 800 | // rather than print a shrug. |
| 801 | failed := exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "kass") |
| 802 | failed.Env = env |
| 803 | failed.Dir = tmp |
| 804 | refusal, err := failed.CombinedOutput() |
| 805 | if err == nil { |
| 806 | t.Fatalf("accounts create with no --owner succeeded — that is the ownerless account bug:\n%s", refusal) |
| 807 | } |
| 808 | if !strings.Contains(string(refusal), "-owner") { |
| 809 | t.Errorf("refusal does not name the flag to pass:\n%s", refusal) |
| 810 | } |
| 811 | // And it refused BEFORE minting: a rejected create must not burn an id or |
| 812 | // leave a half-made account in the bucket. |
| 813 | if keys, err := store.List(ctx, "accounts/"); err != nil || len(keys) != 0 { |
| 814 | t.Errorf("refused create left %v in the bucket", keys) |
| 815 | } |
| 816 | // A typo'd address is refused with the same rule the console's sign-in form |
| 817 | // uses — an owner keymail could never authenticate is a door that does not |
| 818 | // open either. |
| 819 | failed = exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul") |
| 820 | failed.Env = env |
| 821 | failed.Dir = tmp |
| 822 | if refusal, err := failed.CombinedOutput(); err == nil { |
| 823 | t.Errorf("accounts create accepted the non-address owner %q:\n%s", "paul", refusal) |
| 824 | } |
| 825 | |
| 826 | // create: a minted id, its frozen sqid printed for the operator to use. |
| 827 | out := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "Paul@Keymail.dev") |
| 828 | m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out) |
| 829 | if m == nil { |
| 830 | t.Fatalf("accounts create printed no sqid:\n%s", out) |
| 831 | } |
| 832 | sqid := m[1] |
| 833 | if !regexp.MustCompile(`^[a-z]{3,16}$`).MatchString(sqid) { |
| 834 | t.Fatalf("created sqid = %q, want the frozen encoder's alphabet", sqid) |
| 835 | } |
| 836 | // The manifest is in the bucket — that is where identity lives, and now |
| 837 | // where the OWNER lives too: the console materializes it into an owner |
| 838 | // membership, so the carlos binary never has to open console state. |
| 839 | body, _, err := store.Get(ctx, "accounts/"+sqid+"/account.json") |
| 840 | if err != nil { |
| 841 | t.Fatalf("create wrote no account manifest: %v", err) |
| 842 | } |
| 843 | var manifest domains.AccountManifest |
| 844 | if err := json.Unmarshal(body, &manifest); err != nil { |
| 845 | t.Fatal(err) |
| 846 | } |
| 847 | if manifest.Owner != "paul@keymail.dev" { |
| 848 | t.Errorf("manifest owner = %q, want the normalized address — the console's memberships "+ |
| 849 | "key is literal text, so a mixed-case owner is an owner who cannot sign in", manifest.Owner) |
| 850 | } |
| 851 | // The operator has to be able to see, from the output alone, that the |
| 852 | // account is reachable and by whom. |
| 853 | if !strings.Contains(out, "paul@keymail.dev") { |
| 854 | t.Errorf("create does not say who owns the account:\n%s", out) |
| 855 | } |
| 856 | |
| 857 | if out := run("accounts", "list", "-registry", regPath); !strings.Contains(out, sqid) || |
| 858 | !strings.Contains(out, "kass") || !strings.Contains(out, "ops") { |
| 859 | t.Fatalf("accounts list = %q, want ops and the new account", out) |
| 860 | } |
| 861 | |
| 862 | // migrate -dry-run: says what it would do, touches nothing. |
| 863 | before, err := store.List(ctx, "") |
| 864 | if err != nil { |
| 865 | t.Fatal(err) |
| 866 | } |
| 867 | out = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid, "-dry-run") |
| 868 | if !strings.Contains(out, "apps/demo/channels/stable.json") || |
| 869 | !strings.Contains(out, "apps/"+sqid+"/demo/channels/stable.json") { |
| 870 | t.Fatalf("dry run did not report the plan:\n%s", out) |
| 871 | } |
| 872 | // The move is not finished until the console's own app→account row moves, |
| 873 | // and this binary deliberately will not reach into the console's DB to do |
| 874 | // it — so it must tell the operator the exact command that does. |
| 875 | wantFollowUp := "console migrate-app --app demo --to " + sqid |
| 876 | if !strings.Contains(out, wantFollowUp) { |
| 877 | t.Fatalf("dry run does not name the console follow-up %q:\n%s", wantFollowUp, out) |
| 878 | } |
| 879 | after, err := store.List(ctx, "") |
| 880 | if err != nil { |
| 881 | t.Fatal(err) |
| 882 | } |
| 883 | if !reflect.DeepEqual(before, after) { |
| 884 | t.Fatalf("dry run changed the store:\n%v\n%v", before, after) |
| 885 | } |
| 886 | |
| 887 | // migrate for real: the qualified objects exist AND the legacy ones are |
| 888 | // still there — nothing in this plan deletes a legacy object. |
| 889 | if out := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); !strings.Contains(out, wantFollowUp) { |
| 890 | t.Fatalf("migrate does not name the console follow-up %q:\n%s", wantFollowUp, out) |
| 891 | } |
| 892 | for _, key := range []string{ |
| 893 | "apps/" + sqid + "/demo/releases/v1/manifest.json", |
| 894 | "apps/" + sqid + "/demo/releases/v1/demo", |
| 895 | "apps/" + sqid + "/demo/channels/stable.json", |
| 896 | } { |
| 897 | body, _, err := store.Get(ctx, key) |
| 898 | if err != nil { |
| 899 | t.Errorf("migrated object missing: %s: %v", key, err) |
| 900 | continue |
| 901 | } |
| 902 | legacy := "apps/demo/" + strings.TrimPrefix(key, "apps/"+sqid+"/demo/") |
| 903 | want, _, err := store.Get(ctx, legacy) |
| 904 | if err != nil { |
| 905 | t.Errorf("migrate deleted the legacy object %s: %v", legacy, err) |
| 906 | continue |
| 907 | } |
| 908 | if string(body) != string(want) { |
| 909 | t.Errorf("%s = %q, want the legacy bytes %q", key, body, want) |
| 910 | } |
| 911 | } |
| 912 | // The route's account moved with it, so adopt builds the new keys. |
| 913 | reg2, err := registry.Open(regPath) |
| 914 | if err != nil { |
| 915 | t.Fatal(err) |
| 916 | } |
| 917 | defer reg2.Close() |
| 918 | rt, err := reg2.Get("demo.example.com") |
| 919 | if err != nil || rt == nil || rt.Account != sqid { |
| 920 | t.Fatalf("route account = %+v %v, want %s", rt, err, sqid) |
| 921 | } |
| 922 | |
| 923 | // Re-running is idempotent: identical bytes at an immutable path are a |
| 924 | // no-op, not a refusal. |
| 925 | run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| 926 | } |
| 927 | |
| 928 | // TestAccountsMigrateTakesTheLiveObjects is the production-rollback trap. |
| 929 | // |
| 930 | // The moment this binary is adopted, every ship/promote writes to |
| 931 | // apps/bab/<app>/… and every read resolves qualified-first — so the LEGACY |
| 932 | // pointer goes stale the first time anyone deploys. A migrate that blindly |
| 933 | // copied from the legacy prefix would move a stale pointer into the new |
| 934 | // account, miss every post-adopt release, and the next agent tick would |
| 935 | // silently downgrade the app to a pre-adopt build before any human looked at |
| 936 | // it. |
| 937 | // |
| 938 | // So: with objects at BOTH prefixes, the verb must resolve them the way the |
| 939 | // RUNTIME does (qualified wins per key), say that it saw both, and verify |
| 940 | // afterwards that what the destination now serves is what the source served. |
| 941 | func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) { |
| 942 | tmp := t.TempDir() |
| 943 | carlosBin := sharedCarlos(t) |
| 944 | storeDir := filepath.Join(tmp, "store") |
| 945 | regPath := filepath.Join(tmp, "registry.db") |
| 946 | store := release.NewDirStore(storeDir) |
| 947 | ctx := context.Background() |
| 948 | |
| 949 | seed, pubB64, err := release.GenerateKey() |
| 950 | if err != nil { |
| 951 | t.Fatal(err) |
| 952 | } |
| 953 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 954 | if err != nil { |
| 955 | t.Fatal(err) |
| 956 | } |
| 957 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 958 | if err != nil { |
| 959 | t.Fatal(err) |
| 960 | } |
| 961 | env := carlosTestEnv(t, |
| 962 | "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| 963 | "CARLOS_RELEASE_PUBKEY="+pubB64, |
| 964 | "HOME="+tmp, |
| 965 | "CARLOS_CONSOLE=", |
| 966 | "CARLOS_DESTINATION=", |
| 967 | ) |
| 968 | run := func(args ...string) (string, error) { |
| 969 | t.Helper() |
| 970 | cmd := exec.Command(carlosBin, args...) |
| 971 | cmd.Env = env |
| 972 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 973 | cmd.Dir = tmp |
| 974 | out, err := cmd.CombinedOutput() |
| 975 | return string(out), err |
| 976 | } |
| 977 | |
| 978 | // The pre-adopt world: v1 shipped and promoted, its objects living at the |
| 979 | // legacy account-less keys. (Built by promoting under ops and copying the |
| 980 | // bytes down — a pointer's signature covers app/channel/version, not the |
| 981 | // key path, so those ARE valid legacy objects, signed by the same key a |
| 982 | // real deployment used.) |
| 983 | m1 := release.Manifest{Schema: 1, App: "demo", Version: "v1", Kind: "binary", |
| 984 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} |
| 985 | if err := release.Ship(ctx, store, release.OpsAccount, m1, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { |
| 986 | t.Fatal(err) |
| 987 | } |
| 988 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v1"); err != nil { |
| 989 | t.Fatal(err) |
| 990 | } |
| 991 | qualified, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "demo")) |
| 992 | if err != nil { |
| 993 | t.Fatal(err) |
| 994 | } |
| 995 | for _, k := range qualified { |
| 996 | body, _, err := store.Get(ctx, k) |
| 997 | if err != nil { |
| 998 | t.Fatal(err) |
| 999 | } |
| 1000 | legacy := release.LegacyAppPrefix("demo") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "demo")) |
| 1001 | if _, err := store.Put(ctx, legacy, body, ""); err != nil { |
| 1002 | t.Fatal(err) |
| 1003 | } |
| 1004 | } |
| 1005 | |
| 1006 | // Then the deployment adopts this binary and someone ships again: v2 lands |
| 1007 | // ONLY at the qualified keys, and stable now points at it there. The |
| 1008 | // legacy pointer still says v1 — stale, and invisible to every reader. |
| 1009 | m2 := release.Manifest{Schema: 1, App: "demo", Version: "v2", Kind: "binary", |
| 1010 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} |
| 1011 | if err := release.Ship(ctx, store, release.OpsAccount, m2, map[string][]byte{"linux-amd64": []byte("v2 bytes")}); err != nil { |
| 1012 | t.Fatal(err) |
| 1013 | } |
| 1014 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v2"); err != nil { |
| 1015 | t.Fatal(err) |
| 1016 | } |
| 1017 | // Sanity: this is genuinely the trap — the two prefixes disagree. |
| 1018 | if p, err := release.Channel(ctx, store, pub, release.OpsAccount, "demo", "stable"); err != nil || p.Version != "v2" { |
| 1019 | t.Fatalf("fixture: runtime resolves %+v %v, want v2", p, err) |
| 1020 | } |
| 1021 | legacyRaw, _, err := store.Get(ctx, release.LegacyChannelKey("demo", "stable")) |
| 1022 | if err != nil { |
| 1023 | t.Fatal(err) |
| 1024 | } |
| 1025 | if string(legacyRaw) == "" { |
| 1026 | t.Fatal("fixture: no legacy pointer") |
| 1027 | } |
| 1028 | |
| 1029 | reg, err := registry.Open(regPath) |
| 1030 | if err != nil { |
| 1031 | t.Fatal(err) |
| 1032 | } |
| 1033 | if err := reg.Upsert(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 1034 | App: "demo", Status: "ready", Addr: "127.0.0.1:9000"}); err != nil { |
| 1035 | t.Fatal(err) |
| 1036 | } |
| 1037 | reg.Close() |
| 1038 | |
| 1039 | out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") |
| 1040 | if err != nil { |
| 1041 | t.Fatalf("accounts create: %v\n%s", err, out) |
| 1042 | } |
| 1043 | sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] |
| 1044 | |
| 1045 | // The verb must SAY it saw both prefixes — an operator staring at a |
| 1046 | // half-migrated bucket needs to know that is what they are looking at. |
| 1047 | out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid, "-dry-run") |
| 1048 | if err != nil { |
| 1049 | t.Fatalf("dry run: %v\n%s", err, out) |
| 1050 | } |
| 1051 | if !strings.Contains(out, release.AppPrefix(release.OpsAccount, "demo")) || |
| 1052 | !strings.Contains(out, release.LegacyAppPrefix("demo")) { |
| 1053 | t.Errorf("dry run does not mention both source prefixes:\n%s", out) |
| 1054 | } |
| 1055 | |
| 1056 | // The live pointer was signed for ops, so it has to be re-signed for the |
| 1057 | // destination (discussion #220): without the root key the verb refuses |
| 1058 | // and says what it needs, rather than copying a pointer that would not |
| 1059 | // verify where it lands. |
| 1060 | out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| 1061 | if err == nil || !strings.Contains(out, "CARLOS_RELEASE_KEY") { |
| 1062 | t.Fatalf("migrate of an account-bound pointer without the root key: err=%v\n%s", err, out) |
| 1063 | } |
| 1064 | env = append(env, "CARLOS_RELEASE_KEY="+seed) |
| 1065 | out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| 1066 | if err != nil { |
| 1067 | t.Fatalf("migrate: %v\n%s", err, out) |
| 1068 | } |
| 1069 | // THE assertion: the destination serves what the runtime served, not the |
| 1070 | // stale legacy pointer. |
| 1071 | got, err := release.Channel(ctx, store, pub, sqid, "demo", "stable") |
| 1072 | if err != nil { |
| 1073 | t.Fatalf("resolve the migrated channel: %v", err) |
| 1074 | } |
| 1075 | if got.Version != "v2" { |
| 1076 | t.Fatalf("migrated pointer = %s, want v2 — the verb copied the STALE legacy pointer, "+ |
| 1077 | "which would downgrade the app on the next agent tick", got.Version) |
| 1078 | } |
| 1079 | // Both versions' release objects came across, so a rollback target still |
| 1080 | // resolves after the move. |
| 1081 | for _, ver := range []string{"v1", "v2"} { |
| 1082 | if _, err := release.GetManifest(ctx, store, sqid, "demo", ver); err != nil { |
| 1083 | t.Errorf("manifest %s missing after the move: %v", ver, err) |
| 1084 | } |
| 1085 | } |
| 1086 | // And nothing was deleted. |
| 1087 | if _, _, err := store.Get(ctx, release.LegacyChannelKey("demo", "stable")); err != nil { |
| 1088 | t.Errorf("migrate deleted a legacy object: %v", err) |
| 1089 | } |
| 1090 | } |
| 1091 | |
| 1092 | // The production flag does NOT travel. Its signed payload names the account, |
| 1093 | // so a byte copy is unverifiable at the destination forever — and an |
| 1094 | // unverifiable flag is exactly what every box reads as "keep baking", so the |
| 1095 | // migrated app's held heads would sit in a window with no expiry, silently |
| 1096 | // (verifyMigratedPointers checks channel pointers, not this). The verb skips |
| 1097 | // it and says so, loudly, because the alternative failure is invisible. |
| 1098 | func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { |
| 1099 | tmp := t.TempDir() |
| 1100 | carlosBin := sharedCarlos(t) |
| 1101 | storeDir := filepath.Join(tmp, "store") |
| 1102 | regPath := filepath.Join(tmp, "registry.db") |
| 1103 | store := release.NewDirStore(storeDir) |
| 1104 | ctx := context.Background() |
| 1105 | |
| 1106 | seed, pubB64, err := release.GenerateKey() |
| 1107 | if err != nil { |
| 1108 | t.Fatal(err) |
| 1109 | } |
| 1110 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 1111 | if err != nil { |
| 1112 | t.Fatal(err) |
| 1113 | } |
| 1114 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 1115 | if err != nil { |
| 1116 | t.Fatal(err) |
| 1117 | } |
| 1118 | env := append(os.Environ(), |
| 1119 | "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| 1120 | "CARLOS_RELEASE_PUBKEY="+pubB64, |
| 1121 | "CARLOS_RELEASE_KEY="+seed, // ops-bound pointers are re-signed for the destination |
| 1122 | "HOME="+tmp, |
| 1123 | "CARLOS_CONSOLE=", |
| 1124 | "CARLOS_DESTINATION=", |
| 1125 | ) |
| 1126 | run := func(args ...string) (string, error) { |
| 1127 | t.Helper() |
| 1128 | cmd := exec.Command(carlosBin, args...) |
| 1129 | cmd.Env = env |
| 1130 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1131 | cmd.Dir = tmp |
| 1132 | out, err := cmd.CombinedOutput() |
| 1133 | return string(out), err |
| 1134 | } |
| 1135 | |
| 1136 | m := release.Manifest{Schema: 1, App: "demo", Version: "v1", Kind: "binary", |
| 1137 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} |
| 1138 | if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { |
| 1139 | t.Fatal(err) |
| 1140 | } |
| 1141 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v1"); err != nil { |
| 1142 | t.Fatal(err) |
| 1143 | } |
| 1144 | if err := release.SetProduction(ctx, store, priv, release.OpsAccount, "demo", true, "paul@keymail.dev"); err != nil { |
| 1145 | t.Fatal(err) |
| 1146 | } |
| 1147 | |
| 1148 | out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") |
| 1149 | if err != nil { |
| 1150 | t.Fatalf("accounts create: %v\n%s", err, out) |
| 1151 | } |
| 1152 | sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] |
| 1153 | |
| 1154 | out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| 1155 | if err != nil { |
| 1156 | t.Fatalf("migrate: %v\n%s", err, out) |
| 1157 | } |
| 1158 | // No flag object at the destination at all — a copied one would be worse |
| 1159 | // than none: none defaults to "not production", a copy bakes forever. |
| 1160 | if _, _, err := store.Get(ctx, release.ProductionKey(sqid, "demo")); err == nil { |
| 1161 | t.Fatal("the migration copied production.json — the copy names the source account and can never verify") |
| 1162 | } |
| 1163 | if f, err := release.GetProduction(ctx, store, pub, sqid, "demo"); err != nil || f != nil { |
| 1164 | t.Fatalf("destination flag = %+v, %v — want the clean default (no object, no error)", f, err) |
| 1165 | } |
| 1166 | // And the operator is TOLD, because nothing downstream will notice. |
| 1167 | want := "demo: carried a production declaration — re-declare it at the destination" |
| 1168 | if !strings.Contains(out, want) { |
| 1169 | t.Fatalf("migrate output does not name the flag it left behind (%q):\n%s", want, out) |
| 1170 | } |
| 1171 | // The source is untouched, as with every other object this verb moves. |
| 1172 | if f, err := release.GetProduction(ctx, store, pub, release.OpsAccount, "demo"); err != nil || f == nil || !f.Production { |
| 1173 | t.Fatalf("source flag = %+v, %v after the move, want it untouched and still on", f, err) |
| 1174 | } |
| 1175 | // The rest of the app still came across. |
| 1176 | if _, err := release.GetManifest(ctx, store, sqid, "demo", "v1"); err != nil { |
| 1177 | t.Errorf("manifest missing after the move: %v", err) |
| 1178 | } |
| 1179 | if p, err := release.Channel(ctx, store, pub, sqid, "demo", "stable"); err != nil || p.Version != "v1" { |
| 1180 | t.Errorf("migrated stable = %+v, %v, want v1", p, err) |
| 1181 | } |
| 1182 | } |
| 1183 | |
| 1184 | // A destination channel pointer that disagrees with the source must CONVERGE, |
| 1185 | // not strand the operator. Channel pointers are the one mutable thing in the |
| 1186 | // bucket — they move on every promote — so a stale or half-written one at the |
| 1187 | // destination is this migration's own leftover, not foreign data, and the |
| 1188 | // verb's job is to make the destination serve what the source serves. |
| 1189 | // |
| 1190 | // The alternative shape (refuse, and tell the operator to re-run) was a dead |
| 1191 | // end: the re-run would hit the create-only copy and die at "already exists |
| 1192 | // with different content", so the instruction forbade the only exit. |
| 1193 | func TestAccountsMigrateConvergesADivergedPointer(t *testing.T) { |
| 1194 | tmp := t.TempDir() |
| 1195 | carlosBin := sharedCarlos(t) |
| 1196 | storeDir := filepath.Join(tmp, "store") |
| 1197 | regPath := filepath.Join(tmp, "registry.db") |
| 1198 | store := release.NewDirStore(storeDir) |
| 1199 | ctx := context.Background() |
| 1200 | |
| 1201 | seed, pubB64, err := release.GenerateKey() |
| 1202 | if err != nil { |
| 1203 | t.Fatal(err) |
| 1204 | } |
| 1205 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 1206 | if err != nil { |
| 1207 | t.Fatal(err) |
| 1208 | } |
| 1209 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 1210 | if err != nil { |
| 1211 | t.Fatal(err) |
| 1212 | } |
| 1213 | env := carlosTestEnv(t, |
| 1214 | "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| 1215 | "CARLOS_RELEASE_PUBKEY="+pubB64, |
| 1216 | "CARLOS_RELEASE_KEY="+seed) // ops-bound pointers are re-signed for the destination |
| 1217 | run := func(args ...string) (string, error) { |
| 1218 | t.Helper() |
| 1219 | cmd := exec.Command(carlosBin, args...) |
| 1220 | cmd.Env = env |
| 1221 | out, err := cmd.CombinedOutput() |
| 1222 | return string(out), err |
| 1223 | } |
| 1224 | |
| 1225 | for _, ver := range []string{"v1", "v2"} { |
| 1226 | m := release.Manifest{Schema: 1, App: "demo", Version: ver, Kind: "binary", |
| 1227 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} |
| 1228 | if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte(ver + " bytes")}); err != nil { |
| 1229 | t.Fatal(err) |
| 1230 | } |
| 1231 | } |
| 1232 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v2"); err != nil { |
| 1233 | t.Fatal(err) |
| 1234 | } |
| 1235 | reg, err := registry.Open(regPath) |
| 1236 | if err != nil { |
| 1237 | t.Fatal(err) |
| 1238 | } |
| 1239 | reg.Close() |
| 1240 | |
| 1241 | out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") |
| 1242 | if err != nil { |
| 1243 | t.Fatalf("accounts create: %v\n%s", err, out) |
| 1244 | } |
| 1245 | sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] |
| 1246 | |
| 1247 | // A half-finished earlier attempt: the destination already holds this |
| 1248 | // app's releases AND a pointer at the OLD version. (Copied the way the |
| 1249 | // verb copies — same bytes, so only the POINTER diverges.) |
| 1250 | srcKeys, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "demo")) |
| 1251 | if err != nil { |
| 1252 | t.Fatal(err) |
| 1253 | } |
| 1254 | for _, k := range srcKeys { |
| 1255 | if strings.Contains(k, "/channels/") { |
| 1256 | continue |
| 1257 | } |
| 1258 | body, _, err := store.Get(ctx, k) |
| 1259 | if err != nil { |
| 1260 | t.Fatal(err) |
| 1261 | } |
| 1262 | dstKey := release.AppPrefix(sqid, "demo") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "demo")) |
| 1263 | if _, err := store.Put(ctx, dstKey, body, ""); err != nil { |
| 1264 | t.Fatal(err) |
| 1265 | } |
| 1266 | } |
| 1267 | if err := release.Promote(ctx, store, priv, sqid, "demo", "stable", "v1"); err != nil { |
| 1268 | t.Fatal(err) |
| 1269 | } |
| 1270 | if p, _ := release.Channel(ctx, store, pub, sqid, "demo", "stable"); p == nil || p.Version != "v1" { |
| 1271 | t.Fatalf("fixture: destination pointer is %+v, want v1", p) |
| 1272 | } |
| 1273 | |
| 1274 | out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| 1275 | if err != nil { |
| 1276 | t.Fatalf("migrate over a diverged destination pointer must converge, not strand:\n%s", out) |
| 1277 | } |
| 1278 | got, err := release.Channel(ctx, store, pub, sqid, "demo", "stable") |
| 1279 | if err != nil { |
| 1280 | t.Fatalf("resolve the migrated channel: %v", err) |
| 1281 | } |
| 1282 | if got.Version != "v2" { |
| 1283 | t.Fatalf("destination serves %s, want v2 — the migration did not converge the pointer", got.Version) |
| 1284 | } |
| 1285 | if !strings.Contains(out, "stable") { |
| 1286 | t.Errorf("the verb overwrote a pointer without saying so:\n%s", out) |
| 1287 | } |
| 1288 | // And it is re-runnable: that was the whole point. |
| 1289 | if out, err := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); err != nil { |
| 1290 | t.Fatalf("a second run must be a no-op:\n%s", out) |
| 1291 | } |
| 1292 | } |
| 1293 | |
| 1294 | // TestAddUnitBackingRequiresDBForHibernate pins the I1 fix's operator-facing |
| 1295 | // half: `carlos add -backing unit -hibernate` with no -db must refuse, |
| 1296 | // because ops.FleetDBs (the box-wide litestream unit) has no way to |
| 1297 | // discover a unit tenant's database path other than that flag, and a |
| 1298 | // hibernating unit tenant left unreplicated is a genuine data-loss risk the |
| 1299 | // moment systemd stops it for idleness. A non-hibernating unit route stays |
| 1300 | // unenforced (still the operator's call), and -db always persists onto the |
| 1301 | // route when given. |
| 1302 | func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) { |
| 1303 | tmp := t.TempDir() |
| 1304 | carlosBin := sharedCarlos(t) |
| 1305 | |
| 1306 | regPath := filepath.Join(tmp, "registry.db") |
| 1307 | |
| 1308 | // No -db: refused, non-zero exit, no row written. |
| 1309 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1310 | cmd := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| 1311 | "-backing", "unit", "-hibernate", "no-db.example.com") |
| 1312 | cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| 1313 | cmd.Dir = tmp |
| 1314 | out, err := cmd.CombinedOutput() |
| 1315 | if err == nil { |
| 1316 | t.Fatalf("add --backing unit --hibernate with no --db: want non-zero exit, got 0\n%s", out) |
| 1317 | } |
| 1318 | if !strings.Contains(string(out), "-db") { |
| 1319 | t.Errorf("add refusal output = %q, want it to mention --db", out) |
| 1320 | } |
| 1321 | |
| 1322 | // With -db: succeeds, and the route's DBPath is exactly what was given |
| 1323 | // (ops.FleetDBs reads this verbatim — no default synthesized here). |
| 1324 | dbPath := filepath.Join(tmp, "unit-state", "app.db") |
| 1325 | cmd = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| 1326 | "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com") |
| 1327 | cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| 1328 | cmd.Dir = tmp |
| 1329 | out, err = cmd.CombinedOutput() |
| 1330 | if err != nil { |
| 1331 | t.Fatalf("add --backing unit --hibernate --db %s: %v\n%s", dbPath, err, out) |
| 1332 | } |
| 1333 | |
| 1334 | reg, err := registry.Open(regPath) |
| 1335 | if err != nil { |
| 1336 | t.Fatalf("open registry: %v", err) |
| 1337 | } |
| 1338 | routes, err := reg.List() |
| 1339 | if err != nil { |
| 1340 | t.Fatalf("list routes: %v", err) |
| 1341 | } |
| 1342 | var found bool |
| 1343 | for _, rt := range routes { |
| 1344 | if rt.Host != "with-db.example.com" { |
| 1345 | continue |
| 1346 | } |
| 1347 | found = true |
| 1348 | if rt.DBPath != dbPath { |
| 1349 | t.Errorf("with-db.example.com DBPath = %q, want %q", rt.DBPath, dbPath) |
| 1350 | } |
| 1351 | if rt.Backing != registry.BackingUnit { |
| 1352 | t.Errorf("with-db.example.com Backing = %q, want %q", rt.Backing, registry.BackingUnit) |
| 1353 | } |
| 1354 | } |
| 1355 | if !found { |
| 1356 | t.Fatal("with-db.example.com route missing after a successful add") |
| 1357 | } |
| 1358 | // The refused add must not have left a partial row behind. |
| 1359 | for _, rt := range routes { |
| 1360 | if rt.Host == "no-db.example.com" { |
| 1361 | t.Fatal("refused add left a route behind") |
| 1362 | } |
| 1363 | } |
| 1364 | } |
| 1365 | |
| 1366 | // A relative -db on an agent-exec route must be refused at add time. The |
| 1367 | // activation contract's "relative -db resolves inside $STATE_DIRECTORY" |
| 1368 | // convention is real for unit tenants (systemd sets it) but agent-exec |
| 1369 | // children are spawned without it and the activator consumes the path |
| 1370 | // verbatim (Restore, Seed, Start --db, Replicate) — so a relative path |
| 1371 | // resolves against an unwritable cwd and every wake dies as an edge 503, |
| 1372 | // discoverable only by correlating 503s with child stderr in the journal |
| 1373 | // (issue #70, vitogo's first deploy). |
| 1374 | func TestAddRefusesRelativeDBOnExecRoutes(t *testing.T) { |
| 1375 | tmp := t.TempDir() |
| 1376 | carlosBin := sharedCarlos(t) |
| 1377 | regPath := filepath.Join(tmp, "registry.db") |
| 1378 | |
| 1379 | run := func(args ...string) (string, error) { |
| 1380 | cmd := exec.Command(carlosBin, args...) |
| 1381 | cmd.Env = carlosTestEnv(t) |
| 1382 | cmd.Dir = tmp |
| 1383 | out, err := cmd.CombinedOutput() |
| 1384 | return string(out), err |
| 1385 | } |
| 1386 | |
| 1387 | // The vitogo add, verbatim shape: exec backing (the default), relative -db. |
| 1388 | out, err := run("add", "-registry", regPath, "-app", "vitogo", |
| 1389 | "-kind", "instance", "-db", "vito.db", "-hibernate", |
| 1390 | "-channel", "canary/vitogo", "vito.example.com") |
| 1391 | if err == nil { |
| 1392 | t.Fatalf("add with relative --db on an exec route: want non-zero exit, got 0\n%s", out) |
| 1393 | } |
| 1394 | // The refusal must carry the diagnosis and the way out — not just "no". |
| 1395 | if !strings.Contains(out, "STATE_DIRECTORY") || !strings.Contains(out, "absolute") { |
| 1396 | t.Errorf("refusal = %q, want it to explain STATE_DIRECTORY and require an absolute path", out) |
| 1397 | } |
| 1398 | // And it must refuse BEFORE writing: no half-registered route. |
| 1399 | reg, err := registry.Open(regPath) |
| 1400 | if err != nil { |
| 1401 | t.Fatalf("open registry: %v", err) |
| 1402 | } |
| 1403 | defer reg.Close() |
| 1404 | rt, err := reg.Get("vito.example.com") |
| 1405 | if err != nil { |
| 1406 | t.Fatal(err) |
| 1407 | } |
| 1408 | if rt != nil { |
| 1409 | t.Fatalf("refused add still wrote a route: %+v", rt) |
| 1410 | } |
| 1411 | |
| 1412 | // A unit tenant keeps the contract: StateDirectory= is real there, a |
| 1413 | // relative -db is the documented convention, and this guard must not |
| 1414 | // take it away. |
| 1415 | out, err = run("add", "-registry", regPath, "-app", "vitogo", |
| 1416 | "-kind", "instance", "-backing", "unit", "-db", "vito.db", |
| 1417 | "unit.example.com") |
| 1418 | if err != nil { |
| 1419 | t.Fatalf("add --backing unit with relative --db: %v\n%s", err, out) |
| 1420 | } |
| 1421 | |
| 1422 | // An absolute -db on an exec route is untouched by the guard. |
| 1423 | out, err = run("add", "-registry", regPath, "-app", "vitogo", |
| 1424 | "-kind", "instance", "-db", "/data/vito.example.com.db", |
| 1425 | "vito.example.com") |
| 1426 | if err != nil { |
| 1427 | t.Fatalf("add with absolute --db: %v\n%s", err, out) |
| 1428 | } |
| 1429 | } |
| 1430 | |
| 1431 | // TestAddAccountIDRefusesAName pins the whole -account-id gate end to end, |
| 1432 | // on the verb where getting it wrong is quietest. `carlos add -account-id |
| 1433 | // ops` used to succeed: "ops" passes release.ValidAccount (^[a-z]{3,16}$), |
| 1434 | // so a bare shape check waved it through and stamped Account="ops" onto the |
| 1435 | // registry row — a phantom account (the real ops is Sqid(1) = "bab") that |
| 1436 | // every subsequent adopt and edge lookup would key off and find nothing |
| 1437 | // under. Exit 0, no error, nothing serving. The frozen alphabet has no i, l |
| 1438 | // or o, so domains.ValidSqid can refuse it for free; this test is the proof |
| 1439 | // the CLI actually does. |
| 1440 | // |
| 1441 | // The in-process half of this coverage is TestAccountIDShape |
| 1442 | // (accounts_resolve_test.go); the refusal itself is log.Fatal, so only a |
| 1443 | // real subprocess can observe the exit status and the absent row. |
| 1444 | func TestAddAccountIDRefusesAName(t *testing.T) { |
| 1445 | tmp := t.TempDir() |
| 1446 | carlosBin := sharedCarlos(t) |
| 1447 | regPath := filepath.Join(tmp, "registry.db") |
| 1448 | |
| 1449 | out, err := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| 1450 | "-account-id", "ops", "phantom.example.com").CombinedOutput() |
| 1451 | if err == nil { |
| 1452 | t.Fatalf("add --account-id ops: want non-zero exit, got 0\n%s", out) |
| 1453 | } |
| 1454 | // The message must send them to the right flag AND name the sqid they |
| 1455 | // actually wanted — "that's not a sqid" alone leaves them guessing. |
| 1456 | if !strings.Contains(string(out), "--account ops") || !strings.Contains(string(out), "bab") { |
| 1457 | t.Errorf("refusal = %q, want it to name --account ops and the real sqid bab", out) |
| 1458 | } |
| 1459 | |
| 1460 | // A name that is merely sqid-SHAPED is refused the same way when it |
| 1461 | // carries an i, l or o — the letters no id can ever encode to. |
| 1462 | out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| 1463 | "-account-id", "oil", "phantom2.example.com").CombinedOutput() |
| 1464 | if err == nil { |
| 1465 | t.Fatalf("add --account-id oil: want non-zero exit, got 0\n%s", out) |
| 1466 | } |
| 1467 | if !strings.Contains(string(out), "-account") { |
| 1468 | t.Errorf("refusal = %q, want it to point at --account", out) |
| 1469 | } |
| 1470 | |
| 1471 | // A real sqid is accepted and stamped verbatim — never resolved against |
| 1472 | // the registry, which is the point of the flag: this one has no accounts |
| 1473 | // in it at all. |
| 1474 | out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| 1475 | "-account-id", "bah", "real.example.com").CombinedOutput() |
| 1476 | if err != nil { |
| 1477 | t.Fatalf("add --account-id bah: %v\n%s", err, out) |
| 1478 | } |
| 1479 | |
| 1480 | reg, err := registry.Open(regPath) |
| 1481 | if err != nil { |
| 1482 | t.Fatalf("open registry: %v", err) |
| 1483 | } |
| 1484 | defer reg.Close() |
| 1485 | routes, err := reg.List() |
| 1486 | if err != nil { |
| 1487 | t.Fatalf("list routes: %v", err) |
| 1488 | } |
| 1489 | var seen int |
| 1490 | for _, rt := range routes { |
| 1491 | switch rt.Host { |
| 1492 | case "phantom.example.com", "phantom2.example.com": |
| 1493 | t.Errorf("refused add left a route behind (%s, account %q)", rt.Host, rt.Account) |
| 1494 | case "real.example.com": |
| 1495 | seen++ |
| 1496 | if rt.Account != "bah" { |
| 1497 | t.Errorf("route account = %q, want the --account-id sqid %q", rt.Account, "bah") |
| 1498 | } |
| 1499 | } |
| 1500 | } |
| 1501 | if seen != 1 { |
| 1502 | t.Errorf("found %d rows for real.example.com, want 1", seen) |
| 1503 | } |
| 1504 | } |
| 1505 | |
| 1506 | // carlos add on an existing host is an update and must say so — the vitogo |
| 1507 | // incident (issue #69) started with `added vito.platform.tito.io` printed |
| 1508 | // over what was actually a rewrite of an existing row. |
| 1509 | func TestAddOnExistingRouteSaysUpdated(t *testing.T) { |
| 1510 | tmp := t.TempDir() |
| 1511 | carlosBin := sharedCarlos(t) |
| 1512 | regPath := filepath.Join(tmp, "registry.db") |
| 1513 | |
| 1514 | run := func(args ...string) (string, error) { |
| 1515 | cmd := exec.Command(carlosBin, args...) |
| 1516 | cmd.Env = carlosTestEnv(t) |
| 1517 | cmd.Dir = tmp |
| 1518 | out, err := cmd.CombinedOutput() |
| 1519 | return string(out), err |
| 1520 | } |
| 1521 | |
| 1522 | out, err := run("add", "-registry", regPath, "-app", "vitogo", |
| 1523 | "-db", "/data/first.db", "vito.example.com") |
| 1524 | if err != nil { |
| 1525 | t.Fatalf("first add: %v\n%s", err, out) |
| 1526 | } |
| 1527 | if !strings.Contains(out, "added vito.example.com") { |
| 1528 | t.Errorf("first add output = %q, want %q", out, "added vito.example.com") |
| 1529 | } |
| 1530 | |
| 1531 | out, err = run("add", "-registry", regPath, "-app", "vitogo", |
| 1532 | "-db", "/data/second.db", "vito.example.com") |
| 1533 | if err != nil { |
| 1534 | t.Fatalf("re-add: %v\n%s", err, out) |
| 1535 | } |
| 1536 | if !strings.Contains(out, "updated vito.example.com") { |
| 1537 | t.Errorf("re-add output = %q, want it to say updated, not added", out) |
| 1538 | } |
| 1539 | |
| 1540 | // And the rewrite must actually land — the row carries the new path. |
| 1541 | reg, err := registry.Open(regPath) |
| 1542 | if err != nil { |
| 1543 | t.Fatalf("open registry: %v", err) |
| 1544 | } |
| 1545 | defer reg.Close() |
| 1546 | rt, err := reg.Get("vito.example.com") |
| 1547 | if err != nil || rt == nil { |
| 1548 | t.Fatalf("get route: %v (rt=%v)", err, rt) |
| 1549 | } |
| 1550 | if rt.DBPath != "/data/second.db" { |
| 1551 | t.Errorf("db_path = %q, want /data/second.db", rt.DBPath) |
| 1552 | } |
| 1553 | } |
| 1554 | |
| 1555 | // An app still on the pre-accounts keys can be moved INTO ops. Until `-from |
| 1556 | // legacy` existed there was no way to say it: a bare move into ops is refused |
| 1557 | // (ops is also the default source, and the verb will not guess a direction), |
| 1558 | // and -from only took a sqid, which the account-less key space does not have. |
| 1559 | // That left every ops app stranded on legacy keys and made the read-fallback |
| 1560 | // impossible to retire. |
| 1561 | func TestAccountsMigrateLegacyIntoOps(t *testing.T) { |
| 1562 | tmp := t.TempDir() |
| 1563 | carlosBin := sharedCarlos(t) |
| 1564 | storeDir := filepath.Join(tmp, "store") |
| 1565 | regPath := filepath.Join(tmp, "registry.db") |
| 1566 | store := release.NewDirStore(storeDir) |
| 1567 | ctx := context.Background() |
| 1568 | |
| 1569 | seed, pubB64, err := release.GenerateKey() |
| 1570 | if err != nil { |
| 1571 | t.Fatal(err) |
| 1572 | } |
| 1573 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 1574 | if err != nil { |
| 1575 | t.Fatal(err) |
| 1576 | } |
| 1577 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 1578 | if err != nil { |
| 1579 | t.Fatal(err) |
| 1580 | } |
| 1581 | env := carlosTestEnv(t, |
| 1582 | "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| 1583 | "CARLOS_RELEASE_PUBKEY="+pubB64, |
| 1584 | "HOME="+tmp, |
| 1585 | "CARLOS_CONSOLE=", |
| 1586 | "CARLOS_DESTINATION=", |
| 1587 | ) |
| 1588 | run := func(args ...string) (string, error) { |
| 1589 | t.Helper() |
| 1590 | cmd := exec.Command(carlosBin, args...) |
| 1591 | cmd.Env = env |
| 1592 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1593 | cmd.Dir = tmp |
| 1594 | out, err := cmd.CombinedOutput() |
| 1595 | return string(out), err |
| 1596 | } |
| 1597 | |
| 1598 | // A purely legacy app: the deployment store holds oldapp ONLY at the |
| 1599 | // account-less keys, which is the shape every deployment predating accounts |
| 1600 | // is in. Built in a scratch store and copied down, because a pointer's |
| 1601 | // signature covers app/channel/version rather than the key path — so these |
| 1602 | // are genuine legacy objects, signed by the same key a real deployment used. |
| 1603 | scratch := release.NewDirStore(filepath.Join(tmp, "scratch")) |
| 1604 | m := release.Manifest{Schema: 1, App: "oldapp", Version: "v1", Kind: "binary", |
| 1605 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "oldapp"}}} |
| 1606 | if err := release.Ship(ctx, scratch, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { |
| 1607 | t.Fatal(err) |
| 1608 | } |
| 1609 | if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "oldapp", "stable", "v1"); err != nil { |
| 1610 | t.Fatal(err) |
| 1611 | } |
| 1612 | qualified, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "oldapp")) |
| 1613 | if err != nil { |
| 1614 | t.Fatal(err) |
| 1615 | } |
| 1616 | for _, k := range qualified { |
| 1617 | body, _, err := scratch.Get(ctx, k) |
| 1618 | if err != nil { |
| 1619 | t.Fatal(err) |
| 1620 | } |
| 1621 | legacy := release.LegacyAppPrefix("oldapp") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "oldapp")) |
| 1622 | if _, err := store.Put(ctx, legacy, body, ""); err != nil { |
| 1623 | t.Fatal(err) |
| 1624 | } |
| 1625 | } |
| 1626 | opsKey := release.ChannelKey(release.OpsAccount, "oldapp", "stable") |
| 1627 | if _, _, err := store.Get(ctx, opsKey); err == nil { |
| 1628 | t.Fatal("fixture: the qualified key already exists — the migration would prove nothing") |
| 1629 | } |
| 1630 | // Before the move, oldapp is INVISIBLE: the read-fallback is retired, so |
| 1631 | // nothing resolves it and the miss names the exact command that fixes it. |
| 1632 | // This is the state a deployment lands in by upgrading from a |
| 1633 | // pre-accounts binary — the reason this verb has to keep working forever. |
| 1634 | if _, err := release.Channel(ctx, store, pub, release.OpsAccount, "oldapp", "stable"); err == nil { |
| 1635 | t.Fatal("a legacy-only app resolved — the read-fallback is supposed to be retired") |
| 1636 | } else if !strings.Contains(err.Error(), "carlos accounts migrate --app oldapp --from legacy --to bab") { |
| 1637 | t.Errorf("the miss does not name the fix:\n%v", err) |
| 1638 | } |
| 1639 | |
| 1640 | // A bare move into ops is still refused — the ambiguity guard stays — and |
| 1641 | // the refusal now NAMES the way out. |
| 1642 | out, err := run("accounts", "migrate", "-registry", regPath, "-app", "oldapp", "-to", release.OpsAccount) |
| 1643 | if err == nil { |
| 1644 | t.Fatalf("a bare move into ops was accepted:\n%s", out) |
| 1645 | } |
| 1646 | if !strings.Contains(out, "--from legacy") { |
| 1647 | t.Errorf("the refusal does not name the fix:\n%s", out) |
| 1648 | } |
| 1649 | |
| 1650 | // Naming the legacy source works, and the destination then serves what the |
| 1651 | // source served. |
| 1652 | out, err = run("accounts", "migrate", "-registry", regPath, "-app", "oldapp", |
| 1653 | "-from", release.LegacySource, "-to", release.OpsAccount) |
| 1654 | if err != nil { |
| 1655 | t.Fatalf("legacy -> ops migration: %v\n%s", err, out) |
| 1656 | } |
| 1657 | if _, _, err := store.Get(ctx, opsKey); err != nil { |
| 1658 | t.Fatalf("the qualified pointer %s does not exist after the migration: %v\n%s", opsKey, err, out) |
| 1659 | } |
| 1660 | p, err := release.Channel(ctx, store, pub, release.OpsAccount, "oldapp", "stable") |
| 1661 | if err != nil { |
| 1662 | t.Fatalf("ops does not serve oldapp after the migration: %v\n%s", err, out) |
| 1663 | } |
| 1664 | if p.Version != "v1" { |
| 1665 | t.Errorf("ops serves %s, want v1", p.Version) |
| 1666 | } |
| 1667 | // The whole app moved, not just the pointer: the manifest and every |
| 1668 | // artifact must be at qualified keys too, or retiring the fallback would |
| 1669 | // take the app down. |
| 1670 | moved, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "oldapp")) |
| 1671 | if err != nil { |
| 1672 | t.Fatal(err) |
| 1673 | } |
| 1674 | if len(moved) != len(qualified) { |
| 1675 | t.Errorf("migrated %d objects, want all %d", len(moved), len(qualified)) |
| 1676 | } |
| 1677 | |
| 1678 | // "legacy" is a source, never a destination: the point of the verb is to |
| 1679 | // empty that key space, not to fill it. |
| 1680 | if out, err := run("accounts", "migrate", "-registry", regPath, "-app", "oldapp", |
| 1681 | "-to", release.LegacySource); err == nil { |
| 1682 | t.Fatalf("migrating INTO legacy was accepted:\n%s", out) |
| 1683 | } |
| 1684 | } |
| 1685 | |
| 1686 | func TestEnvCLI(t *testing.T) { |
| 1687 | tmp := t.TempDir() |
| 1688 | carlosBin := sharedCarlos(t) |
| 1689 | storeDir := filepath.Join(tmp, "store") |
| 1690 | regPath := filepath.Join(tmp, "registry.db") |
| 1691 | env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| 1692 | run := func(args ...string) string { |
| 1693 | t.Helper() |
| 1694 | cmd := exec.Command(carlosBin, args...) |
| 1695 | cmd.Env = env |
| 1696 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1697 | cmd.Dir = tmp |
| 1698 | out, err := cmd.CombinedOutput() |
| 1699 | if err != nil { |
| 1700 | t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| 1701 | } |
| 1702 | return string(out) |
| 1703 | } |
| 1704 | |
| 1705 | run("env", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "LOG_LEVEL=debug", "FEATURE_X=on") |
| 1706 | out := run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| 1707 | for _, want := range []string{`(?m)^LOG_LEVEL\s+debug$`, `(?m)^FEATURE_X\s+on$`} { |
| 1708 | if !regexp.MustCompile(want).MatchString(out) { |
| 1709 | t.Fatalf("env list = %q, want a line matching %s", out, want) |
| 1710 | } |
| 1711 | } |
| 1712 | |
| 1713 | run("env", "unset", "-registry", regPath, "-account", "bab", "-app", "keymail", "FEATURE_X") |
| 1714 | out = run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| 1715 | if strings.Contains(out, "FEATURE_X") { |
| 1716 | t.Fatalf("env list = %q, still shows unset FEATURE_X", out) |
| 1717 | } |
| 1718 | if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+debug$`).MatchString(out) { |
| 1719 | t.Fatalf("env list = %q, lost LOG_LEVEL after an unrelated unset", out) |
| 1720 | } |
| 1721 | |
| 1722 | // The manifest lands at the account-scoped key, matching every other |
| 1723 | // app key internal/release writes. |
| 1724 | store := release.NewDirStore(storeDir) |
| 1725 | if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil { |
| 1726 | t.Fatalf("config manifest not at the account-scoped key: %v", err) |
| 1727 | } |
| 1728 | } |
| 1729 | |
| 1730 | func TestSecretsCLI(t *testing.T) { |
| 1731 | tmp := t.TempDir() |
| 1732 | carlosBin := sharedCarlos(t) |
| 1733 | storeDir := filepath.Join(tmp, "store") |
| 1734 | regPath := filepath.Join(tmp, "registry.db") |
| 1735 | baseEnv := carlosTestEnv(t) |
| 1736 | run := func(env []string, args ...string) string { |
| 1737 | t.Helper() |
| 1738 | cmd := exec.Command(carlosBin, args...) |
| 1739 | cmd.Env = env |
| 1740 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1741 | cmd.Dir = tmp |
| 1742 | out, err := cmd.CombinedOutput() |
| 1743 | if err != nil { |
| 1744 | t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| 1745 | } |
| 1746 | return string(out) |
| 1747 | } |
| 1748 | storeEnv := append(append([]string{}, baseEnv...), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| 1749 | |
| 1750 | keys := run(baseEnv, "secrets", "genkey") |
| 1751 | priv := regexp.MustCompile(`(?m)^CARLOS_SECRETS_KEY=(\S+)`).FindStringSubmatch(keys) |
| 1752 | pub := regexp.MustCompile(`(?m)^CARLOS_SECRETS_PUBKEY=(\S+)`).FindStringSubmatch(keys) |
| 1753 | if priv == nil || pub == nil { |
| 1754 | t.Fatalf("secrets genkey printed no key pair:\n%s", keys) |
| 1755 | } |
| 1756 | |
| 1757 | sealEnv := append(append([]string{}, storeEnv...), "CARLOS_SECRETS_PUBKEY="+pub[1]) |
| 1758 | run(sealEnv, "secrets", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "SES_SECRET=s3kr1t") |
| 1759 | |
| 1760 | out := run(sealEnv, "secrets", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| 1761 | if !strings.Contains(out, "SES_SECRET") { |
| 1762 | t.Fatalf("secrets list = %q, want SES_SECRET listed", out) |
| 1763 | } |
| 1764 | if strings.Contains(out, "s3kr1t") { |
| 1765 | t.Fatalf("secrets list = %q, printed a secret value — it must only ever print key names", out) |
| 1766 | } |
| 1767 | |
| 1768 | // The value stored in the bucket is opaque ciphertext, not the plaintext. |
| 1769 | store := release.NewDirStore(storeDir) |
| 1770 | raw, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json") |
| 1771 | if err != nil { |
| 1772 | t.Fatalf("read config manifest: %v", err) |
| 1773 | } |
| 1774 | if strings.Contains(string(raw), "s3kr1t") { |
| 1775 | t.Fatalf("config.json contains the plaintext secret:\n%s", raw) |
| 1776 | } |
| 1777 | |
| 1778 | // And it decrypts back to the original plaintext with the private half — |
| 1779 | // the CLI's Seal and the library's Open agree on the wire format. |
| 1780 | var manifest appconfig.Manifest |
| 1781 | if err := json.Unmarshal(raw, &manifest); err != nil { |
| 1782 | t.Fatalf("unmarshal manifest: %v", err) |
| 1783 | } |
| 1784 | privKey, err := appconfig.ParsePrivateKey(priv[1], "test") |
| 1785 | if err != nil { |
| 1786 | t.Fatalf("ParsePrivateKey: %v", err) |
| 1787 | } |
| 1788 | plain, err := appconfig.Open(privKey, appconfig.Binding{Account: "bab", App: "keymail"}, manifest.Vars["SES_SECRET"].Envelope) |
| 1789 | if err != nil { |
| 1790 | t.Fatalf("Open: %v", err) |
| 1791 | } |
| 1792 | if string(plain) != "s3kr1t" { |
| 1793 | t.Fatalf("decrypted secret = %q, want s3kr1t", plain) |
| 1794 | } |
| 1795 | } |
| 1796 | |
| 1797 | // TestEnvironmentBundlesCLI exercises `carlos env`/`carlos secrets -environment` |
| 1798 | // (2026-08-02-pipelines-design.md §4a, plan Task 4): named environment |
| 1799 | // bundles are the same manifest shape as config.json, at a sibling bucket |
| 1800 | // key, created implicitly on first `set` exactly like the default bundle — |
| 1801 | // and never touched by a write to the other. |
| 1802 | func TestEnvironmentBundlesCLI(t *testing.T) { |
| 1803 | tmp := t.TempDir() |
| 1804 | carlosBin := sharedCarlos(t) |
| 1805 | storeDir := filepath.Join(tmp, "store") |
| 1806 | env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| 1807 | run := func(args ...string) string { |
| 1808 | t.Helper() |
| 1809 | cmd := exec.Command(carlosBin, args...) |
| 1810 | cmd.Env = env |
| 1811 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1812 | cmd.Dir = tmp |
| 1813 | out, err := cmd.CombinedOutput() |
| 1814 | if err != nil { |
| 1815 | t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| 1816 | } |
| 1817 | return string(out) |
| 1818 | } |
| 1819 | runFails := func(args ...string) string { |
| 1820 | t.Helper() |
| 1821 | cmd := exec.Command(carlosBin, args...) |
| 1822 | cmd.Env = env |
| 1823 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1824 | cmd.Dir = tmp |
| 1825 | out, err := cmd.CombinedOutput() |
| 1826 | if err == nil { |
| 1827 | t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out) |
| 1828 | } |
| 1829 | return string(out) |
| 1830 | } |
| 1831 | |
| 1832 | // Named-bundle round trip: set on a named environment, see it in that |
| 1833 | // environment's own `list`, unset removes it. |
| 1834 | run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL=trace") |
| 1835 | out := run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo") |
| 1836 | if !strings.Contains(out, "demo") { |
| 1837 | t.Fatalf("env list --environment demo = %q, want the bundle name in the header", out) |
| 1838 | } |
| 1839 | if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+trace$`).MatchString(out) { |
| 1840 | t.Fatalf("env list --environment demo = %q, want LOG_LEVEL=trace", out) |
| 1841 | } |
| 1842 | // The count is of REMOVALS, not of arguments (review MINOR 9): a key that |
| 1843 | // was never set must not read as a successful removal. |
| 1844 | out = run("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL", "NEVER_SET") |
| 1845 | if !strings.Contains(out, "unset 1 of 2") { |
| 1846 | t.Fatalf("env unset = %q, want it to report 1 of 2 keys actually removed", out) |
| 1847 | } |
| 1848 | out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo") |
| 1849 | if strings.Contains(out, "LOG_LEVEL") { |
| 1850 | t.Fatalf("env list --environment demo = %q, LOG_LEVEL survived unset", out) |
| 1851 | } |
| 1852 | |
| 1853 | // Review CRITICAL 2: unsetting from a bundle that does not exist is a |
| 1854 | // refusal, not a create — for a named bundle AND for the default one. |
| 1855 | // Creating it would mint a manifest that EXISTS with zero vars, which is |
| 1856 | // the state config sync converges every one of that app's env files to |
| 1857 | // empty for, as root. |
| 1858 | msg := runFails("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "neverexisted", "ANY") |
| 1859 | if !strings.Contains(msg, "nothing to unset") { |
| 1860 | t.Fatalf("unset on a nonexistent bundle = %q, want it to say there is nothing to unset", msg) |
| 1861 | } |
| 1862 | msg = runFails("secrets", "unset", "-account", "bab", "-app", "keymail-never-configured", "ANY") |
| 1863 | if !strings.Contains(msg, "nothing to unset") { |
| 1864 | t.Fatalf("secrets unset on a never-configured app = %q, want a refusal", msg) |
| 1865 | } |
| 1866 | store := release.NewDirStore(storeDir) |
| 1867 | if _, _, err := store.Get(context.Background(), "apps/bab/keymail-never-configured/config.json"); err == nil { |
| 1868 | t.Fatal("an unset against a never-configured app created its config.json") |
| 1869 | } |
| 1870 | |
| 1871 | // Default untouched by a named-bundle write: set something on demo, and |
| 1872 | // on a second, never-before-seen environment; the default bundle (no |
| 1873 | // -environment) still shows nothing. |
| 1874 | run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "STRIPE_MODE=sandbox") |
| 1875 | run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "staging", "STRIPE_MODE=staging-sandbox") |
| 1876 | out = run("env", "list", "-account", "bab", "-app", "keymail") |
| 1877 | if !strings.Contains(out, "default") { |
| 1878 | t.Fatalf("env list (no --environment) = %q, want the bundle header to say default", out) |
| 1879 | } |
| 1880 | if strings.Contains(out, "STRIPE_MODE") { |
| 1881 | t.Fatalf("env list (no --environment) = %q, a named-bundle write leaked into the default bundle", out) |
| 1882 | } |
| 1883 | // And the reverse: write the default bundle, and it does not appear |
| 1884 | // under the named ones. |
| 1885 | run("env", "set", "-account", "bab", "-app", "keymail", "BASE_ONLY=1") |
| 1886 | out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo") |
| 1887 | if strings.Contains(out, "BASE_ONLY") { |
| 1888 | t.Fatalf("env list --environment demo = %q, a default-bundle write leaked into a named bundle", out) |
| 1889 | } |
| 1890 | |
| 1891 | // `carlos env environments -app <a>` lists the bundle names created |
| 1892 | // implicitly above, sorted, and nothing else (no "default" entry — that |
| 1893 | // bundle isn't a named environment). |
| 1894 | out = run("env", "environments", "-account", "bab", "-app", "keymail") |
| 1895 | names := strings.Fields(strings.TrimSpace(out)) |
| 1896 | if want := []string{"demo", "staging"}; !reflect.DeepEqual(names, want) { |
| 1897 | t.Fatalf("env environments = %q, want %v", names, want) |
| 1898 | } |
| 1899 | |
| 1900 | // An app with no named environments lists none, not an error. |
| 1901 | out = run("env", "environments", "-account", "bab", "-app", "keymail-bare") |
| 1902 | if strings.TrimSpace(out) != "" { |
| 1903 | t.Fatalf("env environments for a bare app = %q, want empty", out) |
| 1904 | } |
| 1905 | |
| 1906 | // Reserved-name refusal: "default" is refused, with a message that |
| 1907 | // explains default already names the base bundle rather than a bare |
| 1908 | // "invalid name". An invalid-charset name is refused too. |
| 1909 | msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1") |
| 1910 | if !strings.Contains(msg, "default") || !strings.Contains(strings.ToLower(msg), "base bundle") { |
| 1911 | t.Fatalf("env set --environment default = %q, want a refusal naming default as the base bundle", msg) |
| 1912 | } |
| 1913 | msg = runFails("secrets", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1") |
| 1914 | if !strings.Contains(msg, "default") { |
| 1915 | t.Fatalf("secrets set --environment default = %q, want a refusal", msg) |
| 1916 | } |
| 1917 | msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "Not_Valid!", "X=1") |
| 1918 | if !strings.Contains(msg, "Not_Valid!") { |
| 1919 | t.Fatalf("env set --environment Not_Valid! = %q, want a refusal naming the bad value", msg) |
| 1920 | } |
| 1921 | // "-environment \"\"" is indistinguishable from omitting the flag — the |
| 1922 | // CLI's sentinel for "the default bundle", never a name to refuse. Not a |
| 1923 | // refusal case: it must succeed, against the default bundle. |
| 1924 | out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "") |
| 1925 | if !strings.Contains(out, "default") { |
| 1926 | t.Fatalf(`env list --environment "" = %q, want it treated as the default bundle`, out) |
| 1927 | } |
| 1928 | |
| 1929 | // The named bundle lands at the sibling bucket key the design specifies. |
| 1930 | if _, _, err := store.Get(context.Background(), "apps/bab/keymail/environments/demo.json"); err != nil { |
| 1931 | t.Fatalf("named environment manifest not at the sibling key: %v", err) |
| 1932 | } |
| 1933 | // And the default bundle is untouched in shape and key. |
| 1934 | if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil { |
| 1935 | t.Fatalf("default config manifest missing: %v", err) |
| 1936 | } |
| 1937 | } |
| 1938 | |
| 1939 | // TestExecDeliveryCLI exercises `carlos env exec-delivery` (issues #18, |
| 1940 | // #195): the opt-in that lets SyncExec materialize /data/<host>.config.env |
| 1941 | // for TCP-addr exec routes — round-trip at app scope, round-trip at |
| 1942 | // environment scope, unknown-value refusal, and the two scope-specific |
| 1943 | // clearing words refusing each other's ("off" vs "inherit"), following |
| 1944 | // TestEnvCLI's build-a-real-binary pattern. |
| 1945 | func TestExecDeliveryCLI(t *testing.T) { |
| 1946 | tmp := t.TempDir() |
| 1947 | carlosBin := sharedCarlos(t) |
| 1948 | storeDir := filepath.Join(tmp, "store") |
| 1949 | env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| 1950 | run := func(args ...string) string { |
| 1951 | t.Helper() |
| 1952 | cmd := exec.Command(carlosBin, args...) |
| 1953 | cmd.Env = env |
| 1954 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1955 | cmd.Dir = tmp |
| 1956 | out, err := cmd.CombinedOutput() |
| 1957 | if err != nil { |
| 1958 | t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| 1959 | } |
| 1960 | return string(out) |
| 1961 | } |
| 1962 | runFails := func(args ...string) string { |
| 1963 | t.Helper() |
| 1964 | cmd := exec.Command(carlosBin, args...) |
| 1965 | cmd.Env = env |
| 1966 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 1967 | cmd.Dir = tmp |
| 1968 | out, err := cmd.CombinedOutput() |
| 1969 | if err == nil { |
| 1970 | t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out) |
| 1971 | } |
| 1972 | return string(out) |
| 1973 | } |
| 1974 | |
| 1975 | // Round trip: off by default (not mentioned in a plain list), "file" |
| 1976 | // turns it on and a plain `env list` mentions it, "off" clears it |
| 1977 | // again and the mention disappears. |
| 1978 | run("env", "set", "-account", "bab", "-app", "titogo", "K=1") |
| 1979 | out := run("env", "list", "-account", "bab", "-app", "titogo") |
| 1980 | if strings.Contains(out, "exec-delivery") { |
| 1981 | t.Fatalf("env list before opt-in = %q, must not mention exec-delivery", out) |
| 1982 | } |
| 1983 | |
| 1984 | out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "file") |
| 1985 | if !strings.Contains(out, "titogo") || !strings.Contains(out, "file") { |
| 1986 | t.Fatalf("env exec-delivery file = %q, want a confirmation naming the app and the value", out) |
| 1987 | } |
| 1988 | out = run("env", "list", "-account", "bab", "-app", "titogo") |
| 1989 | if !regexp.MustCompile(`(?m)^exec-delivery:\s*file$`).MatchString(out) { |
| 1990 | t.Fatalf("env list after opt-in = %q, want an exec-delivery: file line", out) |
| 1991 | } |
| 1992 | // The var set earlier survives — exec-delivery is a field write, not a |
| 1993 | // bundle replace. |
| 1994 | if !regexp.MustCompile(`(?m)^K\s+1$`).MatchString(out) { |
| 1995 | t.Fatalf("env list after opt-in = %q, lost K=1 (exec-delivery must not clobber existing vars)", out) |
| 1996 | } |
| 1997 | |
| 1998 | out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "off") |
| 1999 | if !strings.Contains(out, "off") { |
| 2000 | t.Fatalf("env exec-delivery off = %q, want a confirmation", out) |
| 2001 | } |
| 2002 | out = run("env", "list", "-account", "bab", "-app", "titogo") |
| 2003 | if strings.Contains(out, "exec-delivery") { |
| 2004 | t.Fatalf("env list after off = %q, must no longer mention exec-delivery", out) |
| 2005 | } |
| 2006 | |
| 2007 | // Unknown values are refused at set time, naming the bad value. |
| 2008 | msg := runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "grpc") |
| 2009 | if !strings.Contains(msg, "grpc") { |
| 2010 | t.Fatalf("env exec-delivery grpc = %q, want a refusal naming the bad value", msg) |
| 2011 | } |
| 2012 | msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "on") // not the spelling |
| 2013 | if !strings.Contains(msg, "on") { |
| 2014 | t.Fatalf("env exec-delivery on = %q, want a refusal naming the bad value", msg) |
| 2015 | } |
| 2016 | |
| 2017 | store := release.NewDirStore(storeDir) |
| 2018 | |
| 2019 | // Named-environment placement (issue #195): the flag lands in THAT |
| 2020 | // bundle, and a plain (default-bundle) listing does not mention it — |
| 2021 | // the whole point being that opting one bundle in must not read as |
| 2022 | // opting the app in. |
| 2023 | out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "file") |
| 2024 | if !strings.Contains(out, "demo") || !strings.Contains(out, "file") { |
| 2025 | t.Fatalf("env exec-delivery --environment demo file = %q, want a confirmation naming the bundle and the value", out) |
| 2026 | } |
| 2027 | out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") |
| 2028 | if !regexp.MustCompile(`(?m)^exec-delivery:\s*file$`).MatchString(out) { |
| 2029 | t.Fatalf("env list --environment demo = %q, want an exec-delivery: file line", out) |
| 2030 | } |
| 2031 | out = run("env", "list", "-account", "bab", "-app", "titogo") |
| 2032 | if strings.Contains(out, "exec-delivery") { |
| 2033 | t.Fatalf("default-bundle env list = %q, must not report a NAMED bundle's flag", out) |
| 2034 | } |
| 2035 | |
| 2036 | // "off" is refused for a named bundle, by name: clearing there means |
| 2037 | // inherit, and a verb that spelled it "off" would read as "stop |
| 2038 | // delivering" while doing the opposite. |
| 2039 | msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "off") |
| 2040 | if !strings.Contains(msg, "inherit") { |
| 2041 | t.Fatalf("env exec-delivery --environment demo off = %q, want a refusal naming \"inherit\"", msg) |
| 2042 | } |
| 2043 | out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") |
| 2044 | if !strings.Contains(out, "exec-delivery") { |
| 2045 | t.Fatalf("the refused \"off\" cleared the bundle's flag anyway: %q", out) |
| 2046 | } |
| 2047 | |
| 2048 | // "inherit" clears it. |
| 2049 | out = run("env", "exec-delivery", "-account", "bab", "-app", "titogo", "-environment", "demo", "inherit") |
| 2050 | if !strings.Contains(out, "inherit") { |
| 2051 | t.Fatalf("env exec-delivery --environment demo inherit = %q, want a confirmation", out) |
| 2052 | } |
| 2053 | out = run("env", "list", "-account", "bab", "-app", "titogo", "-environment", "demo") |
| 2054 | if strings.Contains(out, "exec-delivery") { |
| 2055 | t.Fatalf("env list --environment demo after inherit = %q, must no longer mention exec-delivery", out) |
| 2056 | } |
| 2057 | |
| 2058 | // And "inherit" is refused at APP scope, for the mirror-image reason: |
| 2059 | // the default bundle has nothing above it to inherit from. |
| 2060 | msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo", "inherit") |
| 2061 | if !strings.Contains(msg, "off") { |
| 2062 | t.Fatalf("env exec-delivery inherit (no environment) = %q, want a refusal naming \"off\"", msg) |
| 2063 | } |
| 2064 | |
| 2065 | // Turning off against an app that was never configured at all is |
| 2066 | // refused, not a silent create — same idiom as `env unset` on a |
| 2067 | // nonexistent bundle. |
| 2068 | msg = runFails("env", "exec-delivery", "-account", "bab", "-app", "titogo-never-configured", "off") |
| 2069 | if !strings.Contains(msg, "nothing to turn off") { |
| 2070 | t.Fatalf("env exec-delivery off on a never-configured app = %q, want a refusal", msg) |
| 2071 | } |
| 2072 | if _, _, err := store.Get(context.Background(), "apps/bab/titogo-never-configured/config.json"); err == nil { |
| 2073 | t.Fatal("env exec-delivery off on a never-configured app created its config.json") |
| 2074 | } |
| 2075 | } |
| 2076 | |
| 2077 | // TestEnvironmentMergeVisibleFromCLI is the merge-visible check the plan |
| 2078 | // asks for: set the default bundle plus a named override through the CLI |
| 2079 | // exactly as an operator would, then verify — via the library's LoadMerged, |
| 2080 | // the same function Sync calls per bound route — that the merged view a |
| 2081 | // route bound to that environment would actually receive has the override |
| 2082 | // applied and everything else inherited from default. |
| 2083 | func TestEnvironmentMergeVisibleFromCLI(t *testing.T) { |
| 2084 | tmp := t.TempDir() |
| 2085 | carlosBin := sharedCarlos(t) |
| 2086 | storeDir := filepath.Join(tmp, "store") |
| 2087 | env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| 2088 | run := func(args ...string) { |
| 2089 | t.Helper() |
| 2090 | cmd := exec.Command(carlosBin, args...) |
| 2091 | cmd.Env = env |
| 2092 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 2093 | cmd.Dir = tmp |
| 2094 | if out, err := cmd.CombinedOutput(); err != nil { |
| 2095 | t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| 2096 | } |
| 2097 | } |
| 2098 | |
| 2099 | run("env", "set", "-account", "bab", "-app", "tito", |
| 2100 | "STRIPE_KEY=sk_base", "LOG_LEVEL=info", "REGION=eu") |
| 2101 | run("env", "set", "-account", "bab", "-app", "tito", "-environment", "demo", |
| 2102 | "STRIPE_KEY=sk_demo") |
| 2103 | |
| 2104 | store := release.NewDirStore(storeDir) |
| 2105 | merged, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "demo") |
| 2106 | if err != nil { |
| 2107 | t.Fatalf("LoadMerged: %v", err) |
| 2108 | } |
| 2109 | want := map[string]string{"STRIPE_KEY": "sk_demo", "LOG_LEVEL": "info", "REGION": "eu"} |
| 2110 | if len(merged.Vars) != len(want) { |
| 2111 | t.Fatalf("merged vars = %v, want exactly %v", merged.Vars, want) |
| 2112 | } |
| 2113 | for k, v := range want { |
| 2114 | got, ok := merged.Vars[k] |
| 2115 | if !ok || got.Value != v { |
| 2116 | t.Fatalf("merged.Vars[%s] = %+v, want value %q", k, got, v) |
| 2117 | } |
| 2118 | } |
| 2119 | |
| 2120 | // A route with no -environment binding — envName=="" — sees the default |
| 2121 | // bundle alone, the override absent. |
| 2122 | base, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "") |
| 2123 | if err != nil { |
| 2124 | t.Fatalf("LoadMerged (default): %v", err) |
| 2125 | } |
| 2126 | if base.Vars["STRIPE_KEY"].Value != "sk_base" { |
| 2127 | t.Fatalf("default-bound LoadMerged saw STRIPE_KEY=%q, want sk_base — the demo override leaked into the unbound route", |
| 2128 | base.Vars["STRIPE_KEY"].Value) |
| 2129 | } |
| 2130 | } |
| 2131 | |
| 2132 | // TestRouteCLI exercises `carlos route`, the verb that lets a route's channel |
| 2133 | // subscription change without raw sqlite against a production registry. The |
| 2134 | // safety rail IS the point of the verb: a route only repoints when the |
| 2135 | // target channel currently serves the exact version the route is running, or |
| 2136 | // when the operator explicitly says -allow-version-change — because a |
| 2137 | // mismatch means the next adopt pass changes what production serves, and |
| 2138 | // that must never happen by accident (the keymail.dev canary/keymail -> |
| 2139 | // stable move this verb was built for). |
| 2140 | func TestRouteCLI(t *testing.T) { |
| 2141 | tmp := t.TempDir() |
| 2142 | carlosBin := sharedCarlos(t) |
| 2143 | storeDir := filepath.Join(tmp, "store") |
| 2144 | regPath := filepath.Join(tmp, "registry.db") |
| 2145 | store := release.NewDirStore(storeDir) |
| 2146 | ctx := context.Background() |
| 2147 | |
| 2148 | seed, pubB64, err := release.GenerateKey() |
| 2149 | if err != nil { |
| 2150 | t.Fatal(err) |
| 2151 | } |
| 2152 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 2153 | if err != nil { |
| 2154 | t.Fatal(err) |
| 2155 | } |
| 2156 | env := carlosTestEnv(t, |
| 2157 | "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| 2158 | "CARLOS_RELEASE_PUBKEY="+pubB64, |
| 2159 | "HOME="+tmp, |
| 2160 | "CARLOS_CONSOLE=", |
| 2161 | "CARLOS_DESTINATION=", |
| 2162 | ) |
| 2163 | run := func(args ...string) (string, error) { |
| 2164 | t.Helper() |
| 2165 | cmd := exec.Command(carlosBin, args...) |
| 2166 | cmd.Env = env |
| 2167 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 2168 | cmd.Dir = tmp |
| 2169 | out, err := cmd.CombinedOutput() |
| 2170 | return string(out), err |
| 2171 | } |
| 2172 | ship := func(store release.Store, acct, app, version string) { |
| 2173 | t.Helper() |
| 2174 | m := release.Manifest{Schema: 1, App: app, Version: version, Kind: "binary", |
| 2175 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}}} |
| 2176 | if err := release.Ship(ctx, store, acct, m, map[string][]byte{"linux-amd64": []byte(version + " bytes")}); err != nil { |
| 2177 | t.Fatal(err) |
| 2178 | } |
| 2179 | } |
| 2180 | promote := func(acct, app, channel, version string) { |
| 2181 | t.Helper() |
| 2182 | if err := release.Promote(ctx, store, priv, acct, app, channel, version); err != nil { |
| 2183 | t.Fatal(err) |
| 2184 | } |
| 2185 | } |
| 2186 | |
| 2187 | reg, err := registry.Open(regPath) |
| 2188 | if err != nil { |
| 2189 | t.Fatal(err) |
| 2190 | } |
| 2191 | |
| 2192 | // keymail.dev: canary/keymail and stable both already hold 5457379 — the |
| 2193 | // no-op-safe case. |
| 2194 | ship(store, release.OpsAccount, "keymail", "5457379") |
| 2195 | promote(release.OpsAccount, "keymail", "canary/keymail", "5457379") |
| 2196 | promote(release.OpsAccount, "keymail", "stable", "5457379") |
| 2197 | if err := reg.Upsert(registry.Route{Host: "keymail.dev", Kind: "instance", |
| 2198 | App: "keymail", Channel: "canary/keymail", Version: "5457379", Status: "ready"}); err != nil { |
| 2199 | t.Fatal(err) |
| 2200 | } |
| 2201 | |
| 2202 | // widget: canary/widget (what the route is running) and stable (the |
| 2203 | // target) disagree — the refusal case. |
| 2204 | ship(store, release.OpsAccount, "widget", "v1") |
| 2205 | ship(store, release.OpsAccount, "widget", "v2") |
| 2206 | promote(release.OpsAccount, "widget", "canary/widget", "v1") |
| 2207 | promote(release.OpsAccount, "widget", "stable", "v2") |
| 2208 | if err := reg.Upsert(registry.Route{Host: "widget.example.com", Kind: "instance", |
| 2209 | App: "widget", Channel: "canary/widget", Version: "v1", Status: "ready"}); err != nil { |
| 2210 | t.Fatal(err) |
| 2211 | } |
| 2212 | reg.Close() |
| 2213 | |
| 2214 | t.Run("unknown host is a hard error naming the host", func(t *testing.T) { |
| 2215 | out, err := run("route", "-registry", regPath, "-host", "nosuch.example.com", "-channel", "stable") |
| 2216 | if err == nil { |
| 2217 | t.Fatalf("unknown host was accepted:\n%s", out) |
| 2218 | } |
| 2219 | if !strings.Contains(out, "nosuch.example.com") { |
| 2220 | t.Errorf("refusal does not name the host:\n%s", out) |
| 2221 | } |
| 2222 | }) |
| 2223 | |
| 2224 | t.Run("neither --host nor --app is a usage error", func(t *testing.T) { |
| 2225 | out, err := run("route", "-registry", regPath, "-channel", "stable") |
| 2226 | if err == nil { |
| 2227 | t.Fatalf("neither --host nor --app was accepted:\n%s", out) |
| 2228 | } |
| 2229 | if !strings.Contains(out, "usage") { |
| 2230 | t.Errorf("refusal is not a usage message:\n%s", out) |
| 2231 | } |
| 2232 | }) |
| 2233 | |
| 2234 | t.Run("--host and --app together is an error", func(t *testing.T) { |
| 2235 | out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-app", "keymail", "-channel", "stable") |
| 2236 | if err == nil { |
| 2237 | t.Fatalf("both --host and --app was accepted:\n%s", out) |
| 2238 | } |
| 2239 | }) |
| 2240 | |
| 2241 | t.Run("empty --channel explains the difference from route creation", func(t *testing.T) { |
| 2242 | out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-channel", "") |
| 2243 | if err == nil { |
| 2244 | t.Fatalf("empty --channel was accepted:\n%s", out) |
| 2245 | } |
| 2246 | if !strings.Contains(out, "stable-by-default") { |
| 2247 | t.Errorf("refusal does not explain \"\" at route creation vs. this verb:\n%s", out) |
| 2248 | } |
| 2249 | }) |
| 2250 | |
| 2251 | t.Run("invalid channel is refused", func(t *testing.T) { |
| 2252 | out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-channel", "not a channel") |
| 2253 | if err == nil { |
| 2254 | t.Fatalf("invalid channel was accepted:\n%s", out) |
| 2255 | } |
| 2256 | }) |
| 2257 | |
| 2258 | t.Run("versions match: proceeds and says so", func(t *testing.T) { |
| 2259 | out, err := run("route", "-registry", regPath, "-host", "keymail.dev", "-channel", "stable") |
| 2260 | if err != nil { |
| 2261 | t.Fatalf("route: %v\n%s", err, out) |
| 2262 | } |
| 2263 | if !strings.Contains(out, "canary/keymail -> stable") || !strings.Contains(out, "version unchanged: 5457379") { |
| 2264 | t.Errorf("success line missing expected shape:\n%s", out) |
| 2265 | } |
| 2266 | r, err := registry.Open(regPath) |
| 2267 | if err != nil { |
| 2268 | t.Fatal(err) |
| 2269 | } |
| 2270 | defer r.Close() |
| 2271 | rt, err := r.Get("keymail.dev") |
| 2272 | if err != nil || rt == nil || rt.Channel != "stable" { |
| 2273 | t.Fatalf("route channel = %+v, %v, want stable", rt, err) |
| 2274 | } |
| 2275 | }) |
| 2276 | |
| 2277 | t.Run("versions differ: refused, both versions printed", func(t *testing.T) { |
| 2278 | out, err := run("route", "-registry", regPath, "-host", "widget.example.com", "-channel", "stable") |
| 2279 | if err == nil { |
| 2280 | t.Fatalf("a differing-version repoint was accepted:\n%s", out) |
| 2281 | } |
| 2282 | if !strings.Contains(out, "v1") || !strings.Contains(out, "v2") { |
| 2283 | t.Errorf("refusal does not print both versions:\n%s", out) |
| 2284 | } |
| 2285 | if !strings.Contains(out, "-allow-version-change") { |
| 2286 | t.Errorf("refusal does not name the override:\n%s", out) |
| 2287 | } |
| 2288 | r, err := registry.Open(regPath) |
| 2289 | if err != nil { |
| 2290 | t.Fatal(err) |
| 2291 | } |
| 2292 | defer r.Close() |
| 2293 | rt, err := r.Get("widget.example.com") |
| 2294 | if err != nil || rt == nil || rt.Channel != "canary/widget" { |
| 2295 | t.Fatalf("route channel changed despite the refusal: %+v, %v", rt, err) |
| 2296 | } |
| 2297 | }) |
| 2298 | |
| 2299 | t.Run("--allow-version-change overrides", func(t *testing.T) { |
| 2300 | out, err := run("route", "-registry", regPath, "-host", "widget.example.com", |
| 2301 | "-channel", "stable", "-allow-version-change") |
| 2302 | if err != nil { |
| 2303 | t.Fatalf("route --allow-version-change: %v\n%s", err, out) |
| 2304 | } |
| 2305 | if !strings.Contains(out, "canary/widget -> stable") || !strings.Contains(out, "v1") || !strings.Contains(out, "v2") { |
| 2306 | t.Errorf("success line does not show the version change:\n%s", out) |
| 2307 | } |
| 2308 | r, err := registry.Open(regPath) |
| 2309 | if err != nil { |
| 2310 | t.Fatal(err) |
| 2311 | } |
| 2312 | defer r.Close() |
| 2313 | rt, err := r.Get("widget.example.com") |
| 2314 | if err != nil || rt == nil || rt.Channel != "stable" { |
| 2315 | t.Fatalf("route channel = %+v, %v, want stable", rt, err) |
| 2316 | } |
| 2317 | }) |
| 2318 | |
| 2319 | t.Run("--app form moves every matching route", func(t *testing.T) { |
| 2320 | ship(store, release.OpsAccount, "fleet", "f1") |
| 2321 | promote(release.OpsAccount, "fleet", "canary/platform-adoption", "f1") |
| 2322 | promote(release.OpsAccount, "fleet", "stable", "f1") |
| 2323 | r, err := registry.Open(regPath) |
| 2324 | if err != nil { |
| 2325 | t.Fatal(err) |
| 2326 | } |
| 2327 | if err := r.Upsert(registry.Route{Host: "fleet-a.example.com", Kind: "instance", |
| 2328 | App: "fleet", Channel: "canary/platform-adoption", Version: "f1", Status: "ready"}); err != nil { |
| 2329 | t.Fatal(err) |
| 2330 | } |
| 2331 | if err := r.Upsert(registry.Route{Host: "fleet-b.example.com", Kind: "instance", |
| 2332 | App: "fleet", Channel: "canary/platform-adoption", Version: "f1", Status: "ready"}); err != nil { |
| 2333 | t.Fatal(err) |
| 2334 | } |
| 2335 | r.Close() |
| 2336 | |
| 2337 | out, err := run("route", "-registry", regPath, "-app", "fleet", "-channel", "stable") |
| 2338 | if err != nil { |
| 2339 | t.Fatalf("route --app: %v\n%s", err, out) |
| 2340 | } |
| 2341 | for _, host := range []string{"fleet-a.example.com", "fleet-b.example.com"} { |
| 2342 | if !strings.Contains(out, host) { |
| 2343 | t.Errorf("output does not mention %s:\n%s", host, out) |
| 2344 | } |
| 2345 | } |
| 2346 | r2, err := registry.Open(regPath) |
| 2347 | if err != nil { |
| 2348 | t.Fatal(err) |
| 2349 | } |
| 2350 | defer r2.Close() |
| 2351 | for _, host := range []string{"fleet-a.example.com", "fleet-b.example.com"} { |
| 2352 | rt, err := r2.Get(host) |
| 2353 | if err != nil || rt == nil || rt.Channel != "stable" { |
| 2354 | t.Fatalf("%s channel = %+v, %v, want stable", host, rt, err) |
| 2355 | } |
| 2356 | } |
| 2357 | }) |
| 2358 | |
| 2359 | t.Run("--app form with zero matching routes errors, naming the app", func(t *testing.T) { |
| 2360 | out, err := run("route", "-registry", regPath, "-app", "nosuchapp", "-channel", "stable") |
| 2361 | if err == nil { |
| 2362 | t.Fatalf("zero matching routes was accepted:\n%s", out) |
| 2363 | } |
| 2364 | if !strings.Contains(out, "nosuchapp") { |
| 2365 | t.Errorf("refusal does not name the app:\n%s", out) |
| 2366 | } |
| 2367 | }) |
| 2368 | |
| 2369 | t.Run("--app form optionally filtered by --account", func(t *testing.T) { |
| 2370 | accOut, err := run("accounts", "create", "-registry", regPath, "-name", "second", "-owner", "second@keymail.dev") |
| 2371 | if err != nil { |
| 2372 | t.Fatalf("accounts create: %v\n%s", err, accOut) |
| 2373 | } |
| 2374 | m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(accOut) |
| 2375 | if m == nil { |
| 2376 | t.Fatalf("accounts create printed no sqid:\n%s", accOut) |
| 2377 | } |
| 2378 | sqid := m[1] |
| 2379 | |
| 2380 | ship(store, release.OpsAccount, "acctapp", "a1") |
| 2381 | promote(release.OpsAccount, "acctapp", "canary/x", "a1") |
| 2382 | promote(release.OpsAccount, "acctapp", "stable", "a1") |
| 2383 | ship(store, sqid, "acctapp", "a1") |
| 2384 | promote(sqid, "acctapp", "canary/x", "a1") |
| 2385 | promote(sqid, "acctapp", "stable", "a1") |
| 2386 | |
| 2387 | r, err := registry.Open(regPath) |
| 2388 | if err != nil { |
| 2389 | t.Fatal(err) |
| 2390 | } |
| 2391 | if err := r.Upsert(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance", |
| 2392 | App: "acctapp", Account: release.OpsAccount, Channel: "canary/x", Version: "a1", Status: "ready"}); err != nil { |
| 2393 | t.Fatal(err) |
| 2394 | } |
| 2395 | if err := r.Upsert(registry.Route{Host: "second-acctapp.example.com", Kind: "instance", |
| 2396 | App: "acctapp", Account: sqid, Channel: "canary/x", Version: "a1", Status: "ready"}); err != nil { |
| 2397 | t.Fatal(err) |
| 2398 | } |
| 2399 | r.Close() |
| 2400 | |
| 2401 | out, err := run("route", "-registry", regPath, "-app", "acctapp", "-channel", "stable", "-account", sqid) |
| 2402 | if err != nil { |
| 2403 | t.Fatalf("route --app --account: %v\n%s", err, out) |
| 2404 | } |
| 2405 | if strings.Contains(out, "ops-acctapp.example.com") { |
| 2406 | t.Errorf("--account filter still touched the ops route:\n%s", out) |
| 2407 | } |
| 2408 | if !strings.Contains(out, "second-acctapp.example.com") { |
| 2409 | t.Errorf("--account filter missed the target route:\n%s", out) |
| 2410 | } |
| 2411 | |
| 2412 | r2, err := registry.Open(regPath) |
| 2413 | if err != nil { |
| 2414 | t.Fatal(err) |
| 2415 | } |
| 2416 | defer r2.Close() |
| 2417 | if rt, err := r2.Get("ops-acctapp.example.com"); err != nil || rt == nil || rt.Channel != "canary/x" { |
| 2418 | t.Fatalf("unfiltered ops route changed: %+v, %v", rt, err) |
| 2419 | } |
| 2420 | if rt, err := r2.Get("second-acctapp.example.com"); err != nil || rt == nil || rt.Channel != "stable" { |
| 2421 | t.Fatalf("filtered route did not change: %+v, %v", rt, err) |
| 2422 | } |
| 2423 | }) |
| 2424 | } |
| 2425 | |
| 2426 | // routeTestFixture builds a carlos binary once and gives each caller a fresh |
| 2427 | // dir-store + registry + signing key to drive it against — the shared setup |
| 2428 | // under the fix-round-1 regression tests below (review findings on |
| 2429 | // route-verb, 2026-08-02). |
| 2430 | type routeTestFixture struct { |
| 2431 | t *testing.T |
| 2432 | bin string |
| 2433 | storeDir string |
| 2434 | regPath string |
| 2435 | tmpDir string |
| 2436 | store release.Store |
| 2437 | priv ed25519.PrivateKey |
| 2438 | pubB64 string |
| 2439 | } |
| 2440 | |
| 2441 | func newRouteTestFixture(t *testing.T) *routeTestFixture { |
| 2442 | t.Helper() |
| 2443 | tmp := t.TempDir() |
| 2444 | bin := sharedCarlos(t) |
| 2445 | storeDir := filepath.Join(tmp, "store") |
| 2446 | seed, pubB64, err := release.GenerateKey() |
| 2447 | if err != nil { |
| 2448 | t.Fatal(err) |
| 2449 | } |
| 2450 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 2451 | if err != nil { |
| 2452 | t.Fatal(err) |
| 2453 | } |
| 2454 | return &routeTestFixture{ |
| 2455 | t: t, bin: bin, storeDir: storeDir, regPath: filepath.Join(tmp, "registry.db"), |
| 2456 | tmpDir: tmp, store: release.NewDirStore(storeDir), priv: priv, pubB64: pubB64, |
| 2457 | } |
| 2458 | } |
| 2459 | |
| 2460 | // run invokes carlos with env built from base (nil = the full, correct env: |
| 2461 | // deployment dir + pubkey), so a test can knock one variable out to exercise |
| 2462 | // a fail-closed path. |
| 2463 | func (f *routeTestFixture) run(env []string, args ...string) (string, error) { |
| 2464 | f.t.Helper() |
| 2465 | cmd := exec.Command(f.bin, args...) |
| 2466 | cmd.Env = env |
| 2467 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 2468 | cmd.Dir = f.tmpDir |
| 2469 | out, err := cmd.CombinedOutput() |
| 2470 | return string(out), err |
| 2471 | } |
| 2472 | |
| 2473 | func (f *routeTestFixture) fullEnv() []string { |
| 2474 | return carlosTestEnv(f.t, |
| 2475 | "CARLOS_DEPLOYMENT_DIR="+f.storeDir, |
| 2476 | "CARLOS_RELEASE_PUBKEY="+f.pubB64, |
| 2477 | "HOME="+f.tmpDir, |
| 2478 | "CARLOS_CONSOLE=", |
| 2479 | "CARLOS_DESTINATION=", |
| 2480 | ) |
| 2481 | } |
| 2482 | |
| 2483 | func (f *routeTestFixture) ship(acct, app, version string) { |
| 2484 | f.t.Helper() |
| 2485 | m := release.Manifest{Schema: 1, App: app, Version: version, Kind: "binary", |
| 2486 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}}} |
| 2487 | if err := release.Ship(context.Background(), f.store, acct, m, map[string][]byte{"linux-amd64": []byte(version + " bytes")}); err != nil { |
| 2488 | f.t.Fatal(err) |
| 2489 | } |
| 2490 | } |
| 2491 | |
| 2492 | func (f *routeTestFixture) promote(acct, app, channel, version string) { |
| 2493 | f.t.Helper() |
| 2494 | if err := release.Promote(context.Background(), f.store, f.priv, acct, app, channel, version); err != nil { |
| 2495 | f.t.Fatal(err) |
| 2496 | } |
| 2497 | } |
| 2498 | |
| 2499 | func (f *routeTestFixture) addRoute(rt registry.Route) { |
| 2500 | f.t.Helper() |
| 2501 | reg, err := registry.Open(f.regPath) |
| 2502 | if err != nil { |
| 2503 | f.t.Fatal(err) |
| 2504 | } |
| 2505 | defer reg.Close() |
| 2506 | if err := reg.Upsert(rt); err != nil { |
| 2507 | f.t.Fatal(err) |
| 2508 | } |
| 2509 | } |
| 2510 | |
| 2511 | func (f *routeTestFixture) channelOf(host string) string { |
| 2512 | f.t.Helper() |
| 2513 | reg, err := registry.Open(f.regPath) |
| 2514 | if err != nil { |
| 2515 | f.t.Fatal(err) |
| 2516 | } |
| 2517 | defer reg.Close() |
| 2518 | rt, err := reg.Get(host) |
| 2519 | if err != nil { |
| 2520 | f.t.Fatal(err) |
| 2521 | } |
| 2522 | if rt == nil { |
| 2523 | f.t.Fatalf("no route for host %q", host) |
| 2524 | } |
| 2525 | return rt.Channel |
| 2526 | } |
| 2527 | |
| 2528 | func (f *routeTestFixture) environmentOf(host string) string { |
| 2529 | f.t.Helper() |
| 2530 | reg, err := registry.Open(f.regPath) |
| 2531 | if err != nil { |
| 2532 | f.t.Fatal(err) |
| 2533 | } |
| 2534 | defer reg.Close() |
| 2535 | rt, err := reg.Get(host) |
| 2536 | if err != nil { |
| 2537 | f.t.Fatal(err) |
| 2538 | } |
| 2539 | if rt == nil { |
| 2540 | f.t.Fatalf("no route for host %q", host) |
| 2541 | } |
| 2542 | return rt.Environment |
| 2543 | } |
| 2544 | |
| 2545 | // saveEnvironment writes a named environment bundle directly through |
| 2546 | // internal/appconfig, the same store the fixture's binary reads through |
| 2547 | // CARLOS_DEPLOYMENT_DIR — the existence rail `route -environment` checks. |
| 2548 | func (f *routeTestFixture) saveEnvironment(acct, app, name string) { |
| 2549 | f.t.Helper() |
| 2550 | if err := appconfig.SaveEnvironment(context.Background(), f.store, acct, app, name, |
| 2551 | map[string]appconfig.Var{"X": {Kind: "env", Value: "1"}}, nil); err != nil { |
| 2552 | f.t.Fatal(err) |
| 2553 | } |
| 2554 | } |
| 2555 | |
| 2556 | // CRITICAL 2 (review): a no-pubkey run must refuse — never a blind write. |
| 2557 | func TestRouteRefusesWithoutPubkey(t *testing.T) { |
| 2558 | f := newRouteTestFixture(t) |
| 2559 | f.ship(release.OpsAccount, "demo", "v1") |
| 2560 | f.promote(release.OpsAccount, "demo", "canary/x", "v1") |
| 2561 | f.promote(release.OpsAccount, "demo", "stable", "v1") |
| 2562 | f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 2563 | App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"}) |
| 2564 | |
| 2565 | // -allow-version-change is included deliberately (re-review NEW 2): the |
| 2566 | // three refusal tests in this file previously never passed it, so |
| 2567 | // nothing pinned that a missing pubkey stays refused EVEN WITH the |
| 2568 | // override — which is the entire content of the CRITICAL 2 widening. |
| 2569 | // Mutation-proven gap: flipping the pub==nil branch back to |
| 2570 | // overridable=true left the whole suite green until this flag was added |
| 2571 | // here. |
| 2572 | env := carlosTestEnv(f.t, "CARLOS_DEPLOYMENT_DIR="+f.storeDir) // no CARLOS_RELEASE_PUBKEY |
| 2573 | out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com", |
| 2574 | "-channel", "stable", "-allow-version-change") |
| 2575 | if err == nil { |
| 2576 | t.Fatalf("route with no CARLOS_RELEASE_PUBKEY was accepted even with --allow-version-change:\n%s", out) |
| 2577 | } |
| 2578 | if !strings.Contains(out, "CARLOS_RELEASE_PUBKEY") { |
| 2579 | t.Errorf("refusal does not name the missing var:\n%s", out) |
| 2580 | } |
| 2581 | if got := f.channelOf("demo.example.com"); got != "canary/x" { |
| 2582 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 2583 | } |
| 2584 | } |
| 2585 | |
| 2586 | // CRITICAL 2 / IMPORTANT 6: no deployment store configured must also refuse, |
| 2587 | // before any write — openStore's existing log.Fatal, pinned so a refactor |
| 2588 | // that hoists or short-circuits it would be caught. |
| 2589 | func TestRouteRefusesWithoutStore(t *testing.T) { |
| 2590 | f := newRouteTestFixture(t) |
| 2591 | f.ship(release.OpsAccount, "demo", "v1") |
| 2592 | f.promote(release.OpsAccount, "demo", "canary/x", "v1") |
| 2593 | f.promote(release.OpsAccount, "demo", "stable", "v1") |
| 2594 | f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 2595 | App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"}) |
| 2596 | |
| 2597 | env := carlosTestEnv(f.t, "CARLOS_RELEASE_PUBKEY="+f.pubB64) // no store env at all |
| 2598 | out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com", "-channel", "stable") |
| 2599 | if err == nil { |
| 2600 | t.Fatalf("route with no deployment store configured was accepted:\n%s", out) |
| 2601 | } |
| 2602 | if !strings.Contains(out, "CARLOS_DEPLOYMENT_BUCKET") && !strings.Contains(out, "CARLOS_DEPLOYMENT_DIR") { |
| 2603 | t.Errorf("refusal does not name the missing store env:\n%s", out) |
| 2604 | } |
| 2605 | if got := f.channelOf("demo.example.com"); got != "canary/x" { |
| 2606 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 2607 | } |
| 2608 | } |
| 2609 | |
| 2610 | // IMPORTANT 6: a pointer that fails signature verification (signed by a |
| 2611 | // DIFFERENT key than CARLOS_RELEASE_PUBKEY names) must refuse, not be |
| 2612 | // treated as absent or as trusted. |
| 2613 | func TestRouteRefusesOnBadSignature(t *testing.T) { |
| 2614 | f := newRouteTestFixture(t) |
| 2615 | f.ship(release.OpsAccount, "demo", "v1") |
| 2616 | f.promote(release.OpsAccount, "demo", "canary/x", "v1") |
| 2617 | f.promote(release.OpsAccount, "demo", "stable", "v1") |
| 2618 | f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 2619 | App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"}) |
| 2620 | |
| 2621 | _, otherPub, err := release.GenerateKey() |
| 2622 | if err != nil { |
| 2623 | t.Fatal(err) |
| 2624 | } |
| 2625 | // -allow-version-change included for the same reason as |
| 2626 | // TestRouteRefusesWithoutPubkey (re-review NEW 2): a bad signature is a |
| 2627 | // worse state than an unresolvable target (suspect, not merely unknown), |
| 2628 | // and must refuse even with the override. |
| 2629 | env := carlosTestEnv(f.t, "CARLOS_DEPLOYMENT_DIR="+f.storeDir, "CARLOS_RELEASE_PUBKEY="+otherPub) |
| 2630 | out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com", |
| 2631 | "-channel", "stable", "-allow-version-change") |
| 2632 | if err == nil { |
| 2633 | t.Fatalf("route with a wrong-key pubkey was accepted even with --allow-version-change:\n%s", out) |
| 2634 | } |
| 2635 | if !strings.Contains(out, "signature") { |
| 2636 | t.Errorf("refusal does not mention signature verification:\n%s", out) |
| 2637 | } |
| 2638 | if got := f.channelOf("demo.example.com"); got != "canary/x" { |
| 2639 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 2640 | } |
| 2641 | } |
| 2642 | |
| 2643 | // CRITICAL 2: an unresolvable target channel is refused UNCONDITIONALLY — |
| 2644 | // -allow-version-change only ever means "the target resolves and holds a |
| 2645 | // different version, do it anyway", never "skip finding out". A typo'd |
| 2646 | // channel name must never be write-able by any flag. |
| 2647 | func TestRouteAllowVersionChangeNeverOverridesUnresolvableTarget(t *testing.T) { |
| 2648 | f := newRouteTestFixture(t) |
| 2649 | f.ship(release.OpsAccount, "demo", "v1") |
| 2650 | f.promote(release.OpsAccount, "demo", "canary/x", "v1") |
| 2651 | f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 2652 | App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"}) |
| 2653 | // "stable" is never promoted — the target has no pointer at all. |
| 2654 | |
| 2655 | env := f.fullEnv() |
| 2656 | out, err := f.run(env, "route", "-registry", f.regPath, "-host", "demo.example.com", |
| 2657 | "-channel", "stable", "-allow-version-change") |
| 2658 | if err == nil { |
| 2659 | t.Fatalf("an unresolvable target was accepted even with --allow-version-change:\n%s", out) |
| 2660 | } |
| 2661 | if !strings.Contains(out, "does not resolve") { |
| 2662 | t.Errorf("refusal does not say the target is unresolvable:\n%s", out) |
| 2663 | } |
| 2664 | if got := f.channelOf("demo.example.com"); got != "canary/x" { |
| 2665 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 2666 | } |
| 2667 | } |
| 2668 | |
| 2669 | // CRITICAL 1: the -app bulk form must be all-or-nothing. One route that |
| 2670 | // fails the rail must leave EVERY route in the batch untouched, and the |
| 2671 | // output must name every failure, not just the first. |
| 2672 | func TestRouteAppBulkIsAllOrNothing(t *testing.T) { |
| 2673 | f := newRouteTestFixture(t) |
| 2674 | f.ship(release.OpsAccount, "fleet", "v1") |
| 2675 | f.ship(release.OpsAccount, "fleet", "v2") |
| 2676 | f.promote(release.OpsAccount, "fleet", "canary/platform-adoption", "v1") |
| 2677 | f.promote(release.OpsAccount, "fleet", "stable", "v1") |
| 2678 | // Three routes agree with the target (stable == v1); one (fleet-c) is |
| 2679 | // running v9, which stable does not serve. |
| 2680 | for _, h := range []string{"fleet-a.example.com", "fleet-b.example.com", "fleet-d.example.com"} { |
| 2681 | f.addRoute(registry.Route{Host: h, Kind: "instance", App: "fleet", |
| 2682 | Channel: "canary/platform-adoption", Version: "v1", Status: "ready"}) |
| 2683 | } |
| 2684 | f.addRoute(registry.Route{Host: "fleet-c.example.com", Kind: "instance", App: "fleet", |
| 2685 | Channel: "canary/platform-adoption", Version: "v9", Status: "ready"}) |
| 2686 | |
| 2687 | env := f.fullEnv() |
| 2688 | out, err := f.run(env, "route", "-registry", f.regPath, "-app", "fleet", "-channel", "stable") |
| 2689 | if err == nil { |
| 2690 | t.Fatalf("a batch with one failing route was accepted:\n%s", out) |
| 2691 | } |
| 2692 | if !strings.Contains(out, "fleet-c.example.com") { |
| 2693 | t.Errorf("output does not name the failing route:\n%s", out) |
| 2694 | } |
| 2695 | for _, h := range []string{"fleet-a.example.com", "fleet-b.example.com", "fleet-c.example.com", "fleet-d.example.com"} { |
| 2696 | if got := f.channelOf(h); got != "canary/platform-adoption" { |
| 2697 | t.Errorf("%s channel = %q after a refused batch — the batch was not atomic (want it untouched)", h, got) |
| 2698 | } |
| 2699 | } |
| 2700 | } |
| 2701 | |
| 2702 | // CRITICAL 3: a route the domains reconciler minted (Platform: true) is |
| 2703 | // excluded from the -app bulk form, with a reason printed, but -host may |
| 2704 | // still target it explicitly — reconcile only stamps a Platform route's |
| 2705 | // Channel ONCE at mint time and never re-derives it on a later pass (see |
| 2706 | // domains/reconcile.go's byHost/"already minted" idempotency guards), so |
| 2707 | // this verb changing it is not fighting reconcile for ownership. |
| 2708 | func TestRouteAppBulkExcludesPlatformRoutesButHostAllowsExplicit(t *testing.T) { |
| 2709 | f := newRouteTestFixture(t) |
| 2710 | f.ship(release.OpsAccount, "site", "v1") |
| 2711 | f.promote(release.OpsAccount, "site", "canary/x", "v1") |
| 2712 | f.promote(release.OpsAccount, "site", "stable", "v1") |
| 2713 | f.addRoute(registry.Route{Host: "site.example.com", Kind: "static", App: "site", |
| 2714 | Channel: "canary/x", Status: "ready"}) // custom domain: NOT platform-minted |
| 2715 | f.addRoute(registry.Route{Host: "site.bab.apps.example.com", Kind: "static", App: "site", |
| 2716 | Channel: "canary/x", Status: "ready", Platform: true}) // platform-minted apex |
| 2717 | |
| 2718 | env := f.fullEnv() |
| 2719 | out, err := f.run(env, "route", "-registry", f.regPath, "-app", "site", "-channel", "stable") |
| 2720 | if err != nil { |
| 2721 | t.Fatalf("route --app: %v\n%s", err, out) |
| 2722 | } |
| 2723 | if !strings.Contains(out, "site.bab.apps.example.com") || !strings.Contains(out, "skip") { |
| 2724 | t.Errorf("output does not say the platform route was skipped:\n%s", out) |
| 2725 | } |
| 2726 | if got := f.channelOf("site.example.com"); got != "stable" { |
| 2727 | t.Fatalf("the non-platform route did not move: %q", got) |
| 2728 | } |
| 2729 | if got := f.channelOf("site.bab.apps.example.com"); got != "canary/x" { |
| 2730 | t.Fatalf("the --app bulk form touched a platform route: %q", got) |
| 2731 | } |
| 2732 | |
| 2733 | // -host, naming it explicitly, is allowed. |
| 2734 | out, err = f.run(env, "route", "-registry", f.regPath, "-host", "site.bab.apps.example.com", "-channel", "stable") |
| 2735 | if err != nil { |
| 2736 | t.Fatalf("route --host on a platform route: %v\n%s", err, out) |
| 2737 | } |
| 2738 | if got := f.channelOf("site.bab.apps.example.com"); got != "stable" { |
| 2739 | t.Fatalf("explicit --host did not move the platform route: %q", got) |
| 2740 | } |
| 2741 | } |
| 2742 | |
| 2743 | // CRITICAL 3 / re-review NEW 1: a Platform+Canary route must never have its |
| 2744 | // channel changed AT ALL by this verb — not just "moved off canary/*". The |
| 2745 | // domains reconciler's canary-minting loop (reconcile.go) is idempotent via |
| 2746 | // `followed`, a check derived from the route's OWN Channel column, not from |
| 2747 | // the `byHost` map it also sets but never reads for that purpose. So ANY |
| 2748 | // channel change on a Platform+Canary route (including canary/a -> |
| 2749 | // canary/b) makes `followed` false for the channel it just left; the next |
| 2750 | // reconcile pass re-Upserts the SAME host from `firstReadyUpstream`, which |
| 2751 | // silently reverts this verb's write and blanks version/status/dns/cert |
| 2752 | // columns to mintedRoute's zero values. Refuses even via explicit -host and |
| 2753 | // even with -allow-version-change — there is no override for a column this |
| 2754 | // verb does not own. |
| 2755 | func TestRoutePlatformCanaryNeverLeavesCanary(t *testing.T) { |
| 2756 | f := newRouteTestFixture(t) |
| 2757 | f.ship(release.OpsAccount, "site", "v1") |
| 2758 | f.promote(release.OpsAccount, "site", "canary/preview", "v1") |
| 2759 | f.promote(release.OpsAccount, "site", "stable", "v1") |
| 2760 | f.addRoute(registry.Route{Host: "preview.site.bab.apps.example.com", Kind: "static", App: "site", |
| 2761 | Channel: "canary/preview", Status: "ready", Platform: true, Canary: true}) |
| 2762 | |
| 2763 | env := f.fullEnv() |
| 2764 | out, err := f.run(env, "route", "-registry", f.regPath, |
| 2765 | "-host", "preview.site.bab.apps.example.com", "-channel", "stable", "-allow-version-change") |
| 2766 | if err == nil { |
| 2767 | t.Fatalf("moving a Platform+Canary route off canary/* was accepted:\n%s", out) |
| 2768 | } |
| 2769 | if !strings.Contains(out, "canary") { |
| 2770 | t.Errorf("refusal does not explain the canary retirement hazard:\n%s", out) |
| 2771 | } |
| 2772 | if got := f.channelOf("preview.site.bab.apps.example.com"); got != "canary/preview" { |
| 2773 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 2774 | } |
| 2775 | } |
| 2776 | |
| 2777 | // Design §5: once a route is Provisioned, its Channel and Environment are |
| 2778 | // the instance record's. A write here lands and is reverted within seconds |
| 2779 | // of the next serial bump, silently — the same class of bug as #130, |
| 2780 | // arriving from the other side. The refusal names the record as the door, |
| 2781 | // and no flag overrides it: -allow-version-change answers "what would this |
| 2782 | // change", not "who owns this column". |
| 2783 | func TestRouteChannelRefusedOnProvisionedRoute(t *testing.T) { |
| 2784 | f := newRouteTestFixture(t) |
| 2785 | f.ship(release.OpsAccount, "site", "v1") |
| 2786 | f.promote(release.OpsAccount, "site", "stable", "v1") |
| 2787 | f.promote(release.OpsAccount, "site", "canary/preview", "v1") |
| 2788 | f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| 2789 | Channel: "canary/preview", Status: "ready", Provisioned: true}) |
| 2790 | |
| 2791 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 2792 | "-host", "acme.go.tito.io", "-channel", "stable", "-allow-version-change") |
| 2793 | if err == nil { |
| 2794 | t.Fatalf("route --channel on a Provisioned route was accepted:\n%s", out) |
| 2795 | } |
| 2796 | if !strings.Contains(out, "record") { |
| 2797 | t.Errorf("refusal does not name the instance record as the door:\n%s", out) |
| 2798 | } |
| 2799 | if got := f.channelOf("acme.go.tito.io"); got != "canary/preview" { |
| 2800 | t.Fatalf("channel = %q, want it unwritten after the refusal", got) |
| 2801 | } |
| 2802 | } |
| 2803 | |
| 2804 | func TestRouteEnvironmentRefusedOnProvisionedRoute(t *testing.T) { |
| 2805 | f := newRouteTestFixture(t) |
| 2806 | f.ship(release.OpsAccount, "site", "v1") |
| 2807 | f.promote(release.OpsAccount, "site", "stable", "v1") |
| 2808 | f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| 2809 | Channel: "stable", Status: "ready", Provisioned: true}) |
| 2810 | f.saveEnvironment(release.OpsAccount, "site", "demo") |
| 2811 | |
| 2812 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 2813 | "-host", "acme.go.tito.io", "-environment", "demo") |
| 2814 | if err == nil { |
| 2815 | t.Fatalf("route --environment on a Provisioned route was accepted:\n%s", out) |
| 2816 | } |
| 2817 | if got := f.environmentOf("acme.go.tito.io"); got != "" { |
| 2818 | t.Fatalf("environment = %q, want it unwritten after the refusal", got) |
| 2819 | } |
| 2820 | // Clearing is equally not this verb's to do — the refusal is on the ROW, |
| 2821 | // not on one direction of travel, exactly as the Platform+Canary one is. |
| 2822 | f.addRoute(registry.Route{Host: "bound.go.tito.io", Kind: "instance", App: "site", |
| 2823 | Channel: "stable", Status: "ready", Provisioned: true, Environment: "demo"}) |
| 2824 | out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 2825 | "-host", "bound.go.tito.io", "-environment", "-") |
| 2826 | if err == nil { |
| 2827 | t.Fatalf("clearing an environment on a Provisioned route was accepted:\n%s", out) |
| 2828 | } |
| 2829 | if got := f.environmentOf("bound.go.tito.io"); got != "demo" { |
| 2830 | t.Fatalf("environment = %q, want it still bound after the refusal", got) |
| 2831 | } |
| 2832 | } |
| 2833 | |
| 2834 | // Task 11 item 5 (carried from Task 9's review): the sidecar reconcile pass |
| 2835 | // (internal/provision/sidecar_reconcile.go, convergeSidecar) converges |
| 2836 | // Hibernate from the app's sidecars manifest on EVERY pass — unconditionally, |
| 2837 | // not the ordinary instance record's tri-state (absent-means-free) column a |
| 2838 | // Provisioned instance route carries. A `-hibernate` write on such a row |
| 2839 | // lands and is reverted within seconds of the next serial bump, with nothing |
| 2840 | // said anywhere — the same class of bug #130 was, on the axis this repo has |
| 2841 | // not yet paid the lesson for. |
| 2842 | // |
| 2843 | // The reserved sidecar suffix (suffixRail) already refuses every genuine |
| 2844 | // sidecar row through a different door — every one is minted under |
| 2845 | // provision.SidecarSuffix by construction — so this test uses a host OUTSIDE |
| 2846 | // that suffix to isolate the NEW check from that existing one: this is |
| 2847 | // deliberately the shape suffixRail's own doc comment calls out as not |
| 2848 | // hypothetical (an ordinary-kind row's Provisioned+Kind combination is what |
| 2849 | // this rail reads, not the hostname). |
| 2850 | func TestRouteHibernateRefusedOnProvisionedSidecarRow(t *testing.T) { |
| 2851 | f := newRouteTestFixture(t) |
| 2852 | f.addRoute(registry.Route{Host: "bot.eleven.bdm.example.com", Kind: "sidecar", App: "eleven", Account: "bdm", |
| 2853 | Status: "ready", Provisioned: true}) |
| 2854 | |
| 2855 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "bot.eleven.bdm.example.com", "-hibernate=false") |
| 2856 | if err == nil { |
| 2857 | t.Fatalf("route -hibernate on a provisioned sidecar row was accepted:\n%s", out) |
| 2858 | } |
| 2859 | if !strings.Contains(out, "sidecar") || !strings.Contains(out, provision.SidecarManifestKey("bdm", "eleven")) { |
| 2860 | t.Errorf("refusal does not name the sidecar manifest as the door:\n%s", out) |
| 2861 | } |
| 2862 | if got := f.routeOf("bot.eleven.bdm.example.com").Hibernate; got != false { |
| 2863 | t.Fatalf("hibernate = %v, want it unwritten after the refusal", got) |
| 2864 | } |
| 2865 | } |
| 2866 | |
| 2867 | // TestRouteHibernateRefusedOnGenuineSidecarSuffixRow is the end-to-end proof |
| 2868 | // against a REAL sidecar host shape (under provision.SidecarSuffix): both |
| 2869 | // rails agree, and the operator-facing message names the reconcile pass, not |
| 2870 | // just "reserved suffix". |
| 2871 | func TestRouteHibernateRefusedOnGenuineSidecarSuffixRow(t *testing.T) { |
| 2872 | f := newRouteTestFixture(t) |
| 2873 | host := provision.SidecarHost("bot", "eleven", "bdm") |
| 2874 | f.addRoute(registry.Route{Host: host, Kind: "sidecar", App: "eleven", Account: "bdm", |
| 2875 | Status: "ready", Provisioned: true}) |
| 2876 | |
| 2877 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", host, "-hibernate") |
| 2878 | if err == nil { |
| 2879 | t.Fatalf("route -hibernate on a genuine sidecar row was accepted:\n%s", out) |
| 2880 | } |
| 2881 | if got := f.routeOf(host).Hibernate; got != false { |
| 2882 | t.Fatalf("hibernate = %v, want it unwritten after the refusal", got) |
| 2883 | } |
| 2884 | } |
| 2885 | |
| 2886 | // The box-scoped axes are untouched: they name resources that exist on one |
| 2887 | // machine, the record does not own them, and an operator must still be able |
| 2888 | // to grant a port or stamp a unit on a provisioned route. |
| 2889 | func TestRouteBoxScopedAxesStillWorkOnProvisionedRoute(t *testing.T) { |
| 2890 | f := newRouteTestFixture(t) |
| 2891 | f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| 2892 | Channel: "stable", Status: "ready", Provisioned: true, Addr: "127.0.0.1:8090"}) |
| 2893 | |
| 2894 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 2895 | "-host", "acme.go.tito.io", "-unit", "console.service") |
| 2896 | if err != nil { |
| 2897 | t.Fatalf("route --unit on a Provisioned route was refused:\n%s", out) |
| 2898 | } |
| 2899 | if got := f.routeOf("acme.go.tito.io").Unit; got != "console.service" { |
| 2900 | t.Errorf("unit = %q, want console.service", got) |
| 2901 | } |
| 2902 | |
| 2903 | // -unit short-circuits before the verdict machinery this change touched |
| 2904 | // (the grantAxis/hibernateSet/backingSet/unitSet block returns early, well |
| 2905 | // above chanVerdicts/envVerdicts). -tunnel does not: it runs through the |
| 2906 | // same candidates slice and sits downstream of the refusal-collection |
| 2907 | // loops recordOwnedRow feeds, so it is the axis a future recordOwnedRow |
| 2908 | // case could plausibly catch by mistake. Exercise it directly. |
| 2909 | out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 2910 | "-host", "acme.go.tito.io", "-tunnel", "relay1") |
| 2911 | if err != nil { |
| 2912 | t.Fatalf("route --tunnel on a Provisioned route was refused:\n%s", out) |
| 2913 | } |
| 2914 | if got := f.routeOf("acme.go.tito.io").Tunnel; got != "relay1" { |
| 2915 | t.Errorf("tunnel = %q, want relay1", got) |
| 2916 | } |
| 2917 | } |
| 2918 | |
| 2919 | // re-review NEW 1, the reachable gap: moving a Platform+Canary route BETWEEN |
| 2920 | // two canary channels (not off canary/* at all) was previously accepted, |
| 2921 | // because the round-1 guard only fired when the target left the canary/* |
| 2922 | // prefix. Confirmed live against the pre-fix binary: the write succeeded, |
| 2923 | // and — per the doc comment above — the next reconcile pass would silently |
| 2924 | // revert it. Must refuse this too, unconditionally. |
| 2925 | func TestRoutePlatformCanaryRefusesMoveBetweenCanaryChannels(t *testing.T) { |
| 2926 | f := newRouteTestFixture(t) |
| 2927 | f.ship(release.OpsAccount, "site", "v1") |
| 2928 | f.promote(release.OpsAccount, "site", "canary/a", "v1") |
| 2929 | f.promote(release.OpsAccount, "site", "canary/b", "v1") |
| 2930 | f.addRoute(registry.Route{Host: "preview.site.bab.apps.example.com", Kind: "static", App: "site", |
| 2931 | Channel: "canary/a", Status: "ready", Platform: true, Canary: true}) |
| 2932 | |
| 2933 | env := f.fullEnv() |
| 2934 | out, err := f.run(env, "route", "-registry", f.regPath, |
| 2935 | "-host", "preview.site.bab.apps.example.com", "-channel", "canary/b", "-allow-version-change") |
| 2936 | if err == nil { |
| 2937 | t.Fatalf("moving a Platform+Canary route between canary channels was accepted:\n%s", out) |
| 2938 | } |
| 2939 | if got := f.channelOf("preview.site.bab.apps.example.com"); got != "canary/a" { |
| 2940 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 2941 | } |
| 2942 | } |
| 2943 | |
| 2944 | // IMPORTANT 4: -account defaults to "ops" like every other account-taking |
| 2945 | // verb, so a bare -app does not silently cross tenant boundaries; -account |
| 2946 | // all opts into the cross-account sweep Tito's eighteen routes need. |
| 2947 | func TestRouteAccountDefaultsToOpsAndAllOptsIn(t *testing.T) { |
| 2948 | f := newRouteTestFixture(t) |
| 2949 | accOut, err := f.run(f.fullEnv(), "accounts", "create", "-registry", f.regPath, "-name", "second", "-owner", "second@keymail.dev") |
| 2950 | if err != nil { |
| 2951 | t.Fatalf("accounts create: %v\n%s", err, accOut) |
| 2952 | } |
| 2953 | m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(accOut) |
| 2954 | if m == nil { |
| 2955 | t.Fatalf("accounts create printed no sqid:\n%s", accOut) |
| 2956 | } |
| 2957 | sqid := m[1] |
| 2958 | |
| 2959 | f.ship(release.OpsAccount, "acctapp", "a1") |
| 2960 | f.promote(release.OpsAccount, "acctapp", "canary/x", "a1") |
| 2961 | f.promote(release.OpsAccount, "acctapp", "stable", "a1") |
| 2962 | f.ship(sqid, "acctapp", "a1") |
| 2963 | f.promote(sqid, "acctapp", "canary/x", "a1") |
| 2964 | f.promote(sqid, "acctapp", "stable", "a1") |
| 2965 | f.addRoute(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance", |
| 2966 | App: "acctapp", Account: release.OpsAccount, Channel: "canary/x", Version: "a1", Status: "ready"}) |
| 2967 | f.addRoute(registry.Route{Host: "second-acctapp.example.com", Kind: "instance", |
| 2968 | App: "acctapp", Account: sqid, Channel: "canary/x", Version: "a1", Status: "ready"}) |
| 2969 | |
| 2970 | env := f.fullEnv() |
| 2971 | // Bare -app: only the ops-account route moves. |
| 2972 | out, err := f.run(env, "route", "-registry", f.regPath, "-app", "acctapp", "-channel", "stable") |
| 2973 | if err != nil { |
| 2974 | t.Fatalf("route --app (default account): %v\n%s", err, out) |
| 2975 | } |
| 2976 | if got := f.channelOf("ops-acctapp.example.com"); got != "stable" { |
| 2977 | t.Fatalf("the ops-account route did not move under the default: %q", got) |
| 2978 | } |
| 2979 | if got := f.channelOf("second-acctapp.example.com"); got != "canary/x" { |
| 2980 | t.Fatalf("the default --app crossed into another account's route: %q", got) |
| 2981 | } |
| 2982 | |
| 2983 | // -account all: the other account's route moves too. |
| 2984 | out, err = f.run(env, "route", "-registry", f.regPath, "-app", "acctapp", "-channel", "stable", "-account", "all") |
| 2985 | if err != nil { |
| 2986 | t.Fatalf("route --app --account all: %v\n%s", err, out) |
| 2987 | } |
| 2988 | if got := f.channelOf("second-acctapp.example.com"); got != "stable" { |
| 2989 | t.Fatalf("--account all did not move the other account's route: %q, out:\n%s", got, out) |
| 2990 | } |
| 2991 | } |
| 2992 | |
| 2993 | // IMPORTANT 5: a route with no adopted Version (a static route — SetVersions |
| 2994 | // is only ever called for Kind == instance — or an instance never adopted) |
| 2995 | // must not garble the rail message or force -allow-version-change |
| 2996 | // unconditionally. When the route's CURRENT channel and the TARGET channel |
| 2997 | // point at the same version, it is exactly as safe as the normal case. |
| 2998 | func TestRouteStaticRouteWithEmptyVersionComparesChannelPointers(t *testing.T) { |
| 2999 | f := newRouteTestFixture(t) |
| 3000 | f.ship(release.OpsAccount, "site", "v1") |
| 3001 | f.promote(release.OpsAccount, "site", "canary/x", "v1") |
| 3002 | f.promote(release.OpsAccount, "site", "stable", "v1") |
| 3003 | f.addRoute(registry.Route{Host: "site.example.com", Kind: "static", App: "site", |
| 3004 | Channel: "canary/x", Status: "ready"}) // Version left "" — never set for static |
| 3005 | |
| 3006 | env := f.fullEnv() |
| 3007 | out, err := f.run(env, "route", "-registry", f.regPath, "-host", "site.example.com", "-channel", "stable") |
| 3008 | if err != nil { |
| 3009 | t.Fatalf("a static route whose current and target channels agree was refused: %v\n%s", err, out) |
| 3010 | } |
| 3011 | if strings.Contains(out, "route is running ") || strings.Contains(out, "running , ") { |
| 3012 | t.Errorf("output still shows the garbled empty-version message:\n%s", out) |
| 3013 | } |
| 3014 | if got := f.channelOf("site.example.com"); got != "stable" { |
| 3015 | t.Fatalf("channel did not move: %q", got) |
| 3016 | } |
| 3017 | |
| 3018 | // Now make the current and target channels genuinely disagree, and |
| 3019 | // confirm it refuses (rather than either garbling or silently passing). |
| 3020 | f.ship(release.OpsAccount, "site", "v2") |
| 3021 | f.promote(release.OpsAccount, "site", "canary/y", "v2") |
| 3022 | f.addRoute(registry.Route{Host: "site2.example.com", Kind: "static", App: "site", |
| 3023 | Channel: "canary/y", Status: "ready"}) |
| 3024 | out, err = f.run(env, "route", "-registry", f.regPath, "-host", "site2.example.com", "-channel", "stable") |
| 3025 | if err == nil { |
| 3026 | t.Fatalf("a static route whose current/target channels disagree was accepted without --allow-version-change:\n%s", out) |
| 3027 | } |
| 3028 | if got := f.channelOf("site2.example.com"); got != "canary/y" { |
| 3029 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 3030 | } |
| 3031 | } |
| 3032 | |
| 3033 | // MINOR 9 (review): -dry-run runs the full check pass and prints what would |
| 3034 | // happen, but writes nothing — the two-pass CRITICAL 1 fix produces this |
| 3035 | // almost for free, and an operator moving Tito's eighteen routes should be |
| 3036 | // able to preview before committing. |
| 3037 | func TestRouteDryRunWritesNothing(t *testing.T) { |
| 3038 | f := newRouteTestFixture(t) |
| 3039 | f.ship(release.OpsAccount, "demo", "v1") |
| 3040 | f.promote(release.OpsAccount, "demo", "canary/x", "v1") |
| 3041 | f.promote(release.OpsAccount, "demo", "stable", "v1") |
| 3042 | f.addRoute(registry.Route{Host: "demo.example.com", Kind: "instance", |
| 3043 | App: "demo", Channel: "canary/x", Version: "v1", Status: "ready"}) |
| 3044 | |
| 3045 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3046 | "-host", "demo.example.com", "-channel", "stable", "-dry-run") |
| 3047 | if err != nil { |
| 3048 | t.Fatalf("route --dry-run: %v\n%s", err, out) |
| 3049 | } |
| 3050 | if !strings.Contains(out, "dry run") { |
| 3051 | t.Errorf("output does not say this was a dry run:\n%s", out) |
| 3052 | } |
| 3053 | if !strings.Contains(out, "canary/x -> stable") { |
| 3054 | t.Errorf("dry run does not show the plan:\n%s", out) |
| 3055 | } |
| 3056 | if got := f.channelOf("demo.example.com"); got != "canary/x" { |
| 3057 | t.Fatalf("dry run wrote a channel change: %q", got) |
| 3058 | } |
| 3059 | } |
| 3060 | |
| 3061 | // --- Task 2: Route.Environment + `carlos route -environment` -------------- |
| 3062 | |
| 3063 | // Bind then clear: -environment <name> requires the bundle to exist in the |
| 3064 | // bucket (the existence rail), and -environment - clears back to default |
| 3065 | // with no bucket check at all. |
| 3066 | func TestRouteEnvironmentBindClearRoundTrip(t *testing.T) { |
| 3067 | f := newRouteTestFixture(t) |
| 3068 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3069 | f.saveEnvironment(release.OpsAccount, "tito", "demo") |
| 3070 | |
| 3071 | env := f.fullEnv() |
| 3072 | out, err := f.run(env, "route", "-registry", f.regPath, |
| 3073 | "-host", "demo.tito.example.com", "-environment", "demo") |
| 3074 | if err != nil { |
| 3075 | t.Fatalf("route --environment demo: %v\n%s", err, out) |
| 3076 | } |
| 3077 | if !strings.Contains(out, "default -> demo") { |
| 3078 | t.Errorf("output does not show the bind:\n%s", out) |
| 3079 | } |
| 3080 | if got := f.environmentOf("demo.tito.example.com"); got != "demo" { |
| 3081 | t.Fatalf("environment = %q, want demo", got) |
| 3082 | } |
| 3083 | |
| 3084 | out, err = f.run(env, "route", "-registry", f.regPath, |
| 3085 | "-host", "demo.tito.example.com", "-environment", "-") |
| 3086 | if err != nil { |
| 3087 | t.Fatalf("route --environment -: %v\n%s", err, out) |
| 3088 | } |
| 3089 | if !strings.Contains(out, "demo -> default") { |
| 3090 | t.Errorf("output does not show the clear:\n%s", out) |
| 3091 | } |
| 3092 | if got := f.environmentOf("demo.tito.example.com"); got != "" { |
| 3093 | t.Fatalf("environment = %q, want empty (default) after clearing", got) |
| 3094 | } |
| 3095 | } |
| 3096 | |
| 3097 | // A bundle that was never created (or was created for a different app) is |
| 3098 | // refused, not silently bound — the whole point of the existence rail (spec |
| 3099 | // §4a: "binding a bundle that does not exist in the bucket is refused, not |
| 3100 | // recorded"). The refusal names the fix. |
| 3101 | func TestRouteEnvironmentNonexistentBundleRefused(t *testing.T) { |
| 3102 | f := newRouteTestFixture(t) |
| 3103 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3104 | |
| 3105 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3106 | "-host", "demo.tito.example.com", "-environment", "nosuchenv") |
| 3107 | if err == nil { |
| 3108 | t.Fatalf("binding a nonexistent environment was accepted:\n%s", out) |
| 3109 | } |
| 3110 | if !strings.Contains(out, "nosuchenv") || !strings.Contains(out, "carlos env set") { |
| 3111 | t.Errorf("refusal does not name the bundle or the fix:\n%s", out) |
| 3112 | } |
| 3113 | if got := f.environmentOf("demo.tito.example.com"); got != "" { |
| 3114 | t.Fatalf("environment changed despite the refusal: %q", got) |
| 3115 | } |
| 3116 | } |
| 3117 | |
| 3118 | // An invalid environment NAME (bad shape, or the reserved "default") is |
| 3119 | // refused before any bucket check — no CARLOS_DEPLOYMENT_DIR/BUCKET is even |
| 3120 | // required for this refusal to fire. |
| 3121 | func TestRouteEnvironmentInvalidNameRefused(t *testing.T) { |
| 3122 | f := newRouteTestFixture(t) |
| 3123 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3124 | |
| 3125 | for _, bad := range []string{"Has-Upper", "has_underscore", strings.Repeat("a", 33)} { |
| 3126 | out, err := f.run(carlosTestEnv(f.t), "route", "-registry", f.regPath, |
| 3127 | "-host", "demo.tito.example.com", "-environment", bad) |
| 3128 | if err == nil { |
| 3129 | t.Fatalf("invalid environment name %q was accepted:\n%s", bad, out) |
| 3130 | } |
| 3131 | if !strings.Contains(out, "not a valid environment name") { |
| 3132 | t.Errorf("refusal of %q does not name the reason:\n%s", bad, out) |
| 3133 | } |
| 3134 | } |
| 3135 | // "default" is refused for its OWN reason — it names the base bundle, it |
| 3136 | // is not merely malformed (review MINOR 7). Asserting the reason is what |
| 3137 | // keeps this pinned to the reservation: with the reservation removed, the |
| 3138 | // existence rail would still refuse "default", for an unrelated reason, |
| 3139 | // and an assertion on err alone would stay green. |
| 3140 | out, err := f.run(carlosTestEnv(f.t), "route", "-registry", f.regPath, |
| 3141 | "-host", "demo.tito.example.com", "-environment", "default") |
| 3142 | if err == nil { |
| 3143 | t.Fatalf("--environment default was accepted:\n%s", out) |
| 3144 | } |
| 3145 | if !strings.Contains(out, "base bundle") { |
| 3146 | t.Errorf("refusal of \"default\" does not explain that it already names the base bundle:\n%s", out) |
| 3147 | } |
| 3148 | } |
| 3149 | |
| 3150 | // -environment alone (no -channel) requires neither -channel's pubkey nor |
| 3151 | // its version-comparison rail — the two axes are independent all the way |
| 3152 | // down to which env vars a run needs. |
| 3153 | func TestRouteEnvironmentAloneNeedsNoPubkey(t *testing.T) { |
| 3154 | f := newRouteTestFixture(t) |
| 3155 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3156 | f.saveEnvironment(release.OpsAccount, "tito", "demo") |
| 3157 | |
| 3158 | env := carlosTestEnv(f.t, "CARLOS_DEPLOYMENT_DIR="+f.storeDir) // no CARLOS_RELEASE_PUBKEY |
| 3159 | out, err := f.run(env, "route", "-registry", f.regPath, |
| 3160 | "-host", "demo.tito.example.com", "-environment", "demo") |
| 3161 | if err != nil { |
| 3162 | t.Fatalf("route --environment with no CARLOS_RELEASE_PUBKEY: %v\n%s", err, out) |
| 3163 | } |
| 3164 | if got := f.environmentOf("demo.tito.example.com"); got != "demo" { |
| 3165 | t.Fatalf("environment = %q, want demo", got) |
| 3166 | } |
| 3167 | } |
| 3168 | |
| 3169 | // REGRESSION PIN: -channel alone must never touch Environment — the two |
| 3170 | // columns are independent, and a channel-only invocation is the common |
| 3171 | // case (every existing `carlos route -channel` caller). If SetChannels ever |
| 3172 | // grew a write to the environment column, this would catch it. |
| 3173 | func TestRouteChannelAloneNeverTouchesEnvironment(t *testing.T) { |
| 3174 | f := newRouteTestFixture(t) |
| 3175 | f.ship(release.OpsAccount, "tito", "v1") |
| 3176 | f.promote(release.OpsAccount, "tito", "canary/x", "v1") |
| 3177 | f.promote(release.OpsAccount, "tito", "stable", "v1") |
| 3178 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", |
| 3179 | Channel: "canary/x", Version: "v1", Status: "ready", Environment: "demo"}) |
| 3180 | |
| 3181 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3182 | "-host", "demo.tito.example.com", "-channel", "stable") |
| 3183 | if err != nil { |
| 3184 | t.Fatalf("route --channel: %v\n%s", err, out) |
| 3185 | } |
| 3186 | if got := f.channelOf("demo.tito.example.com"); got != "stable" { |
| 3187 | t.Fatalf("channel = %q, want stable", got) |
| 3188 | } |
| 3189 | if got := f.environmentOf("demo.tito.example.com"); got != "demo" { |
| 3190 | t.Fatalf("environment = %q after a --channel-only run, want untouched demo", got) |
| 3191 | } |
| 3192 | } |
| 3193 | |
| 3194 | // -app bulk atomicity for -environment: within one -account all sweep, one |
| 3195 | // account's app has the named bundle and the other does not — the whole |
| 3196 | // batch must refuse, and NEITHER account's route may move, even the one |
| 3197 | // whose bundle genuinely exists. |
| 3198 | func TestRouteEnvironmentAppBulkAllOrNothingAcrossAccounts(t *testing.T) { |
| 3199 | f := newRouteTestFixture(t) |
| 3200 | accOut, err := f.run(f.fullEnv(), "accounts", "create", "-registry", f.regPath, "-name", "second", "-owner", "second@keymail.dev") |
| 3201 | if err != nil { |
| 3202 | t.Fatalf("accounts create: %v\n%s", err, accOut) |
| 3203 | } |
| 3204 | m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(accOut) |
| 3205 | if m == nil { |
| 3206 | t.Fatalf("accounts create printed no sqid:\n%s", accOut) |
| 3207 | } |
| 3208 | sqid := m[1] |
| 3209 | |
| 3210 | f.saveEnvironment(release.OpsAccount, "acctapp", "demo") // only the ops account has it |
| 3211 | f.addRoute(registry.Route{Host: "ops-acctapp.example.com", Kind: "instance", |
| 3212 | App: "acctapp", Account: release.OpsAccount, Status: "ready"}) |
| 3213 | f.addRoute(registry.Route{Host: "second-acctapp.example.com", Kind: "instance", |
| 3214 | App: "acctapp", Account: sqid, Status: "ready"}) |
| 3215 | |
| 3216 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3217 | "-app", "acctapp", "-environment", "demo", "-account", "all") |
| 3218 | if err == nil { |
| 3219 | t.Fatalf("a batch spanning an account without the bundle was accepted:\n%s", out) |
| 3220 | } |
| 3221 | if !strings.Contains(out, "second-acctapp.example.com") { |
| 3222 | t.Errorf("output does not name the failing route:\n%s", out) |
| 3223 | } |
| 3224 | if got := f.environmentOf("ops-acctapp.example.com"); got != "" { |
| 3225 | t.Fatalf("ops-account route moved despite the refused batch (not atomic): %q", got) |
| 3226 | } |
| 3227 | if got := f.environmentOf("second-acctapp.example.com"); got != "" { |
| 3228 | t.Fatalf("second-account route environment = %q, want untouched empty", got) |
| 3229 | } |
| 3230 | } |
| 3231 | |
| 3232 | // Combined -channel/-environment: both rails clear, both columns move in |
| 3233 | // one write. |
| 3234 | func TestRouteCombinedChannelAndEnvironmentBothWrite(t *testing.T) { |
| 3235 | f := newRouteTestFixture(t) |
| 3236 | f.ship(release.OpsAccount, "tito", "v1") |
| 3237 | f.promote(release.OpsAccount, "tito", "canary/x", "v1") |
| 3238 | f.promote(release.OpsAccount, "tito", "stable", "v1") |
| 3239 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", |
| 3240 | Channel: "canary/x", Version: "v1", Status: "ready"}) |
| 3241 | f.saveEnvironment(release.OpsAccount, "tito", "demo") |
| 3242 | |
| 3243 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3244 | "-host", "demo.tito.example.com", "-channel", "stable", "-environment", "demo") |
| 3245 | if err != nil { |
| 3246 | t.Fatalf("route --channel --environment: %v\n%s", err, out) |
| 3247 | } |
| 3248 | if got := f.channelOf("demo.tito.example.com"); got != "stable" { |
| 3249 | t.Fatalf("channel = %q, want stable", got) |
| 3250 | } |
| 3251 | if got := f.environmentOf("demo.tito.example.com"); got != "demo" { |
| 3252 | t.Fatalf("environment = %q, want demo", got) |
| 3253 | } |
| 3254 | } |
| 3255 | |
| 3256 | // Combined atomicity: the environment bundle exists (that rail would pass |
| 3257 | // cleanly) but the channel rail refuses (a genuine version change without |
| 3258 | // -allow-version-change) — NEITHER column may move. Proves the two rails |
| 3259 | // share one gate before either axis writes, not two independent writes that |
| 3260 | // happen to usually succeed together. |
| 3261 | func TestRouteCombinedAtomicityChannelRefusalBlocksEnvironmentToo(t *testing.T) { |
| 3262 | f := newRouteTestFixture(t) |
| 3263 | f.ship(release.OpsAccount, "widget", "v1") |
| 3264 | f.ship(release.OpsAccount, "widget", "v2") |
| 3265 | f.promote(release.OpsAccount, "widget", "canary/widget", "v1") |
| 3266 | f.promote(release.OpsAccount, "widget", "stable", "v2") |
| 3267 | f.addRoute(registry.Route{Host: "widget.example.com", Kind: "instance", App: "widget", |
| 3268 | Channel: "canary/widget", Version: "v1", Status: "ready"}) |
| 3269 | f.saveEnvironment(release.OpsAccount, "widget", "demo") |
| 3270 | |
| 3271 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3272 | "-host", "widget.example.com", "-channel", "stable", "-environment", "demo") |
| 3273 | if err == nil { |
| 3274 | t.Fatalf("a combined write with a genuine version change was accepted without --allow-version-change:\n%s", out) |
| 3275 | } |
| 3276 | if got := f.channelOf("widget.example.com"); got != "canary/widget" { |
| 3277 | t.Fatalf("channel changed despite the refusal: %q", got) |
| 3278 | } |
| 3279 | if got := f.environmentOf("widget.example.com"); got != "" { |
| 3280 | t.Fatalf("environment moved despite the channel rail's refusal (not atomic): %q", got) |
| 3281 | } |
| 3282 | } |
| 3283 | |
| 3284 | // Unlike Channel, -environment is allowed on a Platform+Canary route — the |
| 3285 | // domains reconciler never derives Environment for a minted route (only |
| 3286 | // Channel, for static mints), so this verb owns the column unconditionally |
| 3287 | // on every route kind. |
| 3288 | // Review IMPORTANT 4: a Platform+Canary route's whole ROW is the domains |
| 3289 | // reconciler's, not this verb's — its canary-minting pass re-Upserts the row |
| 3290 | // from mintedRoute's zero values whenever its `followed` check goes false. |
| 3291 | // Upsert now preserves a non-empty Environment (IMPORTANT 3), which makes the |
| 3292 | // binding survivable rather than silently reverted, but a column the |
| 3293 | // reconciler rebuilds is still not one this verb hands an operator. The |
| 3294 | // refusal is structural, exactly like the Channel one, and shares its |
| 3295 | // function (platformCanaryOwnedRow). |
| 3296 | func TestRouteEnvironmentRefusedOnPlatformCanaryRoute(t *testing.T) { |
| 3297 | f := newRouteTestFixture(t) |
| 3298 | f.ship(release.OpsAccount, "site", "v1") |
| 3299 | f.promote(release.OpsAccount, "site", "canary/preview", "v1") |
| 3300 | f.addRoute(registry.Route{Host: "preview.site.bab.apps.example.com", Kind: "static", App: "site", |
| 3301 | Channel: "canary/preview", Status: "ready", Platform: true, Canary: true}) |
| 3302 | f.saveEnvironment(release.OpsAccount, "site", "demo") |
| 3303 | |
| 3304 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3305 | "-host", "preview.site.bab.apps.example.com", "-environment", "demo") |
| 3306 | if err == nil { |
| 3307 | t.Fatalf("route --environment on a Platform+Canary route was accepted:\n%s", out) |
| 3308 | } |
| 3309 | if !strings.Contains(out, "reconciler") { |
| 3310 | t.Errorf("refusal does not say who owns the row:\n%s", out) |
| 3311 | } |
| 3312 | if got := f.environmentOf("preview.site.bab.apps.example.com"); got != "" { |
| 3313 | t.Fatalf("environment = %q, want it unwritten after the refusal", got) |
| 3314 | } |
| 3315 | // The refusal is on the ROW, not on one direction of travel: a binding |
| 3316 | // made before this rail existed (or by an older binary) is equally not |
| 3317 | // this verb's to CLEAR. Only a genuine no-op stays quiet, matching the |
| 3318 | // channel axis, which also refuses moving back to stable. |
| 3319 | f.addRoute(registry.Route{Host: "legacy.site.bab.apps.example.com", Kind: "static", App: "site", |
| 3320 | Channel: "canary/preview", Status: "ready", Platform: true, Canary: true, Environment: "demo"}) |
| 3321 | out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3322 | "-host", "legacy.site.bab.apps.example.com", "-environment", "-") |
| 3323 | if err == nil { |
| 3324 | t.Fatalf("clearing an environment on a Platform+Canary route was accepted:\n%s", out) |
| 3325 | } |
| 3326 | if got := f.environmentOf("legacy.site.bab.apps.example.com"); got != "demo" { |
| 3327 | t.Fatalf("environment = %q, want the pre-existing binding left alone by the refusal", got) |
| 3328 | } |
| 3329 | // A no-op is not a refusal — nothing is being written. |
| 3330 | if out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3331 | "-host", "preview.site.bab.apps.example.com", "-environment", "-"); err != nil { |
| 3332 | t.Fatalf("a no-op clear on an unbound Platform+Canary route was refused: %v\n%s", err, out) |
| 3333 | } |
| 3334 | } |
| 3335 | |
| 3336 | // Review IMPORTANT 3: `carlos add` on an existing host must not unbind it. |
| 3337 | // add has no -environment flag, so a plain re-add (a routine operation — it |
| 3338 | // is how a route's socket, backing or db path is corrected) used to clear the |
| 3339 | // binding, and the next config sync then rendered the default bundle over |
| 3340 | // that route's env file and restarted the unit. |
| 3341 | func TestAddOnABoundHostPreservesItsEnvironment(t *testing.T) { |
| 3342 | f := newRouteTestFixture(t) |
| 3343 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3344 | f.saveEnvironment(release.OpsAccount, "tito", "demo") |
| 3345 | if out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3346 | "-host", "demo.tito.example.com", "-environment", "demo"); err != nil { |
| 3347 | t.Fatalf("route --environment demo: %v\n%s", err, out) |
| 3348 | } |
| 3349 | |
| 3350 | if out, err := f.run(f.fullEnv(), "add", "-registry", f.regPath, |
| 3351 | "-app", "tito", "demo.tito.example.com"); err != nil { |
| 3352 | t.Fatalf("re-add: %v\n%s", err, out) |
| 3353 | } |
| 3354 | if got := f.environmentOf("demo.tito.example.com"); got != "demo" { |
| 3355 | t.Fatalf("environment = %q after `carlos add` on a bound host, want demo", got) |
| 3356 | } |
| 3357 | } |
| 3358 | |
| 3359 | // Review CRITICAL 2, end-to-end: a typo'd `env unset -environment` must not |
| 3360 | // mint the bundle it names. A phantom empty bundle satisfies the route |
| 3361 | // existence rail — whose entire purpose is refusing a bundle that is not |
| 3362 | // there — and a route bound to one silently loses the environment it was on. |
| 3363 | func TestRouteEnvironmentRailNotSatisfiableByATypoedUnset(t *testing.T) { |
| 3364 | f := newRouteTestFixture(t) |
| 3365 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3366 | |
| 3367 | out, err := f.run(f.fullEnv(), "env", "unset", "-app", "tito", "-environment", "typoo", "NOTHING") |
| 3368 | if err == nil { |
| 3369 | t.Fatalf("env unset against a nonexistent bundle succeeded:\n%s", out) |
| 3370 | } |
| 3371 | if !strings.Contains(out, "nothing to unset") { |
| 3372 | t.Errorf("refusal does not say there was nothing to unset:\n%s", out) |
| 3373 | } |
| 3374 | out, err = f.run(f.fullEnv(), "env", "environments", "-app", "tito") |
| 3375 | if err != nil { |
| 3376 | t.Fatalf("env environments: %v\n%s", err, out) |
| 3377 | } |
| 3378 | if strings.Contains(out, "typoo") { |
| 3379 | t.Fatalf("the unset minted a phantom bundle:\n%s", out) |
| 3380 | } |
| 3381 | out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3382 | "-host", "demo.tito.example.com", "-environment", "typoo") |
| 3383 | if err == nil { |
| 3384 | t.Fatalf("the existence rail accepted a bundle that was never created:\n%s", out) |
| 3385 | } |
| 3386 | if got := f.environmentOf("demo.tito.example.com"); got != "" { |
| 3387 | t.Fatalf("environment = %q, want it unwritten", got) |
| 3388 | } |
| 3389 | } |
| 3390 | |
| 3391 | // -dry-run on -environment runs the full existence rail and prints the |
| 3392 | // plan, but writes nothing — same contract as -channel's -dry-run. |
| 3393 | func TestRouteEnvironmentDryRunWritesNothing(t *testing.T) { |
| 3394 | f := newRouteTestFixture(t) |
| 3395 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3396 | f.saveEnvironment(release.OpsAccount, "tito", "demo") |
| 3397 | |
| 3398 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| 3399 | "-host", "demo.tito.example.com", "-environment", "demo", "-dry-run") |
| 3400 | if err != nil { |
| 3401 | t.Fatalf("route --environment --dry-run: %v\n%s", err, out) |
| 3402 | } |
| 3403 | if !strings.Contains(out, "dry run") { |
| 3404 | t.Errorf("output does not say this was a dry run:\n%s", out) |
| 3405 | } |
| 3406 | if got := f.environmentOf("demo.tito.example.com"); got != "" { |
| 3407 | t.Fatalf("dry run wrote an environment change: %q", got) |
| 3408 | } |
| 3409 | } |
| 3410 | |
| 3411 | // Neither -channel nor -environment given is a usage error naming the |
| 3412 | // route-creation "" convention, not a silent no-op. |
| 3413 | func TestRouteNeitherChannelNorEnvironmentIsUsageError(t *testing.T) { |
| 3414 | f := newRouteTestFixture(t) |
| 3415 | f.addRoute(registry.Route{Host: "demo.tito.example.com", Kind: "instance", App: "tito", Status: "ready"}) |
| 3416 | |
| 3417 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "demo.tito.example.com") |
| 3418 | if err == nil { |
| 3419 | t.Fatalf("neither --channel nor --environment was accepted:\n%s", out) |
| 3420 | } |
| 3421 | if !strings.Contains(out, "stable-by-default") { |
| 3422 | t.Errorf("refusal does not explain \"\" at route creation vs. this verb:\n%s", out) |
| 3423 | } |
| 3424 | } |
| 3425 | |
| 3426 | // A legacy-source migration must never move a QUALIFIED pointer backwards. |
| 3427 | // The legacy copy of a channel pointer is stale the moment anything promotes |
| 3428 | // on the qualified key, and "-from legacy" taking legacy as truth converged |
| 3429 | // the flagship console's canary pointer from 3c7c7bc back to a day-old |
| 3430 | // 771c2f0 on 2026-08-03 — a production rollback armed by a routine cleanup, |
| 3431 | // caught only because the next adopt pass printed the downgrade. The verb |
| 3432 | // now keeps a differing qualified pointer and says so. |
| 3433 | func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) { |
| 3434 | tmp := t.TempDir() |
| 3435 | carlosBin := sharedCarlos(t) |
| 3436 | storeDir := filepath.Join(tmp, "store") |
| 3437 | regPath := filepath.Join(tmp, "registry.db") |
| 3438 | store := release.NewDirStore(storeDir) |
| 3439 | ctx := context.Background() |
| 3440 | |
| 3441 | seed, pubB64, err := release.GenerateKey() |
| 3442 | if err != nil { |
| 3443 | t.Fatal(err) |
| 3444 | } |
| 3445 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 3446 | if err != nil { |
| 3447 | t.Fatal(err) |
| 3448 | } |
| 3449 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 3450 | if err != nil { |
| 3451 | t.Fatal(err) |
| 3452 | } |
| 3453 | env := carlosTestEnv(t, |
| 3454 | "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| 3455 | "CARLOS_RELEASE_PUBKEY="+pubB64, |
| 3456 | "HOME="+tmp, |
| 3457 | "CARLOS_CONSOLE=", |
| 3458 | "CARLOS_DESTINATION=", |
| 3459 | ) |
| 3460 | run := func(args ...string) (string, error) { |
| 3461 | t.Helper() |
| 3462 | cmd := exec.Command(carlosBin, args...) |
| 3463 | cmd.Env = env |
| 3464 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 3465 | cmd.Dir = tmp |
| 3466 | out, err := cmd.CombinedOutput() |
| 3467 | return string(out), err |
| 3468 | } |
| 3469 | |
| 3470 | // The console shape: v1 exists at LEGACY keys (built via scratch store, |
| 3471 | // same signed-bytes trick as TestAccountsMigrateLegacyIntoOps), and v2 has |
| 3472 | // since been shipped and promoted on the QUALIFIED key — live state. |
| 3473 | scratch := release.NewDirStore(filepath.Join(tmp, "scratch")) |
| 3474 | m1 := release.Manifest{Schema: 1, App: "consoleish", Version: "v1", Kind: "binary", |
| 3475 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "consoleish"}}} |
| 3476 | if err := release.Ship(ctx, scratch, release.OpsAccount, m1, map[string][]byte{"linux-amd64": []byte("v1")}); err != nil { |
| 3477 | t.Fatal(err) |
| 3478 | } |
| 3479 | if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "consoleish", "canary/main", "v1"); err != nil { |
| 3480 | t.Fatal(err) |
| 3481 | } |
| 3482 | qualified, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "consoleish")) |
| 3483 | if err != nil { |
| 3484 | t.Fatal(err) |
| 3485 | } |
| 3486 | for _, k := range qualified { |
| 3487 | body, _, err := scratch.Get(ctx, k) |
| 3488 | if err != nil { |
| 3489 | t.Fatal(err) |
| 3490 | } |
| 3491 | legacy := release.LegacyAppPrefix("consoleish") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "consoleish")) |
| 3492 | if _, err := store.Put(ctx, legacy, body, ""); err != nil { |
| 3493 | t.Fatal(err) |
| 3494 | } |
| 3495 | } |
| 3496 | m2 := release.Manifest{Schema: 1, App: "consoleish", Version: "v2", Kind: "binary", |
| 3497 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: "consoleish"}}} |
| 3498 | if err := release.Ship(ctx, store, release.OpsAccount, m2, map[string][]byte{"linux-amd64": []byte("v2")}); err != nil { |
| 3499 | t.Fatal(err) |
| 3500 | } |
| 3501 | if err := release.Promote(ctx, store, priv, release.OpsAccount, "consoleish", "canary/main", "v2"); err != nil { |
| 3502 | t.Fatal(err) |
| 3503 | } |
| 3504 | |
| 3505 | out, err := run("accounts", "migrate", "-registry", regPath, "-app", "consoleish", |
| 3506 | "-from", release.LegacySource, "-to", release.OpsAccount) |
| 3507 | if err != nil { |
| 3508 | t.Fatalf("migrate: %v\n%s", err, out) |
| 3509 | } |
| 3510 | if !strings.Contains(out, "KEPT") { |
| 3511 | t.Errorf("the kept qualified pointer was not announced:\n%s", out) |
| 3512 | } |
| 3513 | // THE assertion: the qualified pointer still serves v2. Before the fix it |
| 3514 | // was converged back to v1 and this resolves the rollback. |
| 3515 | p, err := release.Channel(ctx, store, pub, release.OpsAccount, "consoleish", "canary/main") |
| 3516 | if err != nil { |
| 3517 | t.Fatal(err) |
| 3518 | } |
| 3519 | if p.Version != "v2" { |
| 3520 | t.Fatalf("qualified pointer rolled back to %s — the legacy copy won", p.Version) |
| 3521 | } |
| 3522 | // And v1's artifacts still migrated (rollback targets preserved). |
| 3523 | if _, _, err := store.Get(ctx, release.AppPrefix(release.OpsAccount, "consoleish")+"releases/v1/manifest.json"); err != nil { |
| 3524 | t.Errorf("v1's manifest did not migrate: %v", err) |
| 3525 | } |
| 3526 | } |
| 3527 | |
| 3528 | // routeOf returns a whole registry row, for the assertions that need more |
| 3529 | // than one column of it. |
| 3530 | func (f *routeTestFixture) routeOf(host string) registry.Route { |
| 3531 | f.t.Helper() |
| 3532 | reg, err := registry.Open(f.regPath) |
| 3533 | if err != nil { |
| 3534 | f.t.Fatal(err) |
| 3535 | } |
| 3536 | defer reg.Close() |
| 3537 | rt, err := reg.Get(host) |
| 3538 | if err != nil { |
| 3539 | f.t.Fatal(err) |
| 3540 | } |
| 3541 | if rt == nil { |
| 3542 | f.t.Fatalf("no route for host %q", host) |
| 3543 | } |
| 3544 | return *rt |
| 3545 | } |
| 3546 | |
| 3547 | // TestRouteBackingRefusesAnUnknownValue is the 2026-08-03 review's I1: |
| 3548 | // runRouteBacking's doc comment already claimed the value was checked "at the |
| 3549 | // flag before the registry is even opened", and nothing performed the check. |
| 3550 | // registry.SetBacking writes the column verbatim, and release.Adoptable |
| 3551 | // compares against BackingUnit EXACTLY — so `-backing unti` was accepted, |
| 3552 | // stored "unti", and left the route neither unit-backed nor visibly wrong: the |
| 3553 | // migration's one write, silently a no-op that only shows up as an app that |
| 3554 | // never gets its capabilities. |
| 3555 | // |
| 3556 | // The refusal has to NAME both legal values, because the empty one is a real |
| 3557 | // value of this column and an operator cannot guess that from a rejection. |
| 3558 | func TestRouteBackingRefusesAnUnknownValue(t *testing.T) { |
| 3559 | f := newRouteTestFixture(t) |
| 3560 | f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", |
| 3561 | Channel: "stable", Status: "ready"}) |
| 3562 | |
| 3563 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unti") |
| 3564 | if err == nil { |
| 3565 | t.Fatalf("a misspelled --backing value was accepted:\n%s", out) |
| 3566 | } |
| 3567 | for _, want := range []string{"unti", "unit", `""`} { |
| 3568 | if !strings.Contains(out, want) { |
| 3569 | t.Errorf("refusal does not name %q:\n%s", want, out) |
| 3570 | } |
| 3571 | } |
| 3572 | if got := f.routeOf("keymail.dev").Backing; got != "" { |
| 3573 | t.Fatalf("backing column = %q after a refusal, want it untouched", got) |
| 3574 | } |
| 3575 | } |
| 3576 | |
| 3577 | // TestRouteBackingIsExclusiveWithTheOtherAxes is I2: -backing is a |
| 3578 | // single-route axis exactly like -grant/-revoke and -hibernate, and it was not |
| 3579 | // held to their exclusions. Every combination below was a SILENT PARTIAL — |
| 3580 | // runRoute's switch runs grantAxis, then hibernateSet, then -backing, and the |
| 3581 | // channel/environment branch is unreachable once any of them is set — so the |
| 3582 | // command did SOME of what was typed and reported success for all of it. And |
| 3583 | // `-app x -backing unit` fell through to a registry lookup of the empty host, |
| 3584 | // answering `no route for host ""` instead of the require-host message. |
| 3585 | func TestRouteBackingIsExclusiveWithTheOtherAxes(t *testing.T) { |
| 3586 | f := newRouteTestFixture(t) |
| 3587 | f.ship(release.OpsAccount, "keymail", "v1") |
| 3588 | f.promote(release.OpsAccount, "keymail", "stable", "v1") |
| 3589 | f.promote(release.OpsAccount, "keymail", "canary/x", "v1") |
| 3590 | f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", |
| 3591 | Channel: "stable", Version: "v1", Status: "ready"}) |
| 3592 | |
| 3593 | cases := []struct { |
| 3594 | name string |
| 3595 | args []string |
| 3596 | want string |
| 3597 | }{ |
| 3598 | { |
| 3599 | // The repoint would never have happened: the axis branch returns |
| 3600 | // before the channel machinery is reached. |
| 3601 | name: "--backing with --channel", |
| 3602 | args: []string{"-host", "keymail.dev", "-backing", "unit", "-channel", "canary/x"}, |
| 3603 | want: "-channel", |
| 3604 | }, |
| 3605 | { |
| 3606 | name: "--backing with --environment", |
| 3607 | args: []string{"-host", "keymail.dev", "-backing", "unit", "-environment", "eu"}, |
| 3608 | want: "-environment", |
| 3609 | }, |
| 3610 | { |
| 3611 | // The dangerous ordering: -hibernate is evaluated FIRST, so this |
| 3612 | // used to write the hibernate column and drop the backing stamp. |
| 3613 | name: "--hibernate=false with --backing", |
| 3614 | args: []string{"-host", "keymail.dev", "-hibernate=false", "-backing", "unit"}, |
| 3615 | want: "mutually exclusive", |
| 3616 | }, |
| 3617 | { |
| 3618 | name: "--grant with --backing", |
| 3619 | args: []string{"-host", "keymail.dev", "-grant", "port:25", "-backing", "unit"}, |
| 3620 | want: "mutually exclusive", |
| 3621 | }, |
| 3622 | { |
| 3623 | name: "--app with --backing gets the require-host message", |
| 3624 | args: []string{"-app", "keymail", "-backing", "unit"}, |
| 3625 | want: "require --host", |
| 3626 | }, |
| 3627 | } |
| 3628 | for _, c := range cases { |
| 3629 | t.Run(c.name, func(t *testing.T) { |
| 3630 | args := append([]string{"route", "-registry", f.regPath}, c.args...) |
| 3631 | out, err := f.run(f.fullEnv(), args...) |
| 3632 | if err == nil { |
| 3633 | t.Fatalf("%s was accepted:\n%s", c.name, out) |
| 3634 | } |
| 3635 | if !strings.Contains(out, c.want) { |
| 3636 | t.Errorf("refusal does not contain %q:\n%s", c.want, out) |
| 3637 | } |
| 3638 | if strings.Contains(out, `no route for host ""`) { |
| 3639 | t.Errorf("refusal is the empty-host lookup, not an axis rail:\n%s", out) |
| 3640 | } |
| 3641 | // Nothing written, on any axis, by any of them. |
| 3642 | rt := f.routeOf("keymail.dev") |
| 3643 | if rt.Backing != "" || rt.Hibernate || rt.Caps != "" || rt.Channel != "stable" || rt.Environment != "" { |
| 3644 | t.Fatalf("a refused command still wrote: %+v", rt) |
| 3645 | } |
| 3646 | }) |
| 3647 | } |
| 3648 | } |
| 3649 | |
| 3650 | // The other half of the same rail: -backing ALONE, on its own route, still |
| 3651 | // works — and prints what the next adopt pass will do with the new value. |
| 3652 | // Without this the exclusions above could be satisfied by a verb that refuses |
| 3653 | // everything. |
| 3654 | func TestRouteBackingStampsAndSaysWhatHappensNext(t *testing.T) { |
| 3655 | f := newRouteTestFixture(t) |
| 3656 | f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", |
| 3657 | Channel: "stable", Status: "ready"}) |
| 3658 | |
| 3659 | out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unit") |
| 3660 | if err != nil { |
| 3661 | t.Fatalf("route --backing unit: %v\n%s", err, out) |
| 3662 | } |
| 3663 | if got := f.routeOf("keymail.dev").Backing; got != registry.BackingUnit { |
| 3664 | t.Fatalf("backing column = %q, want %q", got, registry.BackingUnit) |
| 3665 | } |
| 3666 | if !strings.Contains(out, "capability drop-ins") { |
| 3667 | t.Errorf("the stamp does not say what the next adopt pass will do:\n%s", out) |
| 3668 | } |
| 3669 | // And back again — the migration's reversibility depends on it. |
| 3670 | out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "") |
| 3671 | if err != nil { |
| 3672 | t.Fatalf(`route --backing "": %v\n%s`, err, out) |
| 3673 | } |
| 3674 | if got := f.routeOf("keymail.dev").Backing; got != "" { |
| 3675 | t.Fatalf("backing column = %q after clearing, want empty", got) |
| 3676 | } |
| 3677 | } |
| 3678 | |