carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
16package main_test
17
18import (
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.
50func 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.
84func 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.
110func 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.
117func 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
138func 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
163func 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.
181func 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
253func 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.
543func 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.
572func 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.
590func 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.
749func 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.
941func 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.
1098func 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.
1193func 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.
1302func 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).
1374func 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.
1444func 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.
1509func 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.
1561func 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
1686func 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
1730func 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.
1802func 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.
1945func 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.
2083func 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).
2140func 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).
2430type 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
2441func 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.
2463func (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
2473func (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
2483func (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
2492func (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
2499func (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
2511func (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
2528func (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.
2548func (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.
2557func 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.
2589func 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.
2613func 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.
2647func 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.
2672func 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.
2708func 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.
2755func 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".
2783func 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
2804func 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).
2850func 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".
2871func 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.
2889func 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.
2925func 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.
2947func 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.
2998func 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.
3037func 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.
3066func 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.
3101func 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.
3121func 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.
3153func 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.
3173func 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.
3198func 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.
3234func 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.
3261func 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).
3296func 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.
3341func 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.
3363func 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.
3393func 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.
3413func 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.
3433func 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.
3530func (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.
3558func 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.
3585func 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.
3654func 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