| 1 | // End-to-end acceptance test for `carlos deploy` (spec §9's acceptance |
| 2 | // debt): the one member-facing verb that is nothing but a composition — |
| 3 | // ship, promote to the channel this app's instances already follow, then |
| 4 | // watch the URL until it serves the new version — driven against a REAL |
| 5 | // console.Server over a DirStore, with a real device-flow login, exactly |
| 6 | // like TestE2EInstancesThroughConsole (instances_e2e_test.go) and |
| 7 | // TestConfigE2EThroughConsole (config_e2e_test.go). |
| 8 | // |
| 9 | // Two deliberate choices, both worth stating: |
| 10 | // |
| 11 | // 1. The verb runs IN-PROCESS (runDeploy), not as a subprocess of the |
| 12 | // built binary. The watch leg has to be pointed at a loopback stand-in |
| 13 | // for the edge, which needs the watchOrigin seam (deploy.go) — an |
| 14 | // unexported package var a subprocess could not reach without turning |
| 15 | // it into an operator-visible env knob, which is exactly the thing that |
| 16 | // seam's comment refuses. This is the same in-process shape the other |
| 17 | // two console e2es use. |
| 18 | // 2. The credential-isolation rule that governs the subprocess e2es |
| 19 | // (carlosTestEnv + cmd.Dir, e2e_test.go:66 — a logged-in laptop running |
| 20 | // `go test ./...` once minted 40+ accounts on the production console) is |
| 21 | // honoured here by its in-process equivalent, useTempCredentials |
| 22 | // (auth_test.go): it points CARLOS_CREDENTIALS, HOME, CARLOS_CONSOLE |
| 23 | // *and* the working directory (t.Chdir — the .carlos/config ancestor |
| 24 | // walk is why cmd.Dir exists) at scratch space, which is the same four |
| 25 | // inputs carlosTestEnv+cmd.Dir masks for a subprocess. Nothing in this |
| 26 | // test can reach the operator's real login, config, console or bucket. |
| 27 | package main |
| 28 | |
| 29 | import ( |
| 30 | "context" |
| 31 | "io" |
| 32 | "net/http" |
| 33 | "net/http/httptest" |
| 34 | "os" |
| 35 | "path/filepath" |
| 36 | "strings" |
| 37 | "sync" |
| 38 | "testing" |
| 39 | "time" |
| 40 | |
| 41 | "github.com/carlosframework/platform/internal/console" |
| 42 | "github.com/carlosframework/platform/internal/edge" |
| 43 | "github.com/carlosframework/platform/internal/provision" |
| 44 | "github.com/carlosframework/platform/internal/registry" |
| 45 | "github.com/carlosframework/platform/internal/release" |
| 46 | ) |
| 47 | |
| 48 | // assertServes checks that the edge under test answers "/" with the expected |
| 49 | // site content AND stamps the expected version — the pair is the point: a |
| 50 | // header that agreed with nothing on disk would be the false "verified" the |
| 51 | // whole feature exists to prevent. |
| 52 | func assertServes(t *testing.T, origin, wantBody, wantVersion string) { |
| 53 | t.Helper() |
| 54 | resp, err := http.Get(origin + "/") |
| 55 | if err != nil { |
| 56 | t.Fatalf("GET %s/: %v", origin, err) |
| 57 | } |
| 58 | defer resp.Body.Close() |
| 59 | body, err := io.ReadAll(resp.Body) |
| 60 | if err != nil { |
| 61 | t.Fatalf("read body: %v", err) |
| 62 | } |
| 63 | if resp.StatusCode != http.StatusOK || string(body) != wantBody { |
| 64 | t.Fatalf("GET %s/ = %d %q, want 200 %q", origin, resp.StatusCode, body, wantBody) |
| 65 | } |
| 66 | if got := resp.Header.Get("X-Carlos-Version"); got != wantVersion { |
| 67 | t.Fatalf("X-Carlos-Version = %q, want %q (the header must name the bytes it served)", got, wantVersion) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // TestE2EStaticDeployThroughConsole is the same verb for the class that has |
| 72 | // no instance records at all (issue #112): a static site, served by the edge |
| 73 | // off the channel pointer, never adopted. Nothing can be resolved from |
| 74 | // instances here, so the deploy takes its host from -host and its channel |
| 75 | // from the app's own channels — and the watch works at all only because the |
| 76 | // static handler now stamps X-Carlos-Version (internal/edge/static.go). |
| 77 | func TestE2EStaticDeployThroughConsole(t *testing.T) { |
| 78 | useTempCredentials(t) |
| 79 | clearDeploymentEnv(t) |
| 80 | checkNoRegistry := requireNoRegistryTouch(t) |
| 81 | |
| 82 | const ( |
| 83 | addr = "paul@keymail.dev" |
| 84 | app = "sitely" |
| 85 | channel = "canary/site" |
| 86 | host = "sitely.example.test" |
| 87 | ) |
| 88 | |
| 89 | tmp := t.TempDir() |
| 90 | relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| 91 | seed, pubB64, err := release.GenerateKey() |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | t.Cleanup(func() { cst.Close() }) |
| 108 | |
| 109 | var srv *console.Server |
| 110 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 111 | srv.ServeHTTP(w, r) |
| 112 | })) |
| 113 | t.Cleanup(ts.Close) |
| 114 | srv, err = console.NewServer(console.Config{ |
| 115 | Origin: ts.URL, |
| 116 | Keymail: "http://keymail.invalid", |
| 117 | Registry: filepath.Join(tmp, "registry.db"), |
| 118 | Releases: relStore, |
| 119 | ReleasePub: pub, |
| 120 | Key: priv, |
| 121 | AppScheme: "http", |
| 122 | AppsDomain: "oncarlos.test", |
| 123 | }, cst) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | |
| 128 | if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") |
| 135 | credFile, err := loadCredentials() |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| 140 | if err := credFile.save(); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | |
| 144 | // The site's edge is the REAL one: an edge.Edge with static serving |
| 145 | // enabled over the same store and pubkey the console writes through, and |
| 146 | // its static route pointed at the same channel. So the header the watch |
| 147 | // judges is the one internal/edge/static.go actually stamps — the two |
| 148 | // halves of #112 meeting, rather than each being tested against a mock of |
| 149 | // the other. The registry here is the EDGE's own, in the test's temp dir; |
| 150 | // checkNoRegistry still guards the one the CLI must never open. |
| 151 | // |
| 152 | // SetStaticRecheck(0) collapses the recheck window: the window is real in |
| 153 | // production (and is what the muted watch line is about) but waiting one |
| 154 | // out here would buy nothing but a minute per run. |
| 155 | edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db")) |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | t.Cleanup(func() { edgeReg.Close() }) |
| 160 | ed, err := edge.New(edgeReg, nil) |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache")) |
| 165 | ed.SetStaticRecheck(0) |
| 166 | edgeSrv := httptest.NewServer(ed) |
| 167 | t.Cleanup(edgeSrv.Close) |
| 168 | // The edge matches on the request's Host with the port stripped, and the |
| 169 | // watch dials this loopback server, so the route's host is 127.0.0.1 — |
| 170 | // the public name lives in -host, which is what gets printed. |
| 171 | if err := edgeReg.Upsert(registry.Route{ |
| 172 | Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready", |
| 173 | }); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if err := ed.Reload(); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | origOrigin := watchOrigin |
| 180 | watchOrigin = func(h string) string { |
| 181 | if h != host { |
| 182 | t.Errorf("watch dialled an unexpected host %q, want %q", h, host) |
| 183 | } |
| 184 | return edgeSrv.URL |
| 185 | } |
| 186 | t.Cleanup(func() { watchOrigin = origOrigin }) |
| 187 | |
| 188 | siteDir := filepath.Join(tmp, "site") |
| 189 | if err := os.MkdirAll(siteDir, 0o755); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | |
| 196 | // --- run 1: the app has no channels yet, so -channel names one -------- |
| 197 | buf := captureStdout(t) |
| 198 | runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| 199 | "-channel", channel, "-host", host, "-timeout", "30s", siteDir}) |
| 200 | out := buf.String() |
| 201 | for _, wantLine := range []string{ |
| 202 | "deploying https://" + host, |
| 203 | "shipped " + app + " sha0001", |
| 204 | "promoted " + app + " sha0001 -> " + channel, |
| 205 | "re-checks its channel", |
| 206 | "live https://" + host, |
| 207 | } { |
| 208 | if !strings.Contains(out, wantLine) { |
| 209 | t.Fatalf("static deploy stdout missing %q:\n%s", wantLine, out) |
| 210 | } |
| 211 | } |
| 212 | if strings.Contains(out, "watching "+host) { |
| 213 | t.Fatalf("static note still repeats the host the deploying line just printed:\n%s", out) |
| 214 | } |
| 215 | // A static route has no process and no /api/version, so the no-probe |
| 216 | // advisory (deploy.go, gated on !staticDeploy) must not tell the |
| 217 | // operator to set one — following that advice on a static site sets a |
| 218 | // probe that 404s and fails every later `carlos deploy` for it. |
| 219 | if strings.Contains(out, "build-probe") { |
| 220 | t.Fatalf("static deploy suggested a build probe, which a static route can never serve:\n%s", out) |
| 221 | } |
| 222 | ptr, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, app, channel) |
| 223 | if err != nil { |
| 224 | t.Fatalf("channel after deploy: %v", err) |
| 225 | } |
| 226 | if ptr.Version != "sha0001" { |
| 227 | t.Fatalf("%s points at %q, want sha0001", channel, ptr.Version) |
| 228 | } |
| 229 | m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, app, "sha0001") |
| 230 | if err != nil { |
| 231 | t.Fatalf("manifest after deploy: %v", err) |
| 232 | } |
| 233 | if m.Kind != "static" { |
| 234 | t.Fatalf("manifest kind = %q, want static", m.Kind) |
| 235 | } |
| 236 | // The bytes and the stamp agree, straight off the real handler: "live" |
| 237 | // meant the site tree, not a header floating free of any content. |
| 238 | assertServes(t, edgeSrv.URL, "v1", "sha0001") |
| 239 | |
| 240 | // --- run 2: no -channel at all. The app now has exactly one channel, so |
| 241 | // deploy resolves it from the channels API — the instance-less |
| 242 | // equivalent of resolving from an instance record. |
| 243 | if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | buf.Reset() |
| 247 | runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", |
| 248 | "-host", host, "-timeout", "30s", siteDir}) |
| 249 | out = buf.String() |
| 250 | if !strings.Contains(out, "promoted "+app+" sha0002 -> "+channel) { |
| 251 | t.Fatalf("channel was not resolved from the app's one channel:\n%s", out) |
| 252 | } |
| 253 | if !strings.Contains(out, "live https://"+host) { |
| 254 | t.Fatalf("static deploy did not confirm the URL:\n%s", out) |
| 255 | } |
| 256 | if strings.Contains(out, edgeSrv.URL) { |
| 257 | t.Fatalf("deploy printed the test's loopback origin instead of the public URL:\n%s", out) |
| 258 | } |
| 259 | assertServes(t, edgeSrv.URL, "v2", "sha0002") |
| 260 | |
| 261 | checkNoRegistry() |
| 262 | } |
| 263 | |
| 264 | // TestE2EDeployThroughConsole is the full verb: one command takes an |
| 265 | // unshipped binary to a URL that serves it. |
| 266 | func TestE2EDeployThroughConsole(t *testing.T) { |
| 267 | useTempCredentials(t) |
| 268 | clearDeploymentEnv(t) |
| 269 | checkNoRegistry := requireNoRegistryTouch(t) |
| 270 | |
| 271 | const ( |
| 272 | addr = "paul@keymail.dev" |
| 273 | app = "titogo" |
| 274 | channel = "canary/e2e" |
| 275 | version = "abc1234" |
| 276 | label = "e2e rollout" |
| 277 | ) |
| 278 | |
| 279 | tmp := t.TempDir() |
| 280 | relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| 281 | seed, pubB64, err := release.GenerateKey() |
| 282 | if err != nil { |
| 283 | t.Fatal(err) |
| 284 | } |
| 285 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 286 | if err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 290 | if err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| 294 | if err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | t.Cleanup(func() { cst.Close() }) |
| 298 | |
| 299 | var srv *console.Server |
| 300 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 301 | srv.ServeHTTP(w, r) |
| 302 | })) |
| 303 | t.Cleanup(ts.Close) |
| 304 | srv, err = console.NewServer(console.Config{ |
| 305 | Origin: ts.URL, |
| 306 | Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin |
| 307 | Registry: filepath.Join(tmp, "registry.db"), |
| 308 | Releases: relStore, |
| 309 | ReleasePub: pub, |
| 310 | Key: priv, |
| 311 | AppScheme: "http", |
| 312 | AppsDomain: "oncarlos.test", |
| 313 | }, cst) |
| 314 | if err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | |
| 318 | if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") |
| 325 | credFile, err := loadCredentials() |
| 326 | if err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| 330 | if err := credFile.save(); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | |
| 334 | // One instance, on a channel of its own: deploy resolves the promote |
| 335 | // target from THIS record, so nothing in the command line below names a |
| 336 | // channel at all. |
| 337 | host := app + ".bab.oncarlos.test" |
| 338 | buf := captureStdout(t) |
| 339 | runInstancesEnable([]string{"-app", app, "-account", "ops", "-channel", channel}) |
| 340 | runInstancesCreate([]string{"-app", app, "-account", "ops", "-host", host}) |
| 341 | rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host) |
| 342 | if err != nil { |
| 343 | t.Fatalf("record after create: %v", err) |
| 344 | } |
| 345 | if rec.Channel != channel { |
| 346 | t.Fatalf("record channel = %q, want %q", rec.Channel, channel) |
| 347 | } |
| 348 | |
| 349 | // The instance's edge, on loopback. It serves the PREVIOUS version to |
| 350 | // the first poll and the new one after that — so the watch leg is proven |
| 351 | // to keep polling rather than passing on whatever the first response |
| 352 | // happened to be, which is the whole reason the verb waits at all. |
| 353 | var mu sync.Mutex |
| 354 | polls := 0 |
| 355 | probes := []string{} |
| 356 | edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 357 | mu.Lock() |
| 358 | polls++ |
| 359 | probes = append(probes, r.URL.RawQuery) |
| 360 | served := "old0000" |
| 361 | if polls > 1 { |
| 362 | served = version // the adopt pass "landed" between polls |
| 363 | } |
| 364 | mu.Unlock() |
| 365 | w.Header().Set("X-Carlos-Version", served) |
| 366 | })) |
| 367 | t.Cleanup(edgeSrv.Close) |
| 368 | |
| 369 | // The seam (deploy.go): production dials https://<host>; here the one |
| 370 | // host this deploy watches resolves to the loopback edge above. Any |
| 371 | // other host would be the verb watching something it was not asked to. |
| 372 | origOrigin := watchOrigin |
| 373 | watchOrigin = func(h string) string { |
| 374 | if h != host { |
| 375 | t.Errorf("watch dialled an unexpected host %q, want %q", h, host) |
| 376 | } |
| 377 | return edgeSrv.URL |
| 378 | } |
| 379 | t.Cleanup(func() { watchOrigin = origOrigin }) |
| 380 | |
| 381 | artifact := filepath.Join(tmp, "titogo-linux-arm64") |
| 382 | if err := os.WriteFile(artifact, []byte("#!/bin/true\n"), 0o755); err != nil { |
| 383 | t.Fatal(err) |
| 384 | } |
| 385 | |
| 386 | // --- the verb ------------------------------------------------------ |
| 387 | buf.Reset() |
| 388 | runDeploy([]string{"-app", app, "-account", "ops", "-version", version, "-label", label, "-timeout", "30s", artifact}) |
| 389 | out := buf.String() |
| 390 | |
| 391 | // Ship happened, against the bucket, signed — and --label, wired |
| 392 | // straight through to runShipAPI (deploy.go), landed in the manifest |
| 393 | // rather than the silently-passed "" this branch exists to fix |
| 394 | // (discussion #13). |
| 395 | m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, app, version) |
| 396 | if err != nil { |
| 397 | t.Fatalf("manifest after deploy: %v", err) |
| 398 | } |
| 399 | if m.Version != version || m.Kind != "binary" { |
| 400 | t.Fatalf("manifest = %+v, want %s binary", m, version) |
| 401 | } |
| 402 | if m.Label != label { |
| 403 | t.Fatalf("manifest label = %q, want %q — carlos deploy --label is not reaching the release", m.Label, label) |
| 404 | } |
| 405 | // Promote happened, onto the channel the RECORD named — never typed. |
| 406 | ptr, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, app, channel) |
| 407 | if err != nil { |
| 408 | t.Fatalf("channel after deploy: %v", err) |
| 409 | } |
| 410 | if ptr.Version != version { |
| 411 | t.Fatalf("%s points at %q, want %q", channel, ptr.Version, version) |
| 412 | } |
| 413 | // And the watch waited for the edge rather than trusting the promote: |
| 414 | // the first poll's answer was the old version. |
| 415 | mu.Lock() |
| 416 | gotPolls, gotProbes := polls, append([]string{}, probes...) |
| 417 | mu.Unlock() |
| 418 | if gotPolls < 2 { |
| 419 | t.Fatalf("edge polled %d times — the watch passed on a stale answer", gotPolls) |
| 420 | } |
| 421 | for _, q := range gotProbes { |
| 422 | if !strings.Contains(q, "carlos-deploy-probe=") { |
| 423 | t.Fatalf("poll without the cache-bust param: %v", gotProbes) |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | // The three lines an operator reads, in order, and the "live" line names |
| 428 | // the PUBLIC url — not the loopback origin the watch happened to dial. |
| 429 | for _, want := range []string{ |
| 430 | "deploying https://" + host, |
| 431 | "shipped " + app + " " + version, |
| 432 | "promoted " + app + " " + version + " -> " + channel, |
| 433 | "live https://" + host, |
| 434 | } { |
| 435 | if !strings.Contains(out, want) { |
| 436 | t.Fatalf("deploy stdout missing %q:\n%s", want, out) |
| 437 | } |
| 438 | } |
| 439 | if i, j := strings.Index(out, "shipped"), strings.Index(out, "promoted"); i > j { |
| 440 | t.Fatalf("promoted printed before shipped:\n%s", out) |
| 441 | } |
| 442 | if i, j := strings.Index(out, "deploying https://"), strings.Index(out, "shipped"); i < 0 || i > j { |
| 443 | t.Fatalf("deploying line missing or printed after shipped:\n%s", out) |
| 444 | } |
| 445 | if strings.Contains(out, edgeSrv.URL) { |
| 446 | t.Fatalf("deploy printed the test's loopback origin instead of the public URL:\n%s", out) |
| 447 | } |
| 448 | |
| 449 | // A re-run of the same version is the CI-retry case: ship is a no-op, |
| 450 | // promote is a no-op, and the verb still exits 0 with the URL confirmed |
| 451 | // (the edge already serves it, so the first poll passes). |
| 452 | buf.Reset() |
| 453 | runDeploy([]string{"-app", app, "-account", "ops", "-version", version, "-timeout", "30s", artifact}) |
| 454 | rerun := buf.String() |
| 455 | if !strings.Contains(rerun, "already shipped") || !strings.Contains(rerun, "live https://"+host) { |
| 456 | t.Fatalf("re-run of an identical deploy:\n%s", rerun) |
| 457 | } |
| 458 | |
| 459 | // Nothing here may have opened a local registry: this is API mode. |
| 460 | checkNoRegistry() |
| 461 | } |
| 462 | |
| 463 | // TestE2EStaticDeployMintsPlatformHost is #186's static half, end to end: an |
| 464 | // app with no route and no -host gets one. The console, the edge and the |
| 465 | // bucket are all real, so what this proves is not "the CLI sent a request" |
| 466 | // but that the record the mint writes is the record the edge serves from. |
| 467 | // |
| 468 | // The member never names a hostname. That is the whole feature: DNS under |
| 469 | // the apps domain already resolves to the edge and the edge already derives |
| 470 | // its own wildcard cert, so a record was the only missing piece. |
| 471 | func TestE2EStaticDeployMintsPlatformHost(t *testing.T) { |
| 472 | useTempCredentials(t) |
| 473 | clearDeploymentEnv(t) |
| 474 | checkNoRegistry := requireNoRegistryTouch(t) |
| 475 | |
| 476 | const ( |
| 477 | addr = "paul@keymail.dev" |
| 478 | app = "freshly" |
| 479 | channel = "canary/site" |
| 480 | // What the mint must derive on its own: the app's label under the |
| 481 | // account's platform subdomain, ops being "bab". |
| 482 | wantHost = app + ".bab.oncarlos.test" |
| 483 | ) |
| 484 | |
| 485 | tmp := t.TempDir() |
| 486 | relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| 487 | seed, pubB64, err := release.GenerateKey() |
| 488 | if err != nil { |
| 489 | t.Fatal(err) |
| 490 | } |
| 491 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 492 | if err != nil { |
| 493 | t.Fatal(err) |
| 494 | } |
| 495 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 496 | if err != nil { |
| 497 | t.Fatal(err) |
| 498 | } |
| 499 | cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| 500 | if err != nil { |
| 501 | t.Fatal(err) |
| 502 | } |
| 503 | t.Cleanup(func() { cst.Close() }) |
| 504 | |
| 505 | var srv *console.Server |
| 506 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 507 | srv.ServeHTTP(w, r) |
| 508 | })) |
| 509 | t.Cleanup(ts.Close) |
| 510 | srv, err = console.NewServer(console.Config{ |
| 511 | Origin: ts.URL, |
| 512 | Keymail: "http://keymail.invalid", |
| 513 | Registry: filepath.Join(tmp, "registry.db"), |
| 514 | Releases: relStore, |
| 515 | ReleasePub: pub, |
| 516 | Key: priv, |
| 517 | AppScheme: "http", |
| 518 | AppsDomain: "oncarlos.test", |
| 519 | }, cst) |
| 520 | if err != nil { |
| 521 | t.Fatal(err) |
| 522 | } |
| 523 | |
| 524 | if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| 525 | t.Fatal(err) |
| 526 | } |
| 527 | if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { |
| 528 | t.Fatal(err) |
| 529 | } |
| 530 | token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") |
| 531 | credFile, err := loadCredentials() |
| 532 | if err != nil { |
| 533 | t.Fatal(err) |
| 534 | } |
| 535 | credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| 536 | if err := credFile.save(); err != nil { |
| 537 | t.Fatal(err) |
| 538 | } |
| 539 | |
| 540 | edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db")) |
| 541 | if err != nil { |
| 542 | t.Fatal(err) |
| 543 | } |
| 544 | t.Cleanup(func() { edgeReg.Close() }) |
| 545 | ed, err := edge.New(edgeReg, nil) |
| 546 | if err != nil { |
| 547 | t.Fatal(err) |
| 548 | } |
| 549 | ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache")) |
| 550 | ed.SetStaticRecheck(0) |
| 551 | edgeSrv := httptest.NewServer(ed) |
| 552 | t.Cleanup(edgeSrv.Close) |
| 553 | // Standing in for the reconcile pass that materializes the record on a |
| 554 | // box: the same route, on the loopback host the watch dials. |
| 555 | if err := edgeReg.Upsert(registry.Route{ |
| 556 | Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready", |
| 557 | }); err != nil { |
| 558 | t.Fatal(err) |
| 559 | } |
| 560 | if err := ed.Reload(); err != nil { |
| 561 | t.Fatal(err) |
| 562 | } |
| 563 | origOrigin := watchOrigin |
| 564 | watchOrigin = func(h string) string { |
| 565 | if h != wantHost { |
| 566 | t.Errorf("watch dialled %q, want the MINTED host %q", h, wantHost) |
| 567 | } |
| 568 | return edgeSrv.URL |
| 569 | } |
| 570 | t.Cleanup(func() { watchOrigin = origOrigin }) |
| 571 | |
| 572 | siteDir := filepath.Join(tmp, "site") |
| 573 | if err := os.MkdirAll(siteDir, 0o755); err != nil { |
| 574 | t.Fatal(err) |
| 575 | } |
| 576 | if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello"), 0o644); err != nil { |
| 577 | t.Fatal(err) |
| 578 | } |
| 579 | |
| 580 | buf := captureStdout(t) |
| 581 | runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| 582 | "-channel", channel, "-timeout", "30s", siteDir}) |
| 583 | out := buf.String() |
| 584 | for _, wantLine := range []string{ |
| 585 | "minted " + wantHost, |
| 586 | "deploying https://" + wantHost, |
| 587 | "promoted " + app + " sha0001 -> " + channel, |
| 588 | "live https://" + wantHost, |
| 589 | } { |
| 590 | if !strings.Contains(out, wantLine) { |
| 591 | t.Fatalf("mint deploy stdout missing %q:\n%s", wantLine, out) |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | // The record is the deliverable: a box's reconcile pass mints the route |
| 596 | // from THIS, so the kind and the channel are what decide whether the URL |
| 597 | // ever serves anything. |
| 598 | rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, wantHost) |
| 599 | if err != nil { |
| 600 | t.Fatalf("record after mint: %v", err) |
| 601 | } |
| 602 | if rec.Kind != "static" { |
| 603 | t.Fatalf("record Kind = %q, want static", rec.Kind) |
| 604 | } |
| 605 | if rec.Channel != channel { |
| 606 | t.Fatalf("record Channel = %q, want %q — an omitted channel would have written stable", rec.Channel, channel) |
| 607 | } |
| 608 | assertServes(t, edgeSrv.URL, "hello", "sha0001") |
| 609 | |
| 610 | // A REDEPLOY must not mint again: the record is now in the instance |
| 611 | // list, so the host resolves from it. |
| 612 | if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello2"), 0o644); err != nil { |
| 613 | t.Fatal(err) |
| 614 | } |
| 615 | buf.Reset() |
| 616 | runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", |
| 617 | "-timeout", "30s", siteDir}) |
| 618 | out = buf.String() |
| 619 | if strings.Contains(out, "minted") { |
| 620 | t.Fatalf("redeploy minted a second time:\n%s", out) |
| 621 | } |
| 622 | if !strings.Contains(out, "live https://"+wantHost) { |
| 623 | t.Fatalf("redeploy did not resolve the host from the record:\n%s", out) |
| 624 | } |
| 625 | assertServes(t, edgeSrv.URL, "hello2", "sha0002") |
| 626 | |
| 627 | checkNoRegistry() |
| 628 | } |
| 629 | |