carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// End-to-end 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.
27package main
28
29import (
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.
52func 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).
77func 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.
266func 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.
471func 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