carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The ship-time capability declaration surface (cli.go): the one door an
4// app's capability block gets into a release manifest through, and the
5// refusals a human meets at their own terminal rather than on a box an hour
6// later.
7
8import (
9 "context"
10 "crypto/ed25519"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/caps"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21 "github.com/carlosframework/platform/internal/release"
22)
23
24// writeCapsFile drops a capability block at a temp path and returns it.
25func writeCapsFile(t *testing.T, body string) string {
26 t.Helper()
27 p := filepath.Join(t.TempDir(), "capabilities.json")
28 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
29 t.Fatal(err)
30 }
31 return p
32}
33
34// TestReadCapabilitiesRefusals: everything the strict decoder and
35// ValidCapabilities refuse is refused HERE, before resolveMode, before a byte
36// crosses any wire and before release.Ship is called — and every refusal
37// names the offending thing, because "invalid capabilities" tells an operator
38// nothing about which line of their block to fix.
39func TestReadCapabilitiesRefusals(t *testing.T) {
40 cases := []struct {
41 name string
42 body string
43 names string
44 }{
45 {"unknown capability name", `{"v":1,"daemon":true,"gpu":true}`, "gpu"},
46 {"unsupported block version", `{"v":2,"daemon":true}`, "update the platform binary"},
47 {"missing v", `{"daemon":true}`, `"v"`},
48 {"not JSON at all", `not json`, "capabilities"},
49 // The file holds EXACTLY the capabilities object — handing it a whole
50 // manifest is refused (on the missing "v", which is checked before
51 // the unknown-name sweep), never half-decoded into something.
52 {"a whole manifest instead of the block", `{"schema":1,"app":"keymail"}`, `"v"`},
53 {"non-privileged port", `{"v":1,"ports":[{"port":8080}]}`, "8080"},
54 {"reserved port", `{"v":1,"ports":[{"port":22}]}`, "22"},
55 {"reserved runtime_dir", `{"v":1,"runtime_dir":"carlos"}`, "carlos"},
56 {"runtime_dir traversal", `{"v":1,"runtime_dir":"../etc"}`, "runtime_dir"},
57 {"dbs with replicate app", `{"v":1,"data":{"replicate":"app","dbs":["x.db"]}}`, "dbs"},
58 // An explicit null is refused rather than folded into "no
59 // capabilities": a human who passed the flag meant to declare
60 // something, and silently shipping nothing is config-that-lies.
61 {"explicit null", `null`, "omit the flag"},
62 }
63 for _, c := range cases {
64 t.Run(c.name, func(t *testing.T) {
65 got, err := readCapabilities(writeCapsFile(t, c.body), "keymail")
66 if err == nil {
67 t.Fatalf("accepted %s, got %+v", c.body, got)
68 }
69 if !strings.Contains(err.Error(), c.names) {
70 t.Fatalf("refusal %q does not contain %q", err, c.names)
71 }
72 })
73 }
74
75 if _, err := readCapabilities(filepath.Join(t.TempDir(), "nope.json"), "keymail"); err == nil {
76 t.Fatal("a missing --capabilities file must be an error, not a silent nil")
77 }
78
79 // The app name reaches the gate: host_role derives a system user from it,
80 // and an app name too long for one is refused at ship, where a human can
81 // rename, rather than on a box where nobody can.
82 long := strings.Repeat("a", 30)
83 if _, err := readCapabilities(writeCapsFile(t, `{"v":1,"host_role":true}`), long); err == nil {
84 t.Fatal("host_role on an over-long app name must be refused at ship")
85 }
86}
87
88// TestReadCapabilitiesHappy: no flag means nil (and therefore a manifest
89// byte-identical to yesterday's), and a valid block decodes whole.
90func TestReadCapabilitiesHappy(t *testing.T) {
91 got, err := readCapabilities("", "keymail")
92 if err != nil || got != nil {
93 t.Fatalf("no --capabilities = (%+v, %v), want (nil, nil)", got, err)
94 }
95
96 got, err = readCapabilities(writeCapsFile(t,
97 `{"v":1,"command":"relay","daemon":true,"children":true,"runtime_dir":"keymail",`+
98 `"ports":[{"port":25,"name":"mx"}],"data":{"replicate":"app"},"host_role":true}`), "keymail")
99 if err != nil {
100 t.Fatalf("readCapabilities: %v", err)
101 }
102 if got.V != 1 || got.Command != "relay" || !got.Daemon || !got.Children ||
103 got.RuntimeDir != "keymail" || !got.HostRole {
104 t.Fatalf("capabilities = %+v, want the declared block", got)
105 }
106 if len(got.Ports) != 1 || got.Ports[0].Port != 25 || got.Ports[0].Name != "mx" {
107 t.Fatalf("ports = %+v, want [{25 mx}]", got.Ports)
108 }
109 if got.Data == nil || got.Data.Replicate != caps.DataReplicateApp {
110 t.Fatalf("data = %+v, want replicate=app", got.Data)
111 }
112}
113
114// TestShipDirectModeEmbedsTheCapabilityFile is the end-to-end direct-mode
115// path: the flag's file lands in the immutable manifest in the bucket, and —
116// the half that matters more — a ship WITHOUT the flag writes a manifest with
117// no "capabilities" key at all. That second assertion is what keeps every
118// re-ship of a pre-capabilities release idempotent: release.Ship's
119// putImmutable compares bytes, so a manifest that grew an empty object would
120// turn a harmless repeat into a permanent immutable-object conflict.
121func TestShipDirectModeEmbedsTheCapabilityFile(t *testing.T) {
122 clearDeploymentEnv(t)
123 dir := t.TempDir()
124 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
125
126 bin := filepath.Join(t.TempDir(), "carlos")
127 if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
128 t.Fatal(err)
129 }
130 capsFile := writeCapsFile(t, `{"v":1,"daemon":true,"data":{"replicate":"platform","dbs":["console.db"]}}`)
131
132 captureStdout(t)
133 runShip([]string{"-app", "console", "-version", "abc1234", "-target", "linux-arm64",
134 "-capabilities", capsFile, bin})
135 runShip([]string{"-app", "console", "-version", "def5678", "-target", "linux-arm64", bin})
136
137 ctx := context.Background()
138 store := release.NewDirStore(dir)
139
140 m, err := release.GetManifest(ctx, store, release.OpsAccount, "console", "abc1234")
141 if err != nil {
142 t.Fatalf("manifest: %v", err)
143 }
144 if m.Capabilities == nil || !m.Capabilities.Daemon {
145 t.Fatalf("capabilities = %+v, want the declared block", m.Capabilities)
146 }
147 if d := m.Capabilities.Data; d == nil || d.Replicate != caps.DataReplicatePlatform ||
148 len(d.DBs) != 1 || d.DBs[0] != "console.db" {
149 t.Fatalf("data = %+v, want platform replication of console.db", d)
150 }
151
152 raw, _, err := store.Get(ctx, release.ManifestKey(release.OpsAccount, "console", "def5678"))
153 if err != nil {
154 t.Fatalf("read the flagless manifest: %v", err)
155 }
156 if strings.Contains(string(raw), "capabilities") {
157 t.Fatalf("a ship with no --capabilities grew a key:\n%s", raw)
158 }
159}
160
161// tinyProgramForCLI is internal/release's tinyProgram, copied because a test
162// helper cannot be imported across packages: a stripped linux/arm64 Go binary
163// whose main package is modPath, built at test time and written to a file
164// `carlos ship` can read. Nothing is checked in.
165func tinyProgramForCLI(t *testing.T, modPath string) string {
166 t.Helper()
167 if testing.Short() {
168 t.Skip("builds a binary")
169 }
170 dir := t.TempDir()
171 must := func(err error) {
172 t.Helper()
173 if err != nil {
174 t.Fatal(err)
175 }
176 }
177 must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644))
178 must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
179 out := filepath.Join(dir, "bin")
180 cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".")
181 cmd.Dir = dir
182 cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false")
183 if b, err := cmd.CombinedOutput(); err != nil {
184 t.Fatalf("build %s: %v\n%s", modPath, err, b)
185 }
186 return out
187}
188
189// pinShipClock fixes direct-mode ship's BuiltAt so a re-ship of the same
190// version can be byte-identical to its first ship.
191func pinShipClock(t *testing.T) {
192 t.Helper()
193 at := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
194 orig := shipNow
195 shipNow = func() time.Time { return at }
196 t.Cleanup(func() { shipNow = orig })
197}
198
199// TestShipDirectModeRecordsTheProgramAndTheFlag: the direct-bucket door
200// stamps the program from the artifact bytes, and --program-change reaches
201// the manifest.
202func TestShipDirectModeRecordsTheProgramAndTheFlag(t *testing.T) {
203 clearDeploymentEnv(t)
204 dir := t.TempDir()
205 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
206 console, cli := tinyProgramForCLI(t, "example.com/console"), tinyProgramForCLI(t, "example.com/carlos")
207
208 captureStdout(t)
209 runShip([]string{"-app", "console", "-version", "va", "-target", "linux-arm64", console})
210 runShip([]string{"-app", "console", "-version", "vb", "-target", "linux-arm64", "-program-change", cli})
211
212 store := release.NewDirStore(dir)
213 for ver, want := range map[string]struct {
214 prog string
215 change bool
216 }{"va": {"example.com/console", false}, "vb": {"example.com/carlos", true}} {
217 m, err := release.GetManifest(context.Background(), store, release.OpsAccount, "console", ver)
218 if err != nil || m.Program != want.prog || m.ProgramChange != want.change {
219 t.Fatalf("%s manifest = %+v, %v; want program %s, program_change %v", ver, m, err, want.prog, want.change)
220 }
221 }
222}
223
224// TestShipDirectModeReshipIsStampedLikeTheFirstShip (ruling G2): re-shipping
225// a version that is already on record skips the program comparison but still
226// stamps Program and ProgramChange, so release.Ship's byte comparison finds
227// the stored manifest identical. Before G2 the re-ship's manifest had neither
228// field and was refused as different content. vb is re-shipped after va2
229// took the app back to the console program, which the comparison would
230// refuse without the flag; the flag is given again, as it was the first time.
231func TestShipDirectModeReshipIsStampedLikeTheFirstShip(t *testing.T) {
232 clearDeploymentEnv(t)
233 dir := t.TempDir()
234 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
235 pinShipClock(t)
236 console, cli := tinyProgramForCLI(t, "example.com/console"), tinyProgramForCLI(t, "example.com/carlos")
237
238 captureStdout(t)
239 runShip([]string{"-app", "console", "-version", "va", "-target", "linux-arm64", console})
240 runShip([]string{"-app", "console", "-version", "vb", "-target", "linux-arm64", "-program-change", cli})
241 runShip([]string{"-app", "console", "-version", "va2", "-target", "linux-arm64", "-program-change", console})
242 // The re-ships: log.Fatal (a conflict) would end the test binary here.
243 runShip([]string{"-app", "console", "-version", "va", "-target", "linux-arm64", console})
244 runShip([]string{"-app", "console", "-version", "vb", "-target", "linux-arm64", "-program-change", cli})
245
246 m, err := release.GetManifest(context.Background(), release.NewDirStore(dir), release.OpsAccount, "console", "vb")
247 if err != nil || m.Program != "example.com/carlos" || !m.ProgramChange {
248 t.Fatalf("vb manifest = %+v, %v; want the first ship's stamp kept", m, err)
249 }
250}
251
252// --- the grant door (Task 4) -------------------------------------------
253//
254// `carlos route -grant/-revoke` is the operator's door to a transfer of box
255// authority, so its refusals are pinned as pure predicates: everything below
256// is decided before the registry is opened for writing.
257
258// TestGrantRailRefusals: every structural reason a grant or a revoke is
259// refused from the row alone. The two row-shape refusals apply to a REVOKE
260// as well — a row this verb does not own is not a row it should be rewriting
261// in either direction.
262func TestGrantRailRefusals(t *testing.T) {
263 unit := registry.Route{Host: "keymail.dev", Kind: "instance", Backing: registry.BackingUnit, App: "keymail"}
264 cases := []struct {
265 name string
266 rt registry.Route
267 tok string
268 says string
269 }{
270 {"reserved port", unit, "port:443", "reserved"},
271 {"high port", unit, "port:8080", "1..1023"},
272 {"nonsense token", unit, "gpu", "unknown grant token"},
273 {"platform+canary row", func() registry.Route {
274 rt := unit
275 rt.Platform, rt.Canary = true, true
276 return rt
277 }(), "port:25", "does not own"},
278 {"exec-child backing", func() registry.Route {
279 rt := unit
280 rt.Backing = ""
281 return rt
282 }(), "port:25", "unit-backed instance routes"},
283 {"service route", func() registry.Route {
284 rt := unit
285 rt.Kind = "service"
286 return rt
287 }(), "host-role", "unit-backed instance routes"},
288 {"static route", func() registry.Route {
289 rt := unit
290 rt.Kind = "static"
291 rt.Backing = ""
292 return rt
293 }(), "port:25", "unit-backed instance routes"},
294 }
295 for _, c := range cases {
296 t.Run(c.name, func(t *testing.T) {
297 err := grantRail(c.rt, c.tok)
298 if err == nil {
299 t.Fatalf("grantRail(%+v, %q) accepted; want a refusal", c.rt, c.tok)
300 }
301 if !strings.Contains(err.Error(), c.says) {
302 t.Fatalf("refusal %q does not say %q", err, c.says)
303 }
304 })
305 }
306 for _, tok := range []string{"port:25", "host-role", "doc-engine"} {
307 if err := grantRail(unit, tok); err != nil {
308 t.Fatalf("grantRail(unit-backed instance, %q) = %v, want accepted", tok, err)
309 }
310 }
311 // A plain Platform apex (not Canary) is grantable: nothing rebuilds that
312 // row from derived values the way the canary-minting pass does.
313 apex := unit
314 apex.Platform = true
315 if err := grantRail(apex, "port:25"); err != nil {
316 t.Fatalf("grantRail(platform apex) = %v, want accepted", err)
317 }
318}
319
320// TestPortGrantConflict: a privileged port can be held by exactly one process
321// on one machine, so granting one already granted elsewhere on this box is
322// refused NAMING the other host — the operator-facing half of the converge
323// pass's own claim check, which otherwise would not run until an hour after
324// the typing.
325func TestPortGrantConflict(t *testing.T) {
326 routes := []registry.Route{
327 {Host: "keymail.dev", Caps: "host-role,port:25"},
328 {Host: "console.example.com", Caps: "host-role"},
329 {Host: "plain.example.com"},
330 }
331 err := portGrantConflict(routes, "other.example.com", "port:25")
332 if err == nil {
333 t.Fatal("granting a port another route already holds must be refused")
334 }
335 if !strings.Contains(err.Error(), "keymail.dev") {
336 t.Fatalf("refusal %q does not name the route already holding the port", err)
337 }
338 // Re-granting to the route that already holds it is not a conflict with
339 // itself — that path is the idempotent no-op, not a refusal.
340 if err := portGrantConflict(routes, "keymail.dev", "port:25"); err != nil {
341 t.Fatalf("re-granting to the holder = %v, want no conflict", err)
342 }
343 // A different port is free.
344 if err := portGrantConflict(routes, "other.example.com", "port:465"); err != nil {
345 t.Fatalf("port:465 = %v, want no conflict", err)
346 }
347 // host-role is never conflict-checked: the box role is not consumed by
348 // being used, and two apps holding it is two operator decisions.
349 if err := portGrantConflict(routes, "other.example.com", "host-role"); err != nil {
350 t.Fatalf("host-role = %v, want no conflict even though another route holds it", err)
351 }
352 // doc-engine is never conflict-checked either, and for a sharper reason:
353 // it is per-route unit text, not a box resource, and a dedicated sidecar
354 // box running several engine routes is the expected shape.
355 engineRoutes := []registry.Route{{Host: "gotenberg.docs.example.com", Caps: "doc-engine"}}
356 if err := portGrantConflict(engineRoutes, "ocr.docs.example.com", "doc-engine"); err != nil {
357 t.Fatalf("doc-engine = %v, want no conflict even though another route holds it", err)
358 }
359 // A row whose caps column does not parse fails CLOSED, naming that row:
360 // "I could not find out whether this port is claimed" is not a licence.
361 bad := []registry.Route{{Host: "handedited.example.com", Caps: "port:25,gpu"}}
362 err = portGrantConflict(bad, "other.example.com", "port:25")
363 if err == nil {
364 t.Fatal("an unreadable caps column on another row must fail the conflict check closed")
365 }
366 if !strings.Contains(err.Error(), "handedited.example.com") {
367 t.Fatalf("refusal %q does not name the unreadable row", err)
368 }
369}
370
371// TestRouteDaemonCheck pins the tri-state the -hibernate rail reads: a proven
372// daemon, a proven non-daemon, and — the case that matters most — every way
373// the comparison can fail to resolve, each of which reports UNPROVEN so the
374// caller refuses rather than guesses. `carlos route` refuses on unproven;
375// `carlos add` (provision time, no pubkey, no store, no release) proceeds on
376// it, which is why the two are separated here rather than folded into a bool.
377func TestRouteDaemonCheck(t *testing.T) {
378 ctx := context.Background()
379 dir := t.TempDir()
380 store := release.NewDirStore(dir)
381 pub, priv, err := ed25519.GenerateKey(nil)
382 if err != nil {
383 t.Fatal(err)
384 }
385 ship := func(app, version string, daemon bool) {
386 t.Helper()
387 m := release.Manifest{App: app, Version: version, Kind: "binary",
388 Artifacts: map[string]release.Artifact{"linux-arm64": {}}}
389 if daemon {
390 m.Capabilities = &caps.Set{V: 1, Daemon: true}
391 }
392 if err := release.Ship(ctx, store, release.OpsAccount, m,
393 map[string][]byte{"linux-arm64": []byte("bytes " + version)}); err != nil {
394 t.Fatalf("ship %s %s: %v", app, version, err)
395 }
396 }
397 ship("keymail", "abc1234", true)
398 ship("kass", "def5678", false)
399 ship("kass", "0badbad", true)
400 if err := release.Promote(ctx, store, priv, release.OpsAccount, "keymail", "canary/keymail", "abc1234"); err != nil {
401 t.Fatal(err)
402 }
403 if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "canary/kass", "def5678"); err != nil {
404 t.Fatal(err)
405 }
406
407 keymail := registry.Route{Host: "keymail.dev", App: "keymail", Channel: "canary/keymail"}
408 daemon, note, proven := routeDaemonCheck(ctx, store, pub, release.OpsAccount, keymail)
409 if !daemon || !proven {
410 t.Fatalf("a declared daemon = (%v, %q, %v), want proven daemon", daemon, note, proven)
411 }
412
413 kass := registry.Route{Host: "kass.example.com", App: "kass", Channel: "canary/kass"}
414 daemon, note, proven = routeDaemonCheck(ctx, store, pub, release.OpsAccount, kass)
415 if daemon || !proven {
416 t.Fatalf("a release with no daemon = (%v, %q, %v), want proven non-daemon", daemon, note, proven)
417 }
418
419 // BOTH versions count as "what this route runs": the one it serves now,
420 // and the one its channel points at (what the next adopt converges onto).
421 // A route still serving a daemon build may not hibernate just because the
422 // channel has since moved to one that isn't.
423 stale := kass
424 stale.Version = "0badbad"
425 daemon, note, proven = routeDaemonCheck(ctx, store, pub, release.OpsAccount, stale)
426 if !daemon || !proven {
427 t.Fatalf("a route still serving a daemon build = (%v, %q, %v), want proven daemon", daemon, note, proven)
428 }
429
430 // A route naming no app is PROVEN not a daemon: there is no release, so
431 // there is nothing that could declare anything.
432 daemon, _, proven = routeDaemonCheck(ctx, store, pub, release.OpsAccount, registry.Route{Host: "static.example.com"})
433 if daemon || !proven {
434 t.Fatalf("a route with no app = (%v, %v), want a proven non-daemon", daemon, proven)
435 }
436
437 // The four unproven shapes. Each must report proven=false, so `carlos
438 // route -hibernate` refuses: "I could not find out" is never a licence.
439 unproven := []struct {
440 name string
441 store release.Store
442 pub ed25519.PublicKey
443 rt registry.Route
444 }{
445 {"no pubkey", store, nil, keymail},
446 {"no store", nil, pub, keymail},
447 {"unresolvable channel", store, pub, registry.Route{Host: "keymail.dev", App: "keymail", Channel: "canary/nope"}},
448 // The channel resolves and its manifest declares no daemon, but the
449 // version the route is actually SERVING has no readable manifest —
450 // so half the question is unanswered, and half an answer is none.
451 {"manifest missing for the served version", store, pub, func() registry.Route {
452 rt := kass
453 rt.Version = "9999999"
454 return rt
455 }()},
456 }
457 for _, c := range unproven {
458 t.Run(c.name, func(t *testing.T) {
459 _, note, proven := routeDaemonCheck(ctx, c.store, c.pub, release.OpsAccount, c.rt)
460 if proven {
461 t.Fatalf("%s reported a proven answer (%q); want unproven so the caller refuses", c.name, note)
462 }
463 if note == "" {
464 t.Fatal("an unproven verdict must carry a note saying what could not be found out")
465 }
466 })
467 }
468
469 // A wrong key proves nothing either: the pointer's signature must verify.
470 otherPub, _, err := ed25519.GenerateKey(nil)
471 if err != nil {
472 t.Fatal(err)
473 }
474 if _, _, proven := routeDaemonCheck(ctx, store, otherPub, release.OpsAccount, keymail); proven {
475 t.Fatal("a pointer signed by another key must not prove anything")
476 }
477}
478
479// TestBackingRailRefusals: `carlos route -backing` is the migration door
480// (app-capabilities design §5b/§5c), and its two row-shape refusals are
481// structural — no flag overrides either. It is STRICTER than the channel and
482// environment axes on platform rows: those refuse only Platform+Canary
483// (rebuilt by the reconciler), this refuses a plain platform apex too,
484// because a second carlos-app@ unit under an alias would claim ports and a
485// state directory the real route already holds.
486func TestBackingRailRefusals(t *testing.T) {
487 instance := registry.Route{Host: "console.example.com", Kind: "instance", App: "console"}
488 cases := []struct {
489 name string
490 rt registry.Route
491 says string
492 }{
493 {"platform apex", func() registry.Route {
494 rt := instance
495 rt.Platform = true
496 return rt
497 }(), "does not own"},
498 {"platform+canary row", func() registry.Route {
499 rt := instance
500 rt.Platform, rt.Canary = true, true
501 return rt
502 }(), "does not own"},
503 {"service route", func() registry.Route {
504 rt := instance
505 rt.Kind = "service"
506 return rt
507 }(), "only meaningful for an instance route"},
508 {"static route", func() registry.Route {
509 rt := instance
510 rt.Kind = "static"
511 return rt
512 }(), "only meaningful for an instance route"},
513 }
514 for _, c := range cases {
515 t.Run(c.name, func(t *testing.T) {
516 // Row-shape refusals hold whichever value is being written, so
517 // both targets are exercised.
518 for _, target := range []string{registry.BackingUnit, ""} {
519 err := backingRail(c.rt, target)
520 if err == nil {
521 t.Fatalf("backingRail(%+v, %q) accepted; want a refusal", c.rt, target)
522 }
523 if !strings.Contains(err.Error(), c.says) {
524 t.Fatalf("refusal %q does not say %q", err, c.says)
525 }
526 }
527 })
528 }
529 // Both directions of the migration are the same verb on the same row, so
530 // both must clear the rail from either starting value.
531 if err := backingRail(instance, registry.BackingUnit); err != nil {
532 t.Fatalf("backingRail(exec-backed instance) = %v, want accepted", err)
533 }
534 unit := instance
535 unit.Backing = registry.BackingUnit
536 if err := backingRail(unit, ""); err != nil {
537 t.Fatalf("backingRail(unit-backed instance) = %v, want accepted", err)
538 }
539}
540
541// The pair rail, from backingRail's side: `carlos route -unit X` followed by
542// `carlos route -backing unit` reaches exactly the state unitRail forbids in
543// the other order, and a rail guarded at one end only is not guarded. The
544// refusal is DIRECTIONAL — moving a stamped row AWAY from unit backing stays
545// legal, or a row that somehow reached the forbidden state would have no way
546// back out.
547func TestBackingRailRefusesUnitBackingOnAStampedRow(t *testing.T) {
548 stamped := registry.Route{
549 Host: "console.carloku.com", Kind: "instance", App: "console",
550 Addr: "127.0.0.1:8090", Unit: "console.service",
551 }
552 err := backingRail(stamped, registry.BackingUnit)
553 if err == nil {
554 t.Fatal("backingRail(unit-stamped row, backing=unit) accepted; want a refusal")
555 }
556 // The operator has to be told which flag to undo, or the refusal is a
557 // dead end.
558 if !strings.Contains(err.Error(), "--unit -") {
559 t.Errorf("refusal %q does not name the clear command", err)
560 }
561 if err := backingRail(stamped, ""); err != nil {
562 t.Fatalf("backingRail(unit-stamped row, backing=\"\") = %v, want accepted — the way out must stay open", err)
563 }
564}
565
566// TestUnitRailRefusals: `carlos route -unit` is the stamp issue #110 needed,
567// and its one structural refusal is a unit-backed route — that route's
568// process is the carlos-app@<host> unit the platform derives and already
569// restarts, so a second unit name on the row is two answers to one question.
570//
571// Everything else clears the rail on purpose, and the omissions are the
572// interesting half: unlike backingRail this one does NOT refuse Platform rows
573// (the domains reconciler never derives this column and Upsert preserves it,
574// so a stamp survives a re-mint) and does NOT refuse non-instance kinds (a
575// `service` route is exactly a sibling process the platform only proxies, and
576// naming the unit that owns it is a true statement).
577func TestUnitRailRefusals(t *testing.T) {
578 console := registry.Route{
579 Host: "console.carloku.com", Kind: "instance", App: "console",
580 Addr: "127.0.0.1:8090",
581 }
582 unitBacked := console
583 unitBacked.Backing = registry.BackingUnit
584 err := unitRail(unitBacked)
585 if err == nil {
586 t.Fatal("unitRail(unit-backed route) accepted; want a refusal")
587 }
588 if !strings.Contains(err.Error(), "carlos-app@") {
589 t.Fatalf("refusal %q does not name the unit that already owns the process", err)
590 }
591
592 for _, c := range []struct {
593 name string
594 rt registry.Route
595 }{
596 {"addr-backed console", console},
597 {"platform apex", func() registry.Route { rt := console; rt.Platform = true; return rt }()},
598 {"platform+canary row", func() registry.Route {
599 rt := console
600 rt.Platform, rt.Canary = true, true
601 return rt
602 }()},
603 {"service route", func() registry.Route { rt := console; rt.Kind = "service"; return rt }()},
604 } {
605 t.Run(c.name, func(t *testing.T) {
606 if err := unitRail(c.rt); err != nil {
607 t.Fatalf("unitRail(%+v) = %v, want accepted", c.rt, err)
608 }
609 })
610 }
611}
612
613// TestRouteUnitStampsAnExistingRoute drives the whole door as a subprocess,
614// because that is the shape the fix ships in: the production consoles already
615// exist, and `carlos route -unit console.service` has to stamp a LIVE
616// registry row rather than need the route recreated. It also pins the
617// refusals an operator can actually type — a typo'd unit name above all,
618// since a bad name is invisible afterwards (a restart that reaches nothing
619// looks exactly like one that worked).
620func TestRouteUnitStampsAnExistingRoute(t *testing.T) {
621 bin := buildCarlos(t)
622 tmp := t.TempDir()
623 scratch := t.TempDir()
624 regPath := filepath.Join(tmp, "registry.db")
625
626 run := func(args ...string) (string, error) {
627 t.Helper()
628 cmd := exec.Command(bin, args...)
629 cmd.Env = carlosTestEnv(t)
630 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
631 cmd.Dir = scratch
632 out, err := cmd.CombinedOutput()
633 return string(out), err
634 }
635
636 // The console as it exists on a box today: addr-backed, no unit.
637 if out, err := run("add", "-registry", regPath, "-app", "console",
638 "-addr", "127.0.0.1:8090", "console.carloku.com"); err != nil {
639 t.Fatalf("add console route: %v\n%s", err, out)
640 }
641 unitOf := func(host string) string {
642 t.Helper()
643 reg, err := registry.Open(regPath)
644 if err != nil {
645 t.Fatalf("open registry: %v", err)
646 }
647 defer reg.Close()
648 rt, err := reg.Get(host)
649 if err != nil || rt == nil {
650 t.Fatalf("get %s: %+v, %v", host, rt, err)
651 }
652 return rt.Unit
653 }
654 if got := unitOf("console.carloku.com"); got != "" {
655 t.Fatalf("a fresh route already names a unit: %q", got)
656 }
657
658 // A typo must cost nothing and reach nothing.
659 out, err := run("route", "-registry", regPath, "-host", "console.carloku.com", "-unit", "console.servcie")
660 if err == nil {
661 t.Fatalf("route --unit console.servcie: want non-zero exit, got 0\n%s", out)
662 }
663 if got := unitOf("console.carloku.com"); got != "" {
664 t.Fatalf("a refused --unit still wrote %q", got)
665 }
666
667 // -dry-run runs the rail and prints the plan, writing nothing.
668 out, err = run("route", "-registry", regPath, "-host", "console.carloku.com",
669 "-unit", "console.service", "-dry-run")
670 if err != nil {
671 t.Fatalf("route --unit --dry-run: %v\n%s", err, out)
672 }
673 if !strings.Contains(out, "console.service") {
674 t.Errorf("dry run output = %q, want it to name the unit", out)
675 }
676 if got := unitOf("console.carloku.com"); got != "" {
677 t.Fatalf("--dry-run wrote %q", got)
678 }
679
680 // The real stamp — the command the deploy runs on each box.
681 if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com",
682 "-unit", "console.service"); err != nil {
683 t.Fatalf("route --unit console.service: %v\n%s", err, out)
684 }
685 if got := unitOf("console.carloku.com"); got != "console.service" {
686 t.Fatalf("Unit = %q after the stamp, want console.service", got)
687 }
688
689 // It survives a re-`carlos add` of the same row (registry.Upsert's
690 // preservation): dropping it would silently restore issue #110.
691 if out, err := run("add", "-registry", regPath, "-app", "console",
692 "-addr", "127.0.0.1:8090", "console.carloku.com"); err != nil {
693 t.Fatalf("re-add console route: %v\n%s", err, out)
694 }
695 if got := unitOf("console.carloku.com"); got != "console.service" {
696 t.Fatalf("Unit = %q after a re-add, want the stamp preserved", got)
697 }
698
699 // The listing shows it, so an operator can see which routes adoption can
700 // cycle without opening the DB.
701 out, err = run("instances", "-registry", regPath)
702 if err != nil {
703 t.Fatalf("instances: %v\n%s", err, out)
704 }
705 if !strings.Contains(out, "UNIT") || !strings.Contains(out, "console.service") {
706 t.Errorf("instances output = %q, want a UNIT column carrying console.service", out)
707 }
708
709 // The same listing carries a route's config-ENVIRONMENT binding (issue
710 // #64): it decides which secrets the route's process is delivered, and
711 // reading it used to mean sqlite3 against this file. Bind one and read
712 // it back through the binary.
713 if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com",
714 "-environment", "relay"); err == nil {
715 // The bind only succeeds where the bundle exists; where it does
716 // not, the refusal is the environment rail doing its job and the
717 // column assertion below still has the unbound case to check.
718 _ = out
719 }
720 out, err = run("instances", "-registry", regPath)
721 if err != nil {
722 t.Fatalf("instances: %v\n%s", err, out)
723 }
724 if !strings.Contains(out, "ENV") {
725 t.Errorf("instances output = %q, want an ENV column — a route's config binding was unreadable without sqlite3 (#64)", out)
726 }
727
728 // And the clear door, so a mis-stamp is reversible.
729 if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com", "-unit", "-"); err != nil {
730 t.Fatalf("route --unit -: %v\n%s", err, out)
731 }
732 if got := unitOf("console.carloku.com"); got != "" {
733 t.Fatalf("Unit = %q after --unit -, want it cleared", got)
734 }
735
736 // The pair rail through the real binary, in both orders. Neither door may
737 // mint a row that is unit-BACKED and unit-NAMED at once: the dispatch
738 // honours the name, so the carlos-app@<host> unit would never be cycled
739 // by adoption again — issue #110 recreated for an ordinary tenant.
740 out, err = run("add", "-registry", regPath, "-app", "kass", "-backing", "unit",
741 "-unit", "bespoke.service", "-db", filepath.Join(tmp, "t.db"), "pair.example.com")
742 if err == nil {
743 t.Fatalf("add --backing unit --unit bespoke.service: want non-zero exit, got 0\n%s", out)
744 }
745 if !strings.Contains(out, "carlos-app@") {
746 t.Errorf("add refusal = %q, want it to name the unit that already owns the process", out)
747 }
748 if out, err := run("add", "-registry", regPath, "-app", "kass", "-backing", "unit",
749 "-db", filepath.Join(tmp, "t.db"), "tenant.example.com"); err != nil {
750 t.Fatalf("add unit tenant: %v\n%s", err, out)
751 }
752 out, err = run("route", "-registry", regPath, "-host", "tenant.example.com", "-unit", "bespoke.service")
753 if err == nil {
754 t.Fatalf("route --unit on a unit-backed route: want non-zero exit, got 0\n%s", out)
755 }
756 if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com",
757 "-unit", "console.service"); err != nil {
758 t.Fatalf("re-stamp console: %v\n%s", err, out)
759 }
760 out, err = run("route", "-registry", regPath, "-host", "console.carloku.com", "-backing", "unit")
761 if err == nil {
762 t.Fatalf("route --backing unit on a unit-stamped route: want non-zero exit, got 0\n%s", out)
763 }
764 if !strings.Contains(out, "--unit -") {
765 t.Errorf("backing refusal = %q, want it to name the clear command", out)
766 }
767 if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com", "-unit", "-"); err != nil {
768 t.Fatalf("clear the stamp again: %v\n%s", err, out)
769 }
770
771 // The exclusions: each of these is a command that would otherwise do part
772 // of what was typed and report success.
773 for _, c := range []struct {
774 name string
775 args []string
776 }{
777 {"with --channel", []string{"-host", "console.carloku.com", "-unit", "console.service", "-channel", "stable"}},
778 {"with --backing", []string{"-host", "console.carloku.com", "-unit", "console.service", "-backing", "unit"}},
779 {"with --hibernate", []string{"-host", "console.carloku.com", "-unit", "console.service", "-hibernate"}},
780 {"bulk --app", []string{"-app", "console", "-unit", "console.service"}},
781 {"empty value", []string{"-host", "console.carloku.com", "-unit", ""}},
782 {"unknown host", []string{"-host", "nosuch.example.com", "-unit", "console.service"}},
783 } {
784 t.Run(c.name, func(t *testing.T) {
785 out, err := run(append([]string{"route", "-registry", regPath}, c.args...)...)
786 if err == nil {
787 t.Fatalf("route %v: want non-zero exit, got 0\n%s", c.args, out)
788 }
789 })
790 }
791 if got := unitOf("console.carloku.com"); got != "" {
792 t.Fatalf("a refused command wrote %q", got)
793 }
794}
795
796// TestAddPreservesUpstreamsOnReAdd carries Task 3's review forward (registry
797// doc at cli.go:194-198, now closed by runAdd): a bare re-`carlos add` over a
798// host that already carries a multi-upstream set must not blank it. Upsert's
799// addrs/health columns are plain overwrites, and until this fix a Route
800// literal built from add's own flags — none of which name Addrs/Health —
801// would clobber whatever `carlos instances set-upstreams` (or a converged
802// reconcile pass) had established, the same shape of hazard a plain
803// excluded.environment once caused.
804//
805// An explicit -addr on that same re-add is its own case (Task 8 review,
806// IMPORTANT 1): it does NOT silently win by clearing the established Addrs
807// — that would build a row ValidateShape refuses everywhere else ("addr and
808// addrs both set") and, even set aside that, would be reverted by the next
809// converge tick anyway (convergeOwned re-derives Addr from the record's
810// Addrs). It is refused instead, pointing at `carlos instances
811// set-upstreams` — the record, not this box verb, owns multi-upstream shape.
812//
813// SetUpstreams (internal/registry) is used directly, not through a CLI verb,
814// to establish the set: it is the narrow setter reconcile itself converges
815// through, and no `carlos` command exposes it standalone — set-upstreams
816// (Task 8, instances_cli.go) speaks to a console, and this test wants
817// nothing else about the write path in flight.
818func TestAddPreservesUpstreamsOnReAdd(t *testing.T) {
819 bin := buildCarlos(t)
820 tmp := t.TempDir()
821 scratch := t.TempDir()
822 regPath := filepath.Join(tmp, "registry.db")
823
824 run := func(args ...string) (string, error) {
825 t.Helper()
826 cmd := exec.Command(bin, args...)
827 cmd.Env = carlosTestEnv(t)
828 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
829 cmd.Dir = scratch
830 out, err := cmd.CombinedOutput()
831 return string(out), err
832 }
833
834 const host = "kass.example.com"
835 if out, err := run("add", "-registry", regPath, "-app", "kass",
836 "-addr", "127.0.0.1:9001", host); err != nil {
837 t.Fatalf("add: %v\n%s", err, out)
838 }
839
840 reg, err := registry.Open(regPath)
841 if err != nil {
842 t.Fatalf("open registry: %v", err)
843 }
844 defer reg.Close()
845 const wantAddrs = "127.0.0.1:9001,127.0.0.1:9002"
846 const wantHealth = `{"path":"/healthz","status":200}`
847 if err := reg.SetUpstreams(host, "", wantAddrs, wantHealth); err != nil {
848 t.Fatalf("SetUpstreams: %v", err)
849 }
850 // Steering (multi-region ingress design Part 4) has the identical gap:
851 // no -steering flag on this verb, Upsert's steering column is a plain
852 // overwrite like addrs/health, so a bare re-add must not blank a route's
853 // Route53 opt-in either (review finding: a re-add landing inside the
854 // steering housekeeper's 5-minute converge window would otherwise delete
855 // the host's live latency records).
856 if err := reg.SetSteering(host, "latency"); err != nil {
857 t.Fatalf("SetSteering: %v", err)
858 }
859
860 // A re-add naming -addr again, once the row carries an established
861 // multi-upstream set, is refused rather than silently clearing it — the
862 // record owns that shape, not this box verb.
863 out, err := run("add", "-registry", regPath, "-app", "kass",
864 "-addr", "127.0.0.1:9001", host)
865 if err == nil {
866 t.Fatalf("re-add with --addr over an established Addrs set: want non-zero exit, got 0\n%s", out)
867 }
868 if !strings.Contains(out, "carlos instances set-upstreams") {
869 t.Errorf("refusal = %q, want it to name carlos instances set-upstreams", out)
870 }
871 rt, err := reg.Get(host)
872 if err != nil || rt == nil {
873 t.Fatalf("get after the refused re-add: %+v, %v", rt, err)
874 }
875 if rt.Addrs != wantAddrs || rt.Health != wantHealth || rt.Addr != "" {
876 t.Fatalf("row after a refused re-add = addr %q addrs %q health %q, want it untouched (addr \"\", addrs %q, health %q)",
877 rt.Addr, rt.Addrs, rt.Health, wantAddrs, wantHealth)
878 }
879 if rt.Steering != "latency" {
880 t.Fatalf("steering after a refused re-add = %q, want it untouched (latency)", rt.Steering)
881 }
882
883 // A re-add naming NO -addr at all must not blank Addrs/Health either —
884 // this box-local verb only carries what its own flags set, and the
885 // refusal above is -addr-specific, not a blanket lockout of the host.
886 if out, err := run("add", "-registry", regPath, "-app", "kass", host); err != nil {
887 t.Fatalf("re-add with no --addr: %v\n%s", err, out)
888 }
889 rt, err = reg.Get(host)
890 if err != nil || rt == nil {
891 t.Fatalf("get after flagless re-add: %+v, %v", rt, err)
892 }
893 if rt.Addrs != wantAddrs || rt.Health != wantHealth {
894 t.Errorf("Addrs/Health after a flagless re-add = %q/%q, want both still preserved", rt.Addrs, rt.Health)
895 }
896 if rt.Addr != "" {
897 t.Errorf("Addr after a flagless re-add = %q, want it to stay preserved as the establishing SetUpstreams left it", rt.Addr)
898 }
899 if rt.Steering != "latency" {
900 t.Errorf("steering after a flagless re-add = %q, want it still preserved as the establishing SetSteering left it", rt.Steering)
901 }
902
903 // The refusal is Addrs-specific: an ordinary re-add with -addr on a host
904 // that has no established multi-upstream set is unaffected and still
905 // works exactly as it always has.
906 const solo = "solo.example.com"
907 if out, err := run("add", "-registry", regPath, "-app", "kass",
908 "-addr", "127.0.0.1:9100", solo); err != nil {
909 t.Fatalf("add solo: %v\n%s", err, out)
910 }
911 if out, err := run("add", "-registry", regPath, "-app", "kass",
912 "-addr", "127.0.0.1:9200", solo); err != nil {
913 t.Fatalf("re-add solo with --addr (no established Addrs set): %v\n%s", err, out)
914 }
915 soloRt, err := reg.Get(solo)
916 if err != nil || soloRt == nil {
917 t.Fatalf("get solo: %+v, %v", soloRt, err)
918 }
919 if soloRt.Addr != "127.0.0.1:9200" {
920 t.Errorf("solo Addr = %q, want the second --addr to have won", soloRt.Addr)
921 }
922}
923
924// --- release versioning: the CLI surface -------------------------------
925//
926// (release versioning spec, 2026-08-08.) Driven through the real binary in
927// DIRECT mode, following scopedkeys_e2e_test.go exactly: a DirStore
928// deployment dir under t.TempDir(), a root signing key the binary mints for
929// itself, carlosTestEnv to mask the operator's real login and console, and
930// cmd.Dir at a scratch dir because the binary walks cwd ancestors for
931// .carlos/config.
932
933// versioningCLI builds carlos, mints a root key with it, and returns a
934// runner bound to a fresh deployment dir — returned alongside the runner so
935// a test can assert on what actually landed in the store, not only on what
936// stdout claimed.
937func versioningCLI(t *testing.T) (run func(args ...string) (string, error), bucket string) {
938 t.Helper()
939 bin := buildCarlos(t)
940 bucket = t.TempDir()
941 scratch := t.TempDir()
942 store := []string{"CARLOS_DEPLOYMENT_DIR=" + bucket, "CARLOS_DATA_DIR=" + t.TempDir()}
943 runWith := func(env []string, args ...string) (string, error) {
944 t.Helper()
945 cmd := exec.Command(bin, args...)
946 cmd.Env = carlosTestEnv(t, append(append([]string{}, store...), env...)...)
947 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
948 cmd.Dir = scratch
949 out, err := cmd.CombinedOutput()
950 return string(out), err
951 }
952 out, err := runWith(nil, "release-keygen")
953 if err != nil {
954 t.Fatalf("release-keygen: %v\n%s", err, out)
955 }
956 keys := []string{
957 "CARLOS_RELEASE_KEY=" + envValue(t, out, "CARLOS_RELEASE_KEY"),
958 "CARLOS_RELEASE_PUBKEY=" + envValue(t, out, "CARLOS_RELEASE_PUBKEY"),
959 }
960 return func(args ...string) (string, error) { return runWith(keys, args...) }, bucket
961}
962
963// versioningArtifact drops one throwaway binary artifact and returns its path.
964func versioningArtifact(t *testing.T, body string) string {
965 t.Helper()
966 p := filepath.Join(t.TempDir(), "kass")
967 if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
968 t.Fatal(err)
969 }
970 return p
971}
972
973// TestShipMintsFromTargetDirect: with a target set, a -version-less ship
974// mints the next iteration under it, and the label/notes the operator typed
975// reach the manifest — the two halves of the feature an operator sees at
976// their own terminal.
977func TestShipMintsFromTargetDirect(t *testing.T) {
978 run, bucket := versioningCLI(t)
979
980 if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
981 t.Fatalf("version target 0.5.0: %v\n%s", err, out)
982 }
983 out, err := run("ship", "-app", "kass",
984 "-label", "fixing modals",
985 "-notes", "modals close on escape now",
986 versioningArtifact(t, "build one"))
987 if err != nil {
988 t.Fatalf("minted ship: %v\n%s", err, out)
989 }
990 if !strings.Contains(out, "0.5.0-1") {
991 t.Fatalf("the first minted ship does not name 0.5.0-1:\n%s", out)
992 }
993
994 st := release.NewDirStore(bucket)
995 m, err := release.GetManifest(context.Background(), st, release.OpsAccount, "kass", "0.5.0-1")
996 if err != nil {
997 t.Fatalf("manifest 0.5.0-1: %v", err)
998 }
999 if m.Label != "fixing modals" {
1000 t.Fatalf("manifest label = %q, want %q", m.Label, "fixing modals")
1001 }
1002 if m.Notes != "modals close on escape now" {
1003 t.Fatalf("manifest notes = %q, want the --notes text", m.Notes)
1004 }
1005
1006 // The counter comes off what has been SHIPPED, so a second ship under the
1007 // same target is -2 with no state anywhere but the release dirs.
1008 out, err = run("ship", "-app", "kass", "-label", "and the sidebar", versioningArtifact(t, "build two"))
1009 if err != nil {
1010 t.Fatalf("second minted ship: %v\n%s", err, out)
1011 }
1012 if !strings.Contains(out, "0.5.0-2") {
1013 t.Fatalf("the second minted ship does not name 0.5.0-2:\n%s", out)
1014 }
1015}
1016
1017// TestShipNoTargetNoVersionFatal: -version is optional only where a target
1018// makes it inferable. With neither, the refusal has to name the verb that
1019// fixes it — an operator who just learned -version is optional must not be
1020// left guessing what the alternative was.
1021func TestShipNoTargetNoVersionFatal(t *testing.T) {
1022 run, _ := versioningCLI(t)
1023 out, err := run("ship", "-app", "kass", versioningArtifact(t, "build one"))
1024 if err == nil {
1025 t.Fatalf("ship with no --version and no target succeeded:\n%s", out)
1026 }
1027 if !strings.Contains(out, "carlos version target") {
1028 t.Fatalf("refusal does not name `carlos version target`:\n%s", out)
1029 }
1030}
1031
1032// TestVersionTargetGetSetDirect: get before set, set, get after — and a
1033// garbage target refused rather than stored.
1034func TestVersionTargetGetSetDirect(t *testing.T) {
1035 run, _ := versioningCLI(t)
1036
1037 out, err := run("version", "target", "-app", "kass")
1038 if err != nil {
1039 t.Fatalf("version target (unset): %v\n%s", err, out)
1040 }
1041 if !strings.Contains(out, "no version target set") {
1042 t.Fatalf("an unset target does not say so:\n%s", out)
1043 }
1044
1045 if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
1046 t.Fatalf("version target 0.5.0: %v\n%s", err, out)
1047 }
1048 out, err = run("version", "target", "-app", "kass")
1049 if err != nil {
1050 t.Fatalf("version target (set): %v\n%s", err, out)
1051 }
1052 if !strings.Contains(out, "0.5.0") {
1053 t.Fatalf("version target does not print the stored target:\n%s", out)
1054 }
1055
1056 if out, err := run("version", "target", "-app", "kass", "banana"); err == nil {
1057 t.Fatalf("version target accepted a non-semver:\n%s", out)
1058 }
1059
1060 // `carlos version` on its own is still the build id, not a target: the
1061 // new sub-verb must not have moved the oldest output in the binary.
1062 out, err = run("version")
1063 if err != nil {
1064 t.Fatalf("version: %v\n%s", err, out)
1065 }
1066 if !strings.HasPrefix(out, "carlos ") || strings.Contains(out, "version target") {
1067 t.Fatalf("`carlos version` is no longer the build id:\n%s", out)
1068 }
1069}
1070
1071// TestReleasesTableDirect: the shipped list, newest first, carrying the
1072// labels that were typed at ship time.
1073func TestReleasesTableDirect(t *testing.T) {
1074 run, _ := versioningCLI(t)
1075 if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
1076 t.Fatalf("version target: %v\n%s", err, out)
1077 }
1078 if out, err := run("ship", "-app", "kass", "-label", "fixing modals", versioningArtifact(t, "build one")); err != nil {
1079 t.Fatalf("ship one: %v\n%s", err, out)
1080 }
1081 if out, err := run("ship", "-app", "kass", "-label", "and the sidebar", versioningArtifact(t, "build two")); err != nil {
1082 t.Fatalf("ship two: %v\n%s", err, out)
1083 }
1084
1085 out, err := run("releases", "-app", "kass")
1086 if err != nil {
1087 t.Fatalf("releases: %v\n%s", err, out)
1088 }
1089 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
1090 if len(lines) < 3 {
1091 t.Fatalf("releases printed %d lines, want a header and two rows:\n%s", len(lines), out)
1092 }
1093 if !strings.Contains(lines[0], "VERSION") || !strings.Contains(lines[0], "LABEL") || !strings.Contains(lines[0], "TAG") {
1094 t.Fatalf("releases header = %q, want VERSION/LABEL/.../TAG", lines[0])
1095 }
1096 if !strings.Contains(lines[1], "0.5.0-2") || !strings.Contains(lines[1], "and the sidebar") {
1097 t.Fatalf("the newest release is not the first row:\n%s", out)
1098 }
1099 if !strings.Contains(out, "fixing modals") {
1100 t.Fatalf("releases does not carry the older label:\n%s", out)
1101 }
1102}
1103
1104// TestPromoteStableTagOutputDirect: the tag a stable promotion cuts is
1105// PRINTED — the operator who typed the promote is the one who needs to know
1106// what the release is now called. `carlos channels` carries it too, so the
1107// name survives past the one line that announced it.
1108func TestPromoteStableTagOutputDirect(t *testing.T) {
1109 run, _ := versioningCLI(t)
1110 if out, err := run("version", "target", "-app", "kass", "0.5.0"); err != nil {
1111 t.Fatalf("version target: %v\n%s", err, out)
1112 }
1113 if out, err := run("ship", "-app", "kass", "-label", "fixing modals", versioningArtifact(t, "build one")); err != nil {
1114 t.Fatalf("ship: %v\n%s", err, out)
1115 }
1116 for _, ch := range []string{"edge", "beta"} {
1117 if out, err := run("promote", "-app", "kass", "0.5.0-1", ch); err != nil {
1118 t.Fatalf("promote to %s: %v\n%s", ch, err, out)
1119 }
1120 }
1121 out, err := run("promote", "-app", "kass", "0.5.0-1", "stable")
1122 if err != nil {
1123 t.Fatalf("promote to stable: %v\n%s", err, out)
1124 }
1125 if !strings.Contains(out, "tagged 0.5.0") {
1126 t.Fatalf("a stable promotion does not print the tag it cut:\n%s", out)
1127 }
1128
1129 chans, err := run("channels", "-app", "kass")
1130 if err != nil {
1131 t.Fatalf("channels: %v\n%s", err, chans)
1132 }
1133 if !strings.Contains(chans, "TAG") {
1134 t.Fatalf("channels has no TAG column:\n%s", chans)
1135 }
1136 var stableRow string
1137 for _, line := range strings.Split(chans, "\n") {
1138 if strings.HasPrefix(line, "stable") {
1139 stableRow = strings.TrimRight(line, " ")
1140 }
1141 }
1142 if stableRow == "" {
1143 t.Fatalf("channels has no stable row:\n%s", chans)
1144 }
1145 if !strings.Contains(stableRow, "0.5.0-1") || !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) {
1146 t.Fatalf("the stable row does not carry the tag of the version it serves: %q\n%s", stableRow, chans)
1147 }
1148}
1149
1150// TestNotesFileAndNotesConflict: two spellings of one field, so passing both
1151// is a refusal rather than a silent precedence rule nobody can remember.
1152func TestNotesFileAndNotesConflict(t *testing.T) {
1153 run, _ := versioningCLI(t)
1154 notes := filepath.Join(t.TempDir(), "NOTES.md")
1155 if err := os.WriteFile(notes, []byte("from a file"), 0o644); err != nil {
1156 t.Fatal(err)
1157 }
1158 out, err := run("ship", "-app", "kass", "-version", "9f3c1ad",
1159 "-notes", "inline", "-notes-file", notes, versioningArtifact(t, "build one"))
1160 if err == nil {
1161 t.Fatalf("ship accepted both --notes and --notes-file:\n%s", out)
1162 }
1163 if !strings.Contains(out, "-notes-file") {
1164 t.Fatalf("refusal does not name --notes-file:\n%s", out)
1165 }
1166}
1167
1168// pickPromoteChannel is what makes `carlos promote <version>` legal without a
1169// channel. The rule it has to hold is not "guess well" but "resolve when the
1170// answer is unambiguous, and otherwise say what this app actually has" —
1171// skills#29 went wrong at the second half: --help documented a four-rung
1172// ladder, the app had two channels of its own, and the guess landed on a name
1173// the app did not have.
1174func TestPickPromoteChannel(t *testing.T) {
1175 // One channel is the shape of most apps: a new app is born with exactly
1176 // one, and that one is its production.
1177 if ch, err := pickPromoteChannel(apiChannelsResult{Channels: []apiChannel{{Channel: "edge"}}}, "a"); err != nil || ch != "edge" {
1178 t.Fatalf("one channel: %q %v", ch, err)
1179 }
1180
1181 // The reported app: a canary someone cut this morning beside the one real
1182 // channel. canary/* is a reserved per-session dead end outside any
1183 // pipeline, so it must not turn an ordinary promote into an ambiguity.
1184 got, err := pickPromoteChannel(apiChannelsResult{Channels: []apiChannel{
1185 {Channel: "canary"}, {Channel: "edge"},
1186 }}, "techarena")
1187 if err != nil || got != "edge" {
1188 t.Fatalf("canary beside edge: %q %v — a canary must not make the real channel ambiguous", got, err)
1189 }
1190
1191 // A pipeline's DECLARED list wins over the promoted rows: a channel with
1192 // no pointer yet is still one you may promote onto.
1193 if ch, err := pickPromoteChannel(apiChannelsResult{
1194 PipelineChannels: []string{"review"}, PipelineEntry: "review",
1195 Channels: []apiChannel{{Channel: "edge"}},
1196 }, "a"); err != nil || ch != "review" {
1197 t.Fatalf("one-channel pipeline: %q %v", ch, err)
1198 }
1199
1200 // Several: refuse, and NAME them. This is the half the reporter needed
1201 // and the reason a bare default would be the wrong fix — a promote that
1202 // silently picked one of these could ship to production.
1203 _, err = pickPromoteChannel(apiChannelsResult{
1204 PipelineChannels: []string{"edge", "beta", "stable"}, PipelineEntry: "edge",
1205 }, "a")
1206 if err == nil {
1207 t.Fatal("a multi-channel pipeline must refuse, not pick")
1208 }
1209 for _, want := range []string{"edge", "beta", "stable", "entry channel is edge"} {
1210 if !strings.Contains(err.Error(), want) {
1211 t.Errorf("refusal %q does not name %q", err, want)
1212 }
1213 }
1214
1215 _, err = pickPromoteChannel(apiChannelsResult{Channels: []apiChannel{
1216 {Channel: "edge"}, {Channel: "production"},
1217 }}, "a")
1218 if err == nil {
1219 t.Fatal("two channels must refuse, not pick")
1220 }
1221 if !strings.Contains(err.Error(), "edge, production") {
1222 t.Errorf("refusal %q does not list the app's own channels", err)
1223 }
1224
1225 // Nothing to resolve from is its own sentence, not a confusing "0 channels".
1226 if _, err := pickPromoteChannel(apiChannelsResult{}, "a"); err == nil ||
1227 !strings.Contains(err.Error(), "no channels yet") {
1228 t.Fatalf("no channels: %v", err)
1229 }
1230}
1231
1232// --- Task 4: the reserved sidecar suffix, enforced at every minting door ---
1233//
1234// Task 3 already refuses to SERVE a Kind=="sidecar" row structurally
1235// (edge.go's Reload skips it at the top of the loop). These rails are
1236// defence in depth behind that guarantee, and they close the OTHER
1237// direction: a row of a DIFFERENT kind minted (by hand, or before this
1238// suffix was reserved) with a Host under provision.SidecarSuffix, which
1239// would be served — undermining the very reservation Task 3 depends on.
1240
1241// TestAddRefusesSidecarSuffixAndKind pins addRail's predicate exactly as the
1242// task-4 brief specifies it.
1243func TestAddRefusesSidecarSuffixAndKind(t *testing.T) {
1244 if err := addRail("bot.eleven.bdm.sidecar.internal", "instance"); err == nil {
1245 t.Fatal("a host under the reserved suffix must be refused")
1246 }
1247 if err := addRail("app.example.com", "sidecar"); err == nil {
1248 t.Fatal("carlos add must not mint a sidecar row — sidecars are provisioned, not added")
1249 }
1250 if err := addRail("app.example.com", "instance"); err != nil {
1251 t.Fatalf("an ordinary add must still work: %v", err)
1252 }
1253}
1254
1255func TestAddRefusesTheTmpNamespace(t *testing.T) {
1256 if err := appRail("tmp-docs"); err == nil {
1257 t.Fatal("carlos add must not route a tmp- app")
1258 }
1259 for _, app := range []string{"", "docs", "tmpdocs"} {
1260 if err := appRail(app); err != nil {
1261 t.Fatalf("appRail(%q) = %v, want nil", app, err)
1262 }
1263 }
1264}
1265
1266// TestAddRefusesTheTmpNamespaceWiredIntoRunAdd proves runAdd calls appRail
1267// before any registry write.
1268func TestAddRefusesTheTmpNamespaceWiredIntoRunAdd(t *testing.T) {
1269 bin := buildCarlos(t)
1270 regPath := filepath.Join(t.TempDir(), "registry.db")
1271 cmd := exec.Command(bin, "add", "-registry", regPath, "-app", "tmp-docs", "-kind", "service", "-addr", "127.0.0.1:9", "svc.example.com")
1272 cmd.Env = carlosTestEnv(t)
1273 cmd.Dir = t.TempDir()
1274 out, err := cmd.CombinedOutput()
1275 if err == nil {
1276 t.Fatalf("carlos add --app tmp-docs: want non-zero exit, got 0\n%s", out)
1277 }
1278 if !strings.Contains(string(out), "tmp-") {
1279 t.Fatalf("refusal does not name the tmp- reservation:\n%s", out)
1280 }
1281 reg, rerr := registry.Open(regPath)
1282 if rerr != nil {
1283 t.Fatal(rerr)
1284 }
1285 defer reg.Close()
1286 if rt, gerr := reg.Get("svc.example.com"); gerr != nil || rt != nil {
1287 t.Fatalf("a refused add still wrote a row: %+v, %v", rt, gerr)
1288 }
1289}
1290
1291// TestAddRefusesSidecarSuffixWiredIntoRunAdd drives `carlos add` as a
1292// subprocess: addRail's own test proves the predicate, this proves runAdd
1293// actually calls it, before any registry write.
1294func TestAddRefusesSidecarSuffixWiredIntoRunAdd(t *testing.T) {
1295 bin := buildCarlos(t)
1296 tmp := t.TempDir()
1297 scratch := t.TempDir()
1298 regPath := filepath.Join(tmp, "registry.db")
1299
1300 run := func(args ...string) (string, error) {
1301 t.Helper()
1302 cmd := exec.Command(bin, args...)
1303 cmd.Env = carlosTestEnv(t)
1304 cmd.Dir = scratch
1305 out, err := cmd.CombinedOutput()
1306 return string(out), err
1307 }
1308
1309 host := "bot.eleven.bdm" + provision.SidecarSuffix
1310 out, err := run("add", "-registry", regPath, "-app", "eleven", host)
1311 if err == nil {
1312 t.Fatalf("carlos add %s: want non-zero exit, got 0\n%s", host, out)
1313 }
1314 if !strings.Contains(out, provision.SidecarSuffix) {
1315 t.Fatalf("refusal does not name the reserved suffix:\n%s", out)
1316 }
1317 reg, rerr := registry.Open(regPath)
1318 if rerr != nil {
1319 t.Fatalf("open registry: %v", rerr)
1320 }
1321 defer reg.Close()
1322 if rt, gerr := reg.Get(host); gerr != nil || rt != nil {
1323 t.Fatalf("a refused add still wrote a row: %+v, %v", rt, gerr)
1324 }
1325
1326 out, err = run("add", "-registry", regPath, "-app", "eleven", "-kind", "sidecar", "app.example.com")
1327 if err == nil {
1328 t.Fatalf("carlos add -kind sidecar: want non-zero exit, got 0\n%s", out)
1329 }
1330 if !strings.Contains(out, "sidecar") {
1331 t.Fatalf("refusal does not mention sidecar:\n%s", out)
1332 }
1333 if rt, gerr := reg.Get("app.example.com"); gerr != nil || rt != nil {
1334 t.Fatalf("a refused -kind sidecar add still wrote a row: %+v, %v", rt, gerr)
1335 }
1336}
1337
1338// TestSuffixRailRefusesReservedSuffix: `carlos route` cannot MINT a row
1339// under the reserved suffix (r.Get returns nil and the verb refuses before
1340// any rail runs) — but it CAN keep an existing one alive or grant it
1341// capabilities, which is the door this rail closes. Pure and table-tested
1342// for the same reason grantRail/backingRail are: a rail nobody can exercise
1343// without running the whole process is a rail nobody tests.
1344func TestSuffixRailRefusesReservedSuffix(t *testing.T) {
1345 under := registry.Route{Host: "bot.eleven.bdm" + provision.SidecarSuffix, Kind: "instance", App: "eleven", Account: "bdm"}
1346 err := suffixRail(under)
1347 if err == nil {
1348 t.Fatalf("suffixRail(%+v) accepted; want a refusal", under)
1349 }
1350 if !strings.Contains(err.Error(), provision.SidecarSuffix) {
1351 t.Fatalf("refusal %q does not name the reserved suffix", err)
1352 }
1353 ordinary := registry.Route{Host: "app.example.com", Kind: "instance", App: "eleven", Account: "bdm"}
1354 if err := suffixRail(ordinary); err != nil {
1355 t.Fatalf("suffixRail(%+v) = %v, want accepted", ordinary, err)
1356 }
1357}
1358
1359// TestRouteRefusesReservedSuffixWiredIn drives `carlos route` as a
1360// subprocess against a row planted directly in the registry — the only way
1361// such a row can exist post-Task-4, since `carlos add` now refuses to mint
1362// one — proving suffixRail is wired into every axis this verb writes, not
1363// only provable in isolation.
1364func TestRouteRefusesReservedSuffixWiredIn(t *testing.T) {
1365 bin := buildCarlos(t)
1366 tmp := t.TempDir()
1367 scratch := t.TempDir()
1368 regPath := filepath.Join(tmp, "registry.db")
1369 host := "bot.eleven.bdm" + provision.SidecarSuffix
1370
1371 reg, err := registry.Open(regPath)
1372 if err != nil {
1373 t.Fatal(err)
1374 }
1375 if err := reg.Upsert(registry.Route{
1376 Host: host, Kind: "instance", App: "eleven", Account: "bdm", Status: "ready",
1377 }); err != nil {
1378 t.Fatal(err)
1379 }
1380 reg.Close()
1381
1382 run := func(args ...string) (string, error) {
1383 t.Helper()
1384 cmd := exec.Command(bin, args...)
1385 cmd.Env = carlosTestEnv(t)
1386 cmd.Dir = scratch
1387 out, err := cmd.CombinedOutput()
1388 return string(out), err
1389 }
1390
1391 for _, args := range [][]string{
1392 {"route", "-registry", regPath, "-host", host, "-channel", "canary/x"},
1393 {"route", "-registry", regPath, "-host", host, "-grant", "port:25"},
1394 {"route", "-registry", regPath, "-host", host, "-hibernate"},
1395 {"route", "-registry", regPath, "-host", host, "-backing", registry.BackingUnit},
1396 {"route", "-registry", regPath, "-host", host, "-unit", "console.service"},
1397 } {
1398 out, err := run(args...)
1399 if err == nil {
1400 t.Fatalf("route %v: want non-zero exit, got 0\n%s", args[3:], out)
1401 }
1402 if !strings.Contains(out, provision.SidecarSuffix) {
1403 t.Fatalf("route %v: refusal does not name the reserved suffix:\n%s", args[3:], out)
1404 }
1405 }
1406
1407 // The -app bulk sweep must not abort on a suffix row swept in alongside
1408 // ordinary ones (a real provisioned sidecar's App equals its owning
1409 // app's, so this is not hypothetical) — it is skipped, like a Platform
1410 // route, while its ordinary sibling still moves.
1411 reg2, err := registry.Open(regPath)
1412 if err != nil {
1413 t.Fatal(err)
1414 }
1415 if err := reg2.Upsert(registry.Route{
1416 Host: "eleven.bdm.oncarlos.com", Kind: "instance", App: "eleven", Account: "bdm", Status: "ready",
1417 }); err != nil {
1418 t.Fatal(err)
1419 }
1420 reg2.Close()
1421 // -environment "-" (clear) is a no-op for a fresh route (Environment is
1422 // already "") and needs no bucket/pubkey setup — chosen so this exercises
1423 // the -app sweep's suffix skip without dragging in the channel axis's
1424 // store/signature machinery, which is not what this test is about.
1425 out, err := run("route", "-registry", regPath, "-app", "eleven", "-account", "bdm", "-environment", "-")
1426 if err != nil {
1427 t.Fatalf("route -app eleven -environment -: %v\n%s", err, out)
1428 }
1429 if !strings.Contains(out, "eleven.bdm.oncarlos.com") {
1430 t.Fatalf("the ordinary sibling route was not moved:\n%s", out)
1431 }
1432 // The suffix row is named in a SKIP line (like a Platform route's own
1433 // skip), not silently dropped — but it must never appear as WRITTEN.
1434 if !strings.Contains(out, host+": skipped") {
1435 t.Fatalf("the suffix row must be named in a skip line, not silently dropped:\n%s", out)
1436 }
1437}
1438
1439// A nested row must stay activator-managed so the layout revert can stop
1440// it: `carlos route --hibernate=false` refuses one, by path shape and
1441// whatever else the row says.
1442func TestRefuseUnflagNested(t *testing.T) {
1443 host := "acme.example.com"
1444 nested := registry.Route{Host: host, Kind: "instance", Hibernate: true,
1445 DBPath: provision.NestedDBPath("/data", host), Socket: provision.NestedSocket(host)}
1446 err := refuseUnflagNested(nested, "/data")
1447 want := "route: acme.example.com: its paths are nested. revert the layout first; a nested instance must stay managed so the revert can stop it"
1448 if err == nil || err.Error() != want {
1449 t.Fatalf("refuseUnflagNested(nested) = %v, want %q", err, want)
1450 }
1451 flat := nested
1452 flat.DBPath, flat.Socket = provision.DefaultDBPath("/data", host), provision.DefaultSocket(host)
1453 if err := refuseUnflagNested(flat, "/data"); err != nil {
1454 t.Fatalf("refuseUnflagNested(flat) = %v, want nil", err)
1455 }
1456}
1457
1458// writeLayoutJournal puts a journal for host under dataDir/layout. The
1459// guards only ask whether one is there, so its contents do not matter.
1460func writeLayoutJournal(t *testing.T, dataDir, host string) {
1461 t.Helper()
1462 path := provision.LayoutJournalPath(dataDir, host)
1463 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1464 t.Fatal(err)
1465 }
1466 if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
1467 t.Fatal(err)
1468 }
1469}
1470
1471// A row whose paths are still flat can be mid-move: the journal is what
1472// says so. Unflagging it would take it out of the management recovery
1473// needs, so the guard refuses it with the same approved text.
1474func TestRefuseUnflagNestedRefusesAFlatRowWithAJournal(t *testing.T) {
1475 host := "acme.example.com"
1476 dd := t.TempDir()
1477 flat := registry.Route{Host: host, Kind: "instance", Hibernate: true,
1478 DBPath: provision.DefaultDBPath(dd, host), Socket: provision.DefaultSocket(host)}
1479 writeLayoutJournal(t, dd, host)
1480 err := refuseUnflagNested(flat, dd)
1481 want := "route: acme.example.com: its paths are nested. revert the layout first; a nested instance must stay managed so the revert can stop it"
1482 if err == nil || err.Error() != want {
1483 t.Fatalf("refuseUnflagNested(flat, journal) = %v, want %q", err, want)
1484 }
1485}
1486
1487// A re-add over a host with a journal would rewrite the row a recovery is
1488// about to check and flip, row or no row, so it refuses too.
1489func TestRefuseAddNestedRefusesAHostWithAJournal(t *testing.T) {
1490 host := "acme.example.com"
1491 dd := t.TempDir()
1492 flat := &registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(dd, host), Socket: provision.DefaultSocket(host)}
1493 writeLayoutJournal(t, dd, host)
1494 want := "add acme.example.com: its paths are nested. revert the layout first; re-adding it would point it back at the shared directory"
1495 for name, existing := range map[string]*registry.Route{"flat row": flat, "no row": nil} {
1496 if err := refuseAddNested(host, existing, dd); err == nil || err.Error() != want {
1497 t.Errorf("%s: refuseAddNested = %v, want %q", name, err, want)
1498 }
1499 }
1500 // A journal this process cannot even read the target of still counts.
1501 other := "other.example.com"
1502 if err := os.Symlink("/nonexistent", provision.LayoutJournalPath(dd, other)); err != nil {
1503 t.Fatal(err)
1504 }
1505 if err := refuseAddNested(other, nil, dd); err == nil {
1506 t.Error("a dangling journal symlink was not taken as a journal")
1507 }
1508}
1509
1510// `carlos add` over an existing nested host would rewrite its paths to the
1511// flat defaults and strand the files under the instance tree, so it
1512// refuses. A new host, and a re-add of a flat one, go ahead.
1513func TestRefuseAddNested(t *testing.T) {
1514 host := "acme.example.com"
1515 nested := &registry.Route{Host: host, Kind: "instance", Hibernate: true,
1516 DBPath: provision.NestedDBPath("/data", host), Socket: provision.NestedSocket(host)}
1517 err := refuseAddNested(host, nested, "/data")
1518 want := "add acme.example.com: its paths are nested. revert the layout first; re-adding it would point it back at the shared directory"
1519 if err == nil || err.Error() != want {
1520 t.Fatalf("refuseAddNested(nested) = %v, want %q", err, want)
1521 }
1522 if err := refuseAddNested(host, nil, "/data"); err != nil {
1523 t.Fatalf("refuseAddNested(new host) = %v, want nil", err)
1524 }
1525 flat := &registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath("/data", host), Socket: provision.DefaultSocket(host)}
1526 if err := refuseAddNested(host, flat, "/data"); err != nil {
1527 t.Fatalf("refuseAddNested(flat) = %v, want nil", err)
1528 }
1529}
1530
1531// `carlos add` mints the nested defaults only when the box's layout
1532// intent names nested; no store means flat as before, and an unreadable
1533// intent is a refusal, not a guess.
1534func TestAddMintsNestedOnlyUnderANestedIntent(t *testing.T) {
1535 ctx := context.Background()
1536 label := func() string { return "kass-1" }
1537 if nested, err := addMintsNested(ctx, nil, label, nil, "/data"); err != nil || nested {
1538 t.Fatalf("no store = %v %v, want flat", nested, err)
1539 }
1540 s := release.NewDirStore(t.TempDir())
1541 if nested, err := addMintsNested(ctx, s, label, nil, "/data"); err != nil || nested {
1542 t.Fatalf("no intent = %v %v, want flat", nested, err)
1543 }
1544 if _, err := release.AppendLayoutIntent(ctx, s, release.LayoutIntent{Mode: release.LayoutModeNested, Scope: release.LayoutScope{Box: "kass-2"}}); err != nil {
1545 t.Fatal(err)
1546 }
1547 if nested, err := addMintsNested(ctx, s, label, nil, "/data"); err != nil || nested {
1548 t.Fatalf("another box's intent = %v %v, want flat", nested, err)
1549 }
1550 if _, err := release.AppendLayoutIntent(ctx, s, release.LayoutIntent{Mode: release.LayoutModeNested, Scope: release.LayoutScope{Box: "kass-1"}}); err != nil {
1551 t.Fatal(err)
1552 }
1553 if nested, err := addMintsNested(ctx, s, label, nil, "/data"); err != nil || !nested {
1554 t.Fatalf("this box's nested intent = %v %v, want nested", nested, err)
1555 }
1556 if _, err := release.AppendLayoutIntent(ctx, s, release.LayoutIntent{Mode: release.LayoutModeFlat}); err != nil {
1557 t.Fatal(err)
1558 }
1559 if nested, err := addMintsNested(ctx, s, label, nil, "/data"); err != nil || nested {
1560 t.Fatalf("a later fleet-wide flat intent = %v %v, want flat", nested, err)
1561 }
1562 // Unreadable (a newer schema): refused with the retry named.
1563 if err := s.Delete(ctx, release.LayoutIntentKey()); err != nil {
1564 t.Fatal(err)
1565 }
1566 if _, err := s.Put(ctx, release.LayoutIntentKey(), []byte(`{"schema":2,"requests":[]}`), ""); err != nil {
1567 t.Fatal(err)
1568 }
1569 if _, err := addMintsNested(ctx, s, label, nil, "/data"); err == nil || !strings.Contains(err.Error(), "pass --socket and --db") {
1570 t.Fatalf("unreadable intent = %v, want a refusal naming the flags", err)
1571 }
1572}
1573
1574// With no intent recorded, `carlos add` mints nested when the box's own
1575// registry already holds an activator-managed row at the nested paths
1576// (the box is nested by default and has moved); a recorded flat intent
1577// wins over that evidence; an unmanaged or external nested-pathed row is
1578// not evidence.
1579func TestAddMintsNestedFromTheBoxsOwnRowsWhenNoIntentIsRecorded(t *testing.T) {
1580 ctx := context.Background()
1581 label := func() string { return "kass-1" }
1582 host := "a.example.com"
1583 managedNested := registry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: provision.NestedSocket(host), DBPath: provision.NestedDBPath("/data", host)}
1584 unflaggedNested := registry.Route{Host: host, Kind: "instance", Socket: provision.NestedSocket(host), DBPath: provision.NestedDBPath("/data", host)}
1585 flat := registry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath("/data", host)}
1586 if nested, err := addMintsNested(ctx, nil, label, []registry.Route{flat}, "/data"); err != nil || nested {
1587 t.Fatalf("flat rows only = %v %v, want flat", nested, err)
1588 }
1589 if nested, err := addMintsNested(ctx, nil, label, []registry.Route{unflaggedNested}, "/data"); err != nil || nested {
1590 t.Fatalf("an unmanaged nested-pathed row = %v %v, want flat (not evidence)", nested, err)
1591 }
1592 if nested, err := addMintsNested(ctx, nil, label, []registry.Route{flat, managedNested}, "/data"); err != nil || !nested {
1593 t.Fatalf("a managed nested row = %v %v, want nested", nested, err)
1594 }
1595 s := release.NewDirStore(t.TempDir())
1596 if nested, err := addMintsNested(ctx, s, label, []registry.Route{managedNested}, "/data"); err != nil || !nested {
1597 t.Fatalf("store with no intent, nested evidence = %v %v, want nested", nested, err)
1598 }
1599 if _, err := release.AppendLayoutIntent(ctx, s, release.LayoutIntent{Mode: release.LayoutModeFlat}); err != nil {
1600 t.Fatal(err)
1601 }
1602 if nested, err := addMintsNested(ctx, s, label, []registry.Route{managedNested}, "/data"); err != nil || nested {
1603 t.Fatalf("a recorded flat intent must win over the evidence: %v %v", nested, err)
1604 }
1605}
1606