| 1 | package 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 | |
| 8 | import ( |
| 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. |
| 25 | func 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. |
| 39 | func 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. |
| 90 | func 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. |
| 121 | func 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. |
| 165 | func 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. |
| 191 | func 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. |
| 202 | func 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. |
| 231 | func 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. |
| 262 | func 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. |
| 325 | func 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. |
| 377 | func 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. |
| 486 | func 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. |
| 547 | func 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). |
| 577 | func 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). |
| 620 | func 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. |
| 818 | func 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. |
| 937 | func 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. |
| 964 | func 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. |
| 977 | func 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. |
| 1021 | func 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. |
| 1034 | func 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. |
| 1073 | func 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. |
| 1108 | func 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. |
| 1152 | func 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. |
| 1174 | func 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. |
| 1243 | func 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 | |
| 1255 | func 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. |
| 1268 | func 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. |
| 1294 | func 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. |
| 1344 | func 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. |
| 1364 | func 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. |
| 1442 | func 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. |
| 1460 | func 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. |
| 1474 | func 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. |
| 1489 | func TestRefuseAddNestedRefusesAHostWithAJournal(t *testing.T) { |
| 1490 | host := "acme.example.com" |
| 1491 | dd := t.TempDir() |
| 1492 | flat := ®istry.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. |
| 1513 | func TestRefuseAddNested(t *testing.T) { |
| 1514 | host := "acme.example.com" |
| 1515 | nested := ®istry.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 := ®istry.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. |
| 1534 | func 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. |
| 1579 | func 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 | |