carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "flag"
9 "fmt"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "testing"
20 "time"
21)
22
23func TestResolveDeployChannel(t *testing.T) {
24 recs := []apiInstanceRecord{
25 {Host: "a.test", Channel: "canary/dev"},
26 {Host: "b.test", Channel: "canary/dev"},
27 }
28 ch, hosts, err := resolveDeployChannel(recs, "", "")
29 if err != nil || ch != "canary/dev" || len(hosts) != 2 {
30 t.Fatalf("%q %v %v", ch, hosts, err)
31 }
32 recs[1].Channel = "edge"
33 if _, _, err := resolveDeployChannel(recs, "", ""); err == nil {
34 t.Fatal("two channels with no --channel/--host must error")
35 }
36 ch, hosts, err = resolveDeployChannel(recs, "b.test", "")
37 if err != nil || ch != "edge" || len(hosts) != 1 || hosts[0] != "b.test" {
38 t.Fatalf("host-scoped: %q %v %v", ch, hosts, err)
39 }
40 if _, _, err := resolveDeployChannel(nil, "", ""); err == nil {
41 t.Fatal("no instances, no pipeline entry must error (hint: carlos instances create)")
42 }
43 // A pipelined app (release pipelines v2, Task 11): zero records resolves
44 // to the pipeline's entry channel instead of erroring, with nothing to
45 // watch yet.
46 ch, hosts, err = resolveDeployChannel(nil, "", "edge")
47 if err != nil || ch != "edge" || len(hosts) != 0 {
48 t.Fatalf("no instances, pipeline entry edge: %q %v %v, want it resolved with no error", ch, hosts, err)
49 }
50}
51
52// TestFilterChannelHosts pins the -channel+-host validation fix (task-9
53// review, finding 3): a -host that names no instance at all must error —
54// not silently produce zero hosts, which would let deploy ship and promote
55// against a typo'd host and only then shrug "no instance follows <channel>",
56// blaming the channel and never naming the host. A host that DOES exist but
57// simply isn't on the requested channel is not an error (the caller may be
58// pointing a channel at a host for the first time).
59func TestFilterChannelHosts(t *testing.T) {
60 recs := []apiInstanceRecord{
61 {Host: "a.test", Channel: "edge"},
62 {Host: "b.test", Channel: "beta"},
63 }
64 hosts, err := filterChannelHosts(recs, "edge", "")
65 if err != nil || len(hosts) != 1 || hosts[0] != "a.test" {
66 t.Fatalf("unscoped: %v %v", hosts, err)
67 }
68 hosts, err = filterChannelHosts(recs, "edge", "a.test")
69 if err != nil || len(hosts) != 1 || hosts[0] != "a.test" {
70 t.Fatalf("host-scoped, matching channel: %v %v", hosts, err)
71 }
72 hosts, err = filterChannelHosts(recs, "edge", "b.test")
73 if err != nil {
74 t.Fatalf("an existing host on a different channel must not error: %v", err)
75 }
76 if len(hosts) != 0 {
77 t.Fatalf("b.test follows beta, not edge — must not appear: %v", hosts)
78 }
79 if _, err := filterChannelHosts(recs, "edge", "typo.test"); err == nil {
80 t.Fatal("a --host naming no instance at all must error")
81 } else if !strings.Contains(err.Error(), "typo.test") {
82 t.Fatalf("error must name the offending host: %v", err)
83 }
84}
85
86// TestStaticDeployHosts pins the host resolution (issue #112, extended by
87// #82's kind-aware gate): -host names one explicitly; without it the app's
88// own static records are the watch targets (each names a host an earlier
89// attach wrote); with neither, the deploy is refused — BEFORE ship and
90// promote run, since afterwards the only honest report left is "promoted,
91// nothing to watch".
92func TestStaticDeployHosts(t *testing.T) {
93 hosts, err := staticDeployHosts("carlosframework.com", nil)
94 if err != nil || len(hosts) != 1 || hosts[0] != "carlosframework.com" {
95 t.Fatalf("with --host: %v %v", hosts, err)
96 }
97 recs := []apiInstanceRecord{{Host: "a.example.test", Kind: "static"}, {Host: "b.example.test", Kind: "static"}}
98 hosts, err = staticDeployHosts("", recs)
99 if err != nil || len(hosts) != 2 || hosts[0] != "a.example.test" || hosts[1] != "b.example.test" {
100 t.Fatalf("no --host, records present: %v %v, want both record hosts watched", hosts, err)
101 }
102 _, err = staticDeployHosts("", nil)
103 if err == nil {
104 t.Fatal("an app with no records and no --host must be refused")
105 }
106 for _, want := range []string{
107 "no instance records",
108 "--host carlosframework.com",
109 } {
110 if !strings.Contains(err.Error(), want) {
111 t.Fatalf("refusal must mention %q:\n%s", want, err)
112 }
113 }
114}
115
116// TestStaticDeployChannel: one channel resolves silently; zero and several
117// are both refused, naming what was found and the flag that settles it.
118func TestStaticDeployChannel(t *testing.T) {
119 ch, err := staticDeployChannel([]apiChannel{{Channel: "canary/site"}})
120 if err != nil || ch != "canary/site" {
121 t.Fatalf("single channel: %q %v", ch, err)
122 }
123 _, err = staticDeployChannel(nil)
124 if err == nil || !strings.Contains(err.Error(), "-channel") {
125 t.Fatalf("no channels must be refused, asking for --channel: %v", err)
126 }
127 // A single channel the console could not verify is NOT a resolution: the
128 // channels endpoint reports it as a row with an error, and promoting onto
129 // it would be this verb writing over a pointer nobody can read.
130 _, err = staticDeployChannel([]apiChannel{{Channel: "canary/site", Err: "bad signature"}})
131 if err == nil {
132 t.Fatal("a single UNREADABLE channel must not resolve")
133 }
134 for _, want := range []string{"canary/site", "bad signature", "-channel"} {
135 if !strings.Contains(err.Error(), want) {
136 t.Fatalf("broken-channel refusal must mention %q:\n%s", want, err)
137 }
138 }
139 _, err = staticDeployChannel([]apiChannel{{Channel: "stable"}, {Channel: "canary/site"}})
140 if err == nil {
141 t.Fatal("several channels with no instances must be refused")
142 }
143 for _, want := range []string{"-channel", "canary/site, stable"} {
144 if !strings.Contains(err.Error(), want) {
145 t.Fatalf("refusal must mention %q (sorted, both listed):\n%s", want, err)
146 }
147 }
148}
149
150// TestDefaultDeployTimeoutOutlastsAStaticRecheck pins the one thing that
151// keeps a static deploy from failing on the clock rather than on the facts: a
152// static route trusts its resolved channel pointer for a whole recheck window
153// (internal/edge/static.go), and the deploy probe's cache-bust does not
154// shorten that — it defeats the edge's HTTP response cache, not the handler's
155// trust window. So the DEFAULT watch has to outlast one window with room for
156// the pointer read, the extract and a poll interval. Lower the default below
157// that and this test says so, instead of production saying "timed out" about a
158// deploy that had already landed.
159func TestDefaultDeployTimeoutOutlastsAStaticRecheck(t *testing.T) {
160 if min := staticRecheckWindow + 30*time.Second; defaultDeployTimeout < min {
161 t.Fatalf("defaultDeployTimeout %s must be at least %s (one %s recheck window plus slack)",
162 defaultDeployTimeout, min, staticRecheckWindow)
163 }
164}
165
166// TestDeployTargetsStaticVsBinary pins the kind gate. Zero instance records
167// mean two different things, and only ONE of them is the static shape:
168//
169// - static: no records by design — -host is required, and the channel comes
170// from -channel or the app's single channel.
171// - binary: records just haven't been created yet — the pre-existing
172// "promoted, nothing to watch" path, which must not grow a -host demand
173// or a static-flavoured watch line.
174func TestDeployTargetsStaticVsBinary(t *testing.T) {
175 noChannels := func() (apiChannelsResult, error) {
176 t.Helper()
177 t.Error("the channels API was consulted on a path that must not need it")
178 return apiChannelsResult{}, nil
179 }
180 oneChannel := func() (apiChannelsResult, error) {
181 return apiChannelsResult{Channels: []apiChannel{{Channel: "canary/site"}}}, nil
182 }
183 // legacyNoPipeline is consulted (unlike noChannels above), but answers
184 // with no channels and no pipeline — the "no instances + NO pipeline"
185 // case, whose refusal must stay byte-identical to before Task 11.
186 legacyNoPipeline := func() (apiChannelsResult, error) {
187 return apiChannelsResult{}, nil
188 }
189
190 // Static, no instances: channel resolved from the app's one channel.
191 ch, hosts, static, err := deployTargets(nil, "static", "", "site.test", oneChannel)
192 if err != nil || ch != "canary/site" || len(hosts) != 1 || hosts[0] != "site.test" || !static {
193 t.Fatalf("static/no-instances: %q %v %v %v", ch, hosts, static, err)
194 }
195 // Static, no instances, explicit -channel: the channels API is not needed.
196 ch, _, static, err = deployTargets(nil, "static", "canary/other", "site.test", noChannels)
197 if err != nil || ch != "canary/other" || !static {
198 t.Fatalf("static/--channel: %q %v %v", ch, static, err)
199 }
200 // Static, no instances, no -host: refused.
201 if _, _, _, err = deployTargets(nil, "static", "canary/site", "", noChannels); err == nil {
202 t.Fatal("static with no instances and no --host must be refused")
203 }
204
205 // BINARY, no instances, explicit -channel: unchanged from before #112 —
206 // no -host demanded, no channels lookup, no static treatment, and zero
207 // hosts so runDeploy prints "promoted, nothing to watch" and exits 0.
208 ch, hosts, static, err = deployTargets(nil, "binary", "canary/dev", "", noChannels)
209 if err != nil {
210 t.Fatalf("binary/no-instances/--channel must not error: %v", err)
211 }
212 if ch != "canary/dev" || len(hosts) != 0 || static {
213 t.Fatalf("binary/no-instances/--channel: %q %v static=%v", ch, hosts, static)
214 }
215 // BINARY, no instances, no -channel, NO pipeline: still the old "create
216 // one" refusal, never the static one — the channels API IS now
217 // consulted (release pipelines v2, Task 11 — see
218 // TestDeployTargetsNoInstancesPipelineEntry for the pipelined case), but
219 // an app with no declared pipeline gets today's error text unchanged.
220 _, _, static, err = deployTargets(nil, "binary", "", "", legacyNoPipeline)
221 if err == nil || !strings.Contains(err.Error(), "carlos instances create") {
222 t.Fatalf("binary/no-instances must keep its own refusal: %v", err)
223 }
224 if strings.Contains(err.Error(), "static site") {
225 t.Fatalf("a binary app must never be told about static sites: %v", err)
226 }
227 if static {
228 t.Fatal("binary must never take the static path")
229 }
230
231 // With records, kind is irrelevant: the instance path is byte-identical
232 // for both kinds, and the channels API is not needed either way.
233 recs := []apiInstanceRecord{{Host: "a.test", Channel: "canary/dev"}}
234 for _, kind := range []string{"binary", "static"} {
235 ch, hosts, static, err = deployTargets(recs, kind, "", "", noChannels)
236 if err != nil || ch != "canary/dev" || len(hosts) != 1 || hosts[0] != "a.test" || static {
237 t.Fatalf("%s with records: %q %v %v %v", kind, ch, hosts, static, err)
238 }
239 }
240}
241
242// TestDeployTargetsNoInstancesPipelineEntry pins the pipeline-entry fallback
243// itself (release pipelines v2, Task 11, spec §2's "the entry channel is
244// where deploy lands"): a brand-new app with no instance records and no
245// resolvable channel — no explicit -channel, no existing channel rows —
246// resolves straight to the pipeline's entry channel instead of erroring, on
247// BOTH the static and the non-static (binary/instance) path. The ambiguity
248// error and -channel override behaviour are otherwise untouched (pinned by
249// TestDeployTargetsStaticVsBinary/TestStaticDeployChannel/
250// TestResolveDeployChannel).
251func TestDeployTargetsNoInstancesPipelineEntry(t *testing.T) {
252 pipelined := func() (apiChannelsResult, error) {
253 return apiChannelsResult{PipelineEntry: "edge", PipelineChannels: []string{"edge", "production"}}, nil
254 }
255
256 // Non-static (binary/instance) path: no records, no -channel, a
257 // pipelined app — resolves to the entry channel, no error, and (like
258 // the pre-existing no-instances shape) nothing to watch yet.
259 ch, hosts, static, err := deployTargets(nil, "binary", "", "", pipelined)
260 if err != nil {
261 t.Fatalf("binary/no-instances/pipelined must not error: %v", err)
262 }
263 if ch != "edge" || len(hosts) != 0 || static {
264 t.Fatalf("binary/no-instances/pipelined = %q %v static=%v, want the entry channel edge", ch, hosts, static)
265 }
266
267 // Static path: no records, -host given (required on the static path
268 // regardless), no -channel, a pipelined app whose channels listing is
269 // empty (nothing has ever been promoted onto it yet) — resolves to the
270 // entry channel rather than staticDeployChannel's "no channels yet"
271 // refusal.
272 ch, hosts, static, err = deployTargets(nil, "static", "", "site.test", pipelined)
273 if err != nil {
274 t.Fatalf("static/no-instances/pipelined must not error: %v", err)
275 }
276 if ch != "edge" || len(hosts) != 1 || hosts[0] != "site.test" || !static {
277 t.Fatalf("static/no-instances/pipelined = %q %v static=%v, want the entry channel edge, host watched", ch, hosts, static)
278 }
279}
280
281func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) {
282 var mu sync.Mutex
283 ver := "old1234"
284 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285 mu.Lock()
286 w.Header().Set("X-Carlos-Version", ver)
287 mu.Unlock()
288 w.WriteHeader(http.StatusOK) // the 200 path this test pins stays unchanged
289 }))
290 defer srv.Close()
291 go func() { time.Sleep(30 * time.Millisecond); mu.Lock(); ver = "new5678"; mu.Unlock() }()
292 var out bytes.Buffer
293 err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
294 2*time.Second, 10*time.Millisecond, nil)
295 if err != nil {
296 t.Fatal(err)
297 }
298 if !strings.Contains(out.String(), "x.test") {
299 t.Fatalf("live line missing: %q", out.String())
300 }
301}
302
303func TestWatchVersionTimeoutDiagnostics(t *testing.T) {
304 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
305 w.Header().Set("X-Carlos-Version", "old1234")
306 }))
307 defer srv.Close()
308 var out bytes.Buffer
309 err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
310 50*time.Millisecond, 10*time.Millisecond, nil)
311 if err == nil {
312 t.Fatal("must time out")
313 }
314 if !strings.Contains(err.Error(), "old1234") {
315 t.Fatalf("diagnostic must name the version still serving: %v", err)
316 }
317}
318
319// TestWatchVersionMatchingHeaderNon2xxNotLive pins the fix: the edge stamps
320// X-Carlos-Version before proxying, so a tenant that dies on boot can answer
321// with the new version header attached to a 502. Matching the header alone
322// used to report that as "live" and exit 0 on a crash-looping deploy — the
323// success arm must gate on status too.
324func TestWatchVersionMatchingHeaderNon2xxNotLive(t *testing.T) {
325 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326 w.Header().Set("X-Carlos-Version", "new5678")
327 w.WriteHeader(http.StatusBadGateway)
328 }))
329 defer srv.Close()
330 var out bytes.Buffer
331 err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
332 50*time.Millisecond, 10*time.Millisecond, nil)
333 if err == nil {
334 t.Fatal("a 502 carrying the new version header must NOT be reported live")
335 }
336 if strings.Contains(out.String(), "live") {
337 t.Fatalf("must not print live: %q", out.String())
338 }
339 if !strings.Contains(err.Error(), "serving new5678 but HTTP 502") {
340 t.Fatalf("diagnostic must name the version and the failing status: %v", err)
341 }
342}
343
344// TestWatchVersionNoHeaderDiagnostic: 2xx with no header is the ONE shape
345// that really means "this edge predates X-Carlos-Version" — something is
346// being served here, it just isn't stamped.
347func TestWatchVersionNoHeaderDiagnostic(t *testing.T) {
348 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
349 defer srv.Close()
350 var out bytes.Buffer
351 err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
352 50*time.Millisecond, 10*time.Millisecond, nil)
353 if err == nil || !strings.Contains(err.Error(), "no X-Carlos-Version") {
354 t.Fatalf("old-edge diagnostic: %v", err)
355 }
356}
357
358// TestWatchVersionNonOKNoHeaderDiagnostic pins the split (review finding 2):
359// an activator 503 (wake failed) and a 404 (route not minted yet) also arrive
360// with no X-Carlos-Version, and calling those "the edge predates this
361// feature — the hourly pass will adopt it" sends the operator off to wait an
362// hour for a pass that cannot help. A non-2xx must name its status code and
363// the two things that actually produce it.
364func TestWatchVersionNonOKNoHeaderDiagnostic(t *testing.T) {
365 for _, code := range []int{http.StatusServiceUnavailable, http.StatusNotFound} {
366 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
367 w.WriteHeader(code)
368 }))
369 var out bytes.Buffer
370 err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
371 50*time.Millisecond, 10*time.Millisecond, nil)
372 srv.Close()
373 if err == nil {
374 t.Fatalf("HTTP %d: must time out", code)
375 }
376 if !strings.Contains(err.Error(), fmt.Sprintf("not serving (HTTP %d)", code)) {
377 t.Fatalf("HTTP %d diagnostic must name the status: %v", code, err)
378 }
379 if strings.Contains(err.Error(), "predates this feature") {
380 t.Fatalf("HTTP %d must not be reported as an old edge: %v", code, err)
381 }
382 }
383}
384
385// TestWatchVersionSweepsTargetsConcurrently pins the concurrency fix: with N
386// targets, a single tick must probe them all in parallel, not one after
387// another. Each origin bumps a counter shared across all three servers on
388// entry and drops it on exit, tracking the high-water mark: sequential
389// probing can never push that mark above 1, while true concurrency pushes it
390// to len(targets). Asserting on the high-water mark rather than a wall-clock
391// budget means CI scheduler noise can slow the run down without flaking the
392// test — only true sequential probing can fail it. The 100ms sleep just
393// needs to be long enough that three goroutines' Gets overlap inside it.
394func TestWatchVersionSweepsTargetsConcurrently(t *testing.T) {
395 const delay = 100 * time.Millisecond
396 var inFlight atomic.Int64
397 var maxInFlight atomic.Int64
398 newSrv := func() *httptest.Server {
399 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
400 n := inFlight.Add(1)
401 for {
402 cur := maxInFlight.Load()
403 if n <= cur || maxInFlight.CompareAndSwap(cur, n) {
404 break
405 }
406 }
407 time.Sleep(delay)
408 inFlight.Add(-1)
409 w.Header().Set("X-Carlos-Version", "new5678")
410 }))
411 }
412 srvs := []*httptest.Server{newSrv(), newSrv(), newSrv()}
413 defer func() {
414 for _, s := range srvs {
415 s.Close()
416 }
417 }()
418 targets := make([]watchTarget, len(srvs))
419 for i, s := range srvs {
420 targets[i] = watchTarget{Host: fmt.Sprintf("h%d.test", i), Origin: s.URL}
421 }
422 var out bytes.Buffer
423 err := watchVersion(&out, targets, "new5678", 5*time.Second, 10*time.Millisecond, nil)
424 if err != nil {
425 t.Fatal(err)
426 }
427 if got := maxInFlight.Load(); got != int64(len(targets)) {
428 t.Fatalf("max in-flight = %d, want %d — targets were probed sequentially, not concurrently", got, len(targets))
429 }
430 for _, target := range targets {
431 if !strings.Contains(out.String(), target.Host) {
432 t.Fatalf("live line missing for %s: %q", target.Host, out.String())
433 }
434 }
435}
436
437// TestWatchVersionCacheBustsEveryPoll pins the fix for the edge's up-to-5min
438// cache TTL on X-Carlos-Version (internal/edge/edge.go, versionHeader): a
439// watch with no cache-bust could poll the same cached response for the whole
440// default 120s timeout and false-FAIL a deploy that already converged. Every
441// poll must carry a query parameter that differs from the previous one, so
442// the edge cache never serves a stale hit back to the watch.
443func TestWatchVersionCacheBustsEveryPoll(t *testing.T) {
444 var mu sync.Mutex
445 var queries []string
446 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447 mu.Lock()
448 queries = append(queries, r.URL.RawQuery)
449 mu.Unlock()
450 w.Header().Set("X-Carlos-Version", "old1234")
451 }))
452 defer srv.Close()
453 var out bytes.Buffer
454 _ = watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
455 35*time.Millisecond, 10*time.Millisecond, nil)
456
457 mu.Lock()
458 defer mu.Unlock()
459 if len(queries) < 2 {
460 t.Fatalf("expected multiple polls against a version that never matches, got %d: %v", len(queries), queries)
461 }
462 for _, q := range queries {
463 if q == "" {
464 t.Fatalf("poll missing cache-bust query param: %v", queries)
465 }
466 }
467 if queries[0] == queries[1] {
468 t.Fatalf("cache-bust param did not vary between polls: %v", queries)
469 }
470}
471
472// TestDeployingLine pins the destination line deploy prints before ship:
473// the one place every later path (held, watch, even a ship failure) gets
474// its "where is this going" from. Zero hosts is the no-instance path,
475// which keeps its own honest "nothing to watch" message instead.
476func TestDeployingLine(t *testing.T) {
477 cases := []struct {
478 name string
479 hosts []string
480 channel string
481 want string
482 }{
483 {"no hosts", nil, "stable", ""},
484 {"one host", []string{"hello.baf.oncarlos.com"}, "stable",
485 "deploying https://hello.baf.oncarlos.com"},
486 {"pair", []string{"a.example.com", "b.example.com"}, "stable", "deploying 2 instances on stable"},
487 {"fleet", []string{"a.example.com", "b.example.com", "c.example.com"}, "stable",
488 "deploying 3 instances on stable"},
489 }
490 for _, c := range cases {
491 if got := deployingLine(c.hosts, c.channel); got != c.want {
492 t.Errorf("%s: deployingLine = %q, want %q", c.name, got, c.want)
493 }
494 }
495}
496
497// TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint (issue #131's tail):
498// the timeout report used to end with a hint that could not help — the wake
499// reason was journal-only and the suggested command was the box-local form.
500// With diagnostics wired, the report carries the host's last platform-stream
501// error and names the command that shows the whole timeline.
502func TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint(t *testing.T) {
503 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
504 w.WriteHeader(http.StatusServiceUnavailable)
505 }))
506 defer srv.Close()
507 var out bytes.Buffer
508 var asked []string
509 diag := &watchDiagnostics{
510 wakeReason: func(host string) string {
511 asked = append(asked, host)
512 return "wake failed: nothing is promoted for this instance's channel"
513 },
514 logsHint: "carlos logs --app ledgerpay --account bde --stream platform",
515 }
516 err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
517 50*time.Millisecond, 10*time.Millisecond, diag)
518 if err == nil {
519 t.Fatal("watch should have timed out")
520 }
521 for _, want := range []string{
522 "wake failed: nothing is promoted",
523 "full timeline: carlos logs --app ledgerpay --account bde --stream platform",
524 } {
525 if !strings.Contains(err.Error(), want) {
526 t.Fatalf("timeout report missing %q:\n%v", want, err)
527 }
528 }
529 if len(asked) != 1 || asked[0] != "x.test" {
530 t.Fatalf("wakeReason asked for %v, want the one pending host", asked)
531 }
532 // A reason lookup that finds nothing adds nothing — no empty line.
533 diag.wakeReason = func(string) string { return "" }
534 err = watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
535 50*time.Millisecond, 10*time.Millisecond, diag)
536 if err == nil || strings.Contains(err.Error(), "\n \n") {
537 t.Fatalf("empty reason printed a blank line: %v", err)
538 }
539}
540
541// ---- domain attach (Task 6, #82/#151) --------------------------------
542//
543// The server half of the wire contract below does not exist yet (Task 8
544// builds it, gated on #151 landing); these tests pin what THIS client sends
545// and expects against a hand-rolled fake console, exactly like
546// TestAPISessionRestartWireShape (restart_test.go) does for a door that
547// already shipped.
548
549// TestAPISessionDomainListAndAttachWireShape pins the wire shape the
550// console door (apidomains.go, #151's contract) answers: GET .../domains
551// for the list (rows carry dns_status/guidance), POST .../domains with a
552// {"host","kind","channel"} body for attach, decoding a 200 into
553// (created, resolvedRoute).
554func TestAPISessionDomainListAndAttachWireShape(t *testing.T) {
555 var gotListPath string
556 var gotAttachPath, gotAttachMethod, gotAttachBody string
557 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
558 switch {
559 case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/domains"):
560 gotListPath = r.URL.RequestURI()
561 json.NewEncoder(w).Encode(map[string]any{
562 "domains": []map[string]string{{"host": "existing.example.test", "route": "static", "status": "live", "dns_status": "pointing"}},
563 })
564 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"):
565 gotAttachPath, gotAttachMethod = r.URL.RequestURI(), r.Method
566 b, _ := io.ReadAll(r.Body)
567 gotAttachBody = string(b)
568 json.NewEncoder(w).Encode(map[string]any{"host": "fresh.example.test", "route": "fresh.example.test", "created": true})
569 default:
570 http.NotFound(w, r)
571 }
572 }))
573 defer ts.Close()
574
575 sess := newTestSession(ts.URL)
576 domains, err := sess.domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
577 if err != nil {
578 t.Fatalf("domainList: %v", err)
579 }
580 if gotListPath != "/api/cli/apps/ops/sitely/domains" {
581 t.Fatalf("list path = %s", gotListPath)
582 }
583 if len(domains) != 1 || domains[0].Host != "existing.example.test" || domains[0].DNSStatus != "pointing" {
584 t.Fatalf("domains = %+v", domains)
585 }
586
587 created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely",
588 "fresh.example.test", "", "static", "canary/live", nil, nil, "publish the new site")
589 if err != nil {
590 t.Fatalf("domainAttach: %v", err)
591 }
592 if gotAttachMethod != http.MethodPost {
593 t.Fatalf("method = %s, want POST", gotAttachMethod)
594 }
595 if gotAttachPath != "/api/cli/apps/ops/sitely/domains" {
596 t.Fatalf("path = %s", gotAttachPath)
597 }
598 if !strings.Contains(gotAttachBody, `"host":"fresh.example.test"`) ||
599 !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) ||
600 !strings.Contains(gotAttachBody, `"message":"publish the new site"`) {
601 t.Fatalf("body = %s, want host+kind+channel+message", gotAttachBody)
602 }
603 if strings.Contains(gotAttachBody, `"route"`) {
604 t.Fatalf("body = %s, empty route must be omitted", gotAttachBody)
605 }
606 if !created || resolvedRoute != "fresh.example.test" {
607 t.Fatalf("attach result = created %v route %q", created, resolvedRoute)
608 }
609}
610
611// TestAPISessionDomainAttachRefusalSurfacesVerbatim pins the two refusal
612// shapes the wire contract names: 409 "hostname unavailable" (a host
613// already held — the binding note deploy's own gate exists to avoid ever
614// triggering) and a 422 policy refusal. Both must reach the caller in the
615// console's own words, via apiErrorFromBody, never rewritten.
616func TestAPISessionDomainAttachRefusalSurfacesVerbatim(t *testing.T) {
617 for _, tc := range []struct {
618 status int
619 msg string
620 }{
621 {http.StatusConflict, "hostname unavailable"},
622 {http.StatusUnprocessableEntity, "not a usable hostname"},
623 } {
624 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
625 w.WriteHeader(tc.status)
626 json.NewEncoder(w).Encode(map[string]string{"error": tc.msg})
627 }))
628 _, _, err := newTestSession(ts.URL).domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely",
629 "x.test", "", "static", "canary/live", nil, nil)
630 ts.Close()
631 if err == nil || !strings.Contains(err.Error(), tc.msg) {
632 t.Fatalf("status %d: err = %v, want %q verbatim", tc.status, err, tc.msg)
633 }
634 }
635}
636
637// TestDomainNeedsAttach pins the gate itself, independent of any HTTP call:
638// a host answered by an existing instance record, or already named in
639// domainList, must never be attached again.
640func TestDomainNeedsAttach(t *testing.T) {
641 recs := []apiInstanceRecord{{Host: "has-instance.test"}}
642 domains := []apiDomainRecord{{Host: "already-claimed.test"}}
643 cases := []struct {
644 host string
645 want bool
646 }{
647 {"fresh.test", true},
648 {"has-instance.test", false},
649 {"already-claimed.test", false},
650 }
651 for _, c := range cases {
652 if got := domainNeedsAttach(c.host, recs, domains); got != c.want {
653 t.Errorf("domainNeedsAttach(%q) = %v, want %v", c.host, got, c.want)
654 }
655 }
656}
657
658// deployFakeConsole is a minimal console double for `carlos deploy`'s own
659// tests: only the doors deploy itself calls (instances, domains, releases,
660// promote) — not internal/console's real handlers, since the CLI-facing
661// domains door does not exist on this branch yet (Task 8 builds it,
662// integrating against #151's real one). calls records every hit, in order,
663// so a test can assert attach happened before ship rather than merely that
664// it happened at all.
665type deployFakeConsole struct {
666 shipMessage, promoteMessage string
667 ts *httptest.Server
668
669 mu sync.Mutex
670 calls []string // "METHOD /path", in hit order
671 domains []map[string]string
672 instances []apiInstanceRecord // nil = the app has no records
673 shipVersion string
674 domainsListStatus int // 0 = normal 200 JSON list; else this status with a plain (non-JSON) body — simulates an old console (404) or an unwell one (5xx)
675 attachStatus int // 0 = normal 201; else this status with an {"error": attachErrMsg} body
676 attachErrMsg string
677 // attachRouteOverride, when non-empty, is the "route" the POST /domains
678 // response names instead of echoing the attached host — simulating the
679 // door aliasing this host onto an existing route rather than minting it
680 // as a first route, the review#1 case attachUnclaimedHost must read off
681 // the door's own answer rather than assume.
682 attachRouteOverride string
683
684 // shipProbe is the app's build_probe, echoed by the ship door exactly as
685 // handleAPIShip echoes it. "" means the app declares none and the key is
686 // absent from the response — which is also what every console built
687 // before the field existed answers.
688 shipProbe string
689
690 // promoteProposed, when true, makes POST .../promote answer 202 (a
691 // guarded channel's proposal shape, release pipelines v2 §4) instead of
692 // the normal 200 — the RIDER case, Task 11.
693 promoteProposed bool
694}
695
696func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
697 t.Helper()
698 fc := &deployFakeConsole{shipVersion: shipVersion}
699 mux := http.NewServeMux()
700 mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", func(w http.ResponseWriter, r *http.Request) {
701 fc.record(r)
702 fc.mu.Lock()
703 recs := fc.instances
704 fc.mu.Unlock()
705 if recs == nil {
706 recs = []apiInstanceRecord{}
707 }
708 json.NewEncoder(w).Encode(map[string]any{"instances": recs})
709 })
710 mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) {
711 fc.record(r)
712 fc.mu.Lock()
713 domains, status := fc.domains, fc.domainsListStatus
714 fc.mu.Unlock()
715 if status != 0 {
716 // A plain body with no JSON "error" field, on purpose: this is
717 // what an unregistered route (old console) OR a bare 5xx (an
718 // unwell one) both look like on the wire — neither carries a
719 // server diagnosis, which is exactly the shape apiErrorFromBody
720 // treats as "not a real diagnosis, just a status".
721 w.WriteHeader(status)
722 return
723 }
724 json.NewEncoder(w).Encode(map[string]any{"domains": domains})
725 })
726 mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) {
727 fc.record(r)
728 fc.mu.Lock()
729 status, errMsg, routeOverride := fc.attachStatus, fc.attachErrMsg, fc.attachRouteOverride
730 fc.mu.Unlock()
731 if status != 0 {
732 w.WriteHeader(status)
733 json.NewEncoder(w).Encode(map[string]string{"error": errMsg})
734 return
735 }
736 var in struct {
737 Host string `json:"host"`
738 }
739 json.NewDecoder(r.Body).Decode(&in)
740 route := in.Host
741 if routeOverride != "" {
742 route = routeOverride
743 }
744 json.NewEncoder(w).Encode(map[string]any{"host": in.Host, "route": route, "created": true})
745 })
746 mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) {
747 fc.record(r)
748 mr, err := r.MultipartReader()
749 if err != nil {
750 t.Error(err)
751 return
752 }
753 part, err := mr.NextPart()
754 if err != nil {
755 t.Error(err)
756 return
757 }
758 var meta struct {
759 Message string `json:"message"`
760 }
761 if err := json.NewDecoder(part).Decode(&meta); err != nil {
762 t.Error(err)
763 return
764 }
765 fc.mu.Lock()
766 fc.shipMessage = meta.Message
767 fc.mu.Unlock()
768 fc.mu.Lock()
769 ver, probe := fc.shipVersion, fc.shipProbe
770 fc.mu.Unlock()
771 w.WriteHeader(http.StatusCreated)
772 body := map[string]string{"version": ver}
773 if probe != "" {
774 body["build_probe"] = probe
775 }
776 json.NewEncoder(w).Encode(body)
777 })
778 mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) {
779 fc.record(r)
780 var input struct {
781 Message string `json:"message"`
782 }
783 if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
784 t.Error(err)
785 return
786 }
787 fc.mu.Lock()
788 fc.promoteMessage = input.Message
789 fc.mu.Unlock()
790 fc.mu.Lock()
791 proposed := fc.promoteProposed
792 fc.mu.Unlock()
793 if proposed {
794 w.WriteHeader(http.StatusAccepted)
795 json.NewEncoder(w).Encode(map[string]any{
796 "result": "proposed", "pending_id": "pend-1", "needed": 2, "confirmed": 0,
797 "console_url": "https://console.example.test/a/ops/apps/sitely/pending/pend-1",
798 })
799 return
800 }
801 json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false})
802 })
803 fc.ts = httptest.NewServer(mux)
804 t.Cleanup(fc.ts.Close)
805 return fc
806}
807
808// setDomains is the mutex-guarded way to change what the fake's GET
809// .../domains answers once the server is already live (t.Setenv-style
810// direct field writes are only safe before the httptest.Server starts
811// serving requests).
812func (fc *deployFakeConsole) setDomains(domains []map[string]string) {
813 fc.mu.Lock()
814 defer fc.mu.Unlock()
815 fc.domains = domains
816}
817
818func (fc *deployFakeConsole) setInstances(recs []apiInstanceRecord) {
819 fc.mu.Lock()
820 defer fc.mu.Unlock()
821 fc.instances = recs
822}
823
824func (fc *deployFakeConsole) setDomainsListStatus(status int) {
825 fc.mu.Lock()
826 defer fc.mu.Unlock()
827 fc.domainsListStatus = status
828}
829
830func (fc *deployFakeConsole) setAttachRefusal(status int, msg string) {
831 fc.mu.Lock()
832 defer fc.mu.Unlock()
833 fc.attachStatus, fc.attachErrMsg = status, msg
834}
835
836func (fc *deployFakeConsole) setShipProbe(probe string) {
837 fc.mu.Lock()
838 defer fc.mu.Unlock()
839 fc.shipProbe = probe
840}
841
842func (fc *deployFakeConsole) setAttachRouteOverride(route string) {
843 fc.mu.Lock()
844 defer fc.mu.Unlock()
845 fc.attachRouteOverride = route
846}
847
848func (fc *deployFakeConsole) setPromoteProposed(proposed bool) {
849 fc.mu.Lock()
850 defer fc.mu.Unlock()
851 fc.promoteProposed = proposed
852}
853
854func (fc *deployFakeConsole) record(r *http.Request) {
855 fc.mu.Lock()
856 defer fc.mu.Unlock()
857 fc.calls = append(fc.calls, r.Method+" "+r.URL.Path)
858}
859
860// callIndex returns the index of the first recorded call containing sub, or
861// -1 — used both to assert a call happened and, via two calls, that one
862// happened before another.
863func (fc *deployFakeConsole) callIndex(sub string) int {
864 fc.mu.Lock()
865 defer fc.mu.Unlock()
866 for i, c := range fc.calls {
867 if strings.Contains(c, sub) {
868 return i
869 }
870 }
871 return -1
872}
873
874// TestDeployAutoAttachesUnclaimedStaticHost pins the auto-attach behaviour
875// end to end: a -host deploy against a fresh static target (no instance
876// record, no domainList entry) attaches the host BEFORE shipping and says
877// so; a repeated deploy against the SAME host, once domainList already
878// names it, must not re-POST attach at all — the binding note's 409 "host
879// unavailable" is what a re-attach gets, so this is what keeps a repeated
880// `carlos deploy` idempotent rather than erroring on its own prior success.
881func TestDeployAutoAttachesUnclaimedStaticHost(t *testing.T) {
882 useTempCredentials(t)
883 clearDeploymentEnv(t)
884 checkNoRegistry := requireNoRegistryTouch(t)
885
886 const (
887 app = "sitely"
888 host = "sitely.example.test"
889 channel = "canary/site"
890 )
891 fc := newDeployFakeConsole(t, "sha0001")
892 credFile, err := loadCredentials()
893 if err != nil {
894 t.Fatal(err)
895 }
896 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
897 ExpiresAt: time.Now().Add(time.Hour)})
898 if err := credFile.save(); err != nil {
899 t.Fatal(err)
900 }
901
902 // The watch leg's own edge: always answers whatever version this run
903 // expects, so the test is about the attach step, not about racing a
904 // slow-to-converge edge.
905 var edgeVersion atomic.Value
906 edgeVersion.Store("sha0001")
907 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
908 w.Header().Set("X-Carlos-Version", edgeVersion.Load().(string))
909 }))
910 defer edgeSrv.Close()
911 origOrigin := watchOrigin
912 watchOrigin = func(h string) string { return edgeSrv.URL }
913 defer func() { watchOrigin = origOrigin }()
914
915 siteDir := t.TempDir()
916 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
917 t.Fatal(err)
918 }
919
920 buf := captureStdout(t)
921 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
922 "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
923 out := buf.String()
924
925 attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains")
926 if attachIdx < 0 {
927 t.Fatalf("attach was never POSTed: %v", fc.calls)
928 }
929 if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx {
930 t.Fatalf("ship (index %d) happened before attach (index %d): %v", shipIdx, attachIdx, fc.calls)
931 }
932 if want := "attached " + host + " (first route: static, channel " + channel + ")"; !strings.Contains(out, want) {
933 t.Fatalf("stdout missing %q:\n%s", want, out)
934 }
935
936 // --- re-run: domainList now names the host, as the console would once
937 // the first attach landed. The second deploy must NOT re-POST attach. --
938 fc.mu.Lock()
939 fc.domains = []map[string]string{{"host": host, "route": "static", "dns_status": "pointing"}}
940 fc.calls = nil
941 fc.shipVersion = "sha0002"
942 fc.mu.Unlock()
943 edgeVersion.Store("sha0002")
944
945 buf.Reset()
946 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
947 "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
948 out = buf.String()
949 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
950 t.Fatalf("a host already in domainList must not trigger an attach POST: %v", fc.calls)
951 }
952 if strings.Contains(out, "attached "+host) {
953 t.Fatalf("stdout claims an attach that must not have happened:\n%s", out)
954 }
955 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
956 t.Fatalf("ship never happened on the re-run: %v", fc.calls)
957 }
958
959 checkNoRegistry()
960}
961
962// TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute is final-review
963// finding #1: attachUnclaimedHost must print off the door's OWN answer, not
964// assume "first route" just because that's what it asked for. A console
965// that resolves this deploy's -host to a DIFFERENT route than the host
966// itself — the alias case, e.g. because the app already had a ready route
967// by the time the door decided (spec §1's yes/no split lives door-side,
968// not in what this call sent) — must get the "aliases <route>" line with
969// no channel named; a channel belongs to the primary route, and an alias
970// serves whatever channel that primary already follows.
971func TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute(t *testing.T) {
972 useTempCredentials(t)
973 clearDeploymentEnv(t)
974 checkNoRegistry := requireNoRegistryTouch(t)
975
976 const (
977 app = "sitely"
978 host = "alias.example.test"
979 primary = "sitely.canary.oncarlos.test"
980 channel = "canary/site"
981 )
982 fc := newDeployFakeConsole(t, "sha0001")
983 fc.setAttachRouteOverride(primary)
984 credFile, err := loadCredentials()
985 if err != nil {
986 t.Fatal(err)
987 }
988 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
989 ExpiresAt: time.Now().Add(time.Hour)})
990 if err := credFile.save(); err != nil {
991 t.Fatal(err)
992 }
993
994 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
995 w.Header().Set("X-Carlos-Version", "sha0001")
996 }))
997 defer edgeSrv.Close()
998 origOrigin := watchOrigin
999 watchOrigin = func(h string) string { return edgeSrv.URL }
1000 defer func() { watchOrigin = origOrigin }()
1001
1002 siteDir := t.TempDir()
1003 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
1004 t.Fatal(err)
1005 }
1006
1007 buf := captureStdout(t)
1008 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
1009 "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
1010 out := buf.String()
1011
1012 if want := "attached " + host + " (aliases " + primary + ")"; !strings.Contains(out, want) {
1013 t.Fatalf("stdout = %q, want the alias wording naming the door's resolved route %q", out, primary)
1014 }
1015 if strings.Contains(out, "first route") || strings.Contains(out, "channel "+channel) {
1016 t.Fatalf("stdout wrongly named a channel for an alias attach (a channel belongs to the primary):\n%s", out)
1017 }
1018
1019 checkNoRegistry()
1020}
1021
1022// TestDeployExitsHonestlyWhenDNSIsNotPointingYet pins the exit contract for
1023// a connection-class watch timeout: the deploy itself already succeeded
1024// (shipped, promoted) but the host never answered at all — the shape a
1025// domain that has not been pointed here yet produces — so the exit text
1026// must say DNS specifically, carry the console's own guidance, and still
1027// exit nonzero: "deploy failed" would be a lie (the release IS live), and a
1028// zero exit would be a worse one (the URL does not serve it).
1029//
1030// Run as a subprocess (buildCarlos + exec.Command, carlosTestEnv's own
1031// pattern): log.Fatal exits the process, which would take the rest of this
1032// package's tests down with it if this ran in-process. The host is a bare
1033// loopback address with nothing listening on :443 — an instant, hermetic
1034// "unreachable" that needs no real DNS or outbound network access, and
1035// exercises the exact diag class (watchVersion's "unreachable: ...", from
1036// http.Client.Get itself failing) a not-yet-pointing domain produces. The
1037// fake console already lists the host as claimed (domainList has an entry
1038// with guidance), so auto-attach is a no-op here — this is the SECOND deploy
1039// to an already-attached host shape, not the first.
1040func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) {
1041 bin := buildCarlos(t)
1042 const (
1043 app = "sitely"
1044 host = "127.0.0.1"
1045 channel = "canary/site"
1046 version = "sha0001"
1047 guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test"
1048 )
1049 fc := newDeployFakeConsole(t, version)
1050 fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}})
1051
1052 credPath := filepath.Join(t.TempDir(), "credentials")
1053 t.Setenv("CARLOS_CREDENTIALS", credPath)
1054 credFile, err := loadCredentials()
1055 if err != nil {
1056 t.Fatal(err)
1057 }
1058 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1059 ExpiresAt: time.Now().Add(time.Hour)})
1060 if err := credFile.save(); err != nil {
1061 t.Fatal(err)
1062 }
1063
1064 siteDir := t.TempDir()
1065 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
1066 t.Fatal(err)
1067 }
1068
1069 cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static",
1070 "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL,
1071 "-timeout", "100ms", siteDir)
1072 // carlosTestEnv is the repo rule for a subprocess that shells out to the
1073 // carlos binary — it masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE to its
1074 // own scratch space; the explicit CARLOS_CREDENTIALS below wins over
1075 // that default because os/exec keeps only the LAST value for a
1076 // duplicate key, landing the subprocess on the exact credential file
1077 // this test just wrote to (the same path this process's own
1078 // CARLOS_CREDENTIALS was just pointed at, via t.Setenv above).
1079 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
1080 cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule (carlosTestEnv's own reasoning)
1081 out, err := cmd.CombinedOutput()
1082 if err == nil {
1083 t.Fatalf("DNS-not-pointing deploy exited 0:\n%s", out)
1084 }
1085 want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide +
1086 ". Watch with 'carlos domains list --app " + app + " --account ops'; re-run this deploy any time — the shipped release is already promoted."
1087 if !strings.Contains(string(out), want) {
1088 t.Fatalf("exit text = %q, want it to contain %q", out, want)
1089 }
1090 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
1091 t.Fatalf("host already in domainList must not have triggered an attach POST: %v", fc.calls)
1092 }
1093}
1094
1095// TestDeployDNSGuidanceMessageBothArms pins review fix #1: a host-match alone is
1096// not enough to say "DNS is not pointing" — DNS == "pointing" with a
1097// connection-class failure means something else (the certificate, almost
1098// always) is what's still missing, and printing an empty "Create: ." would
1099// be a lie by omission. Both arms are driven through a real domainList call
1100// against a fake console (not hand-built apiDomainRecord values), so the test
1101// also proves the DNS/Guidance fields survive the wire round trip into the
1102// message. A third case pins the fall-through: no DNS verdict and no
1103// guidance yet must defer to the generic timeout report rather than guess.
1104func TestDeployDNSGuidanceMessageBothArms(t *testing.T) {
1105 fakeDomainsServer := func(t *testing.T, domain map[string]string) []apiDomainRecord {
1106 t.Helper()
1107 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1108 json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{domain}})
1109 }))
1110 defer ts.Close()
1111 domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
1112 if err != nil {
1113 t.Fatal(err)
1114 }
1115 return domains
1116 }
1117
1118 // Arm 1: DNS is pointing, so a connection-class failure means the
1119 // certificate isn't issued yet — never "DNS is not pointing" (that
1120 // would be false), and never an empty "Create: ." (pointing carries no
1121 // guidance at all).
1122 domains := fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "pointing"})
1123 msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"})
1124 if !ok {
1125 t.Fatal("pointing arm: want ok")
1126 }
1127 for _, want := range []string{
1128 "DNS for x.test is pointing here, but the certificate is not issued yet",
1129 "re-run this deploy shortly",
1130 "carlos domains list --app sitely --account ops",
1131 } {
1132 if !strings.Contains(msg, want) {
1133 t.Fatalf("pointing-arm message = %q, missing %q", msg, want)
1134 }
1135 }
1136 if strings.Contains(msg, "Create:") || strings.Contains(msg, "not pointing") {
1137 t.Fatalf("pointing arm must not claim DNS is not pointing or print an empty guidance line: %q", msg)
1138 }
1139
1140 // Arm 2: DNS is elsewhere, with real guidance — the original report.
1141 domains = fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "elsewhere", "guidance": "create a CNAME"})
1142 msg, ok = dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"})
1143 if !ok {
1144 t.Fatal("not-pointing arm: want ok")
1145 }
1146 for _, want := range []string{
1147 "DNS for x.test is not pointing at this deployment yet. Create: create a CNAME",
1148 "re-run this deploy any time",
1149 "carlos domains list --app sitely --account ops",
1150 } {
1151 if !strings.Contains(msg, want) {
1152 t.Fatalf("not-pointing-arm message = %q, missing %q", msg, want)
1153 }
1154 }
1155
1156 // Fall-through: DNS never checked at all (no dns field, no guidance) —
1157 // neither arm applies, so the caller must fall back to the generic
1158 // watchVersion timeout report rather than print a guessed-at message.
1159 domains = fakeDomainsServer(t, map[string]string{"host": "x.test"})
1160 if _, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok {
1161 t.Fatal("no DNS verdict and no guidance yet must fall through, not print a message")
1162 }
1163
1164 // A host absent from domains entirely must also fall through.
1165 if _, ok := dnsGuidanceMessage(domains, "not-listed.test", "sitely", apiAcct{Seg: "ops"}); ok {
1166 t.Fatal("a host absent from domainList must fall through, not print a message")
1167 }
1168}
1169
1170// TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure
1171// pins the third status: "unresolved" means the DNS LOOKUP failed (our
1172// resolver, a timeout, a SERVFAIL) — not that the member's record is
1173// missing. Printing "DNS ... is not pointing ... Create: ..." here would
1174// tell them to fix something that was never wrong; the honest move is the
1175// same fall-through as "never checked", so the caller reports the plain
1176// watchVersion timeout instead of a guess.
1177func TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure(t *testing.T) {
1178 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1179 json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{
1180 {"host": "x.test", "dns_status": "unresolved"},
1181 }})
1182 }))
1183 defer ts.Close()
1184 domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
1185 if err != nil {
1186 t.Fatal(err)
1187 }
1188 if msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok {
1189 t.Fatalf("unresolved must fall through, not print a message: ok=%v msg=%q", ok, msg)
1190 }
1191
1192 // Defensive: even if some future server bug stamped guidance alongside
1193 // "unresolved", this function must still refuse it rather than trust an
1194 // upstream promise it cannot verify.
1195 ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1196 json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{
1197 {"host": "x.test", "dns_status": "unresolved", "guidance": "create a CNAME"},
1198 }})
1199 }))
1200 defer ts2.Close()
1201 domains2, err := newTestSession(ts2.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
1202 if err != nil {
1203 t.Fatal(err)
1204 }
1205 if msg, ok := dnsGuidanceMessage(domains2, "x.test", "sitely", apiAcct{Seg: "ops"}); ok {
1206 t.Fatalf("unresolved with stray guidance must still fall through: ok=%v msg=%q", ok, msg)
1207 }
1208}
1209
1210// TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor pins review
1211// fix #2: silence is only right when nothing needed attaching anyway. A
1212// console that predates the /domains door 404s the list; when the target
1213// host is genuinely unclaimed (absent from the instance list — always true
1214// on the no-instance static path), the member is told the door is missing
1215// and the host needs a console-side attach, and the deploy still PROCEEDS
1216// (ships and promotes) rather than fataling over a feature this console
1217// simply cannot offer yet.
1218func TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor(t *testing.T) {
1219 useTempCredentials(t)
1220 clearDeploymentEnv(t)
1221
1222 const (
1223 app = "sitely"
1224 host = "sitely.example.test"
1225 channel = "canary/site"
1226 )
1227 fc := newDeployFakeConsole(t, "sha0001")
1228 fc.setDomainsListStatus(http.StatusNotFound) // old console: the route itself is unregistered
1229 credFile, err := loadCredentials()
1230 if err != nil {
1231 t.Fatal(err)
1232 }
1233 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1234 ExpiresAt: time.Now().Add(time.Hour)})
1235 if err := credFile.save(); err != nil {
1236 t.Fatal(err)
1237 }
1238
1239 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1240 w.Header().Set("X-Carlos-Version", "sha0001")
1241 }))
1242 defer edgeSrv.Close()
1243 origOrigin := watchOrigin
1244 watchOrigin = func(h string) string { return edgeSrv.URL }
1245 defer func() { watchOrigin = origOrigin }()
1246
1247 siteDir := t.TempDir()
1248 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
1249 t.Fatal(err)
1250 }
1251
1252 buf := captureStdout(t)
1253 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
1254 "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
1255 out := buf.String()
1256
1257 if !strings.Contains(out, "no domains door") || !strings.Contains(out, host) {
1258 t.Fatalf("stdout missing the no-domains-door note naming %s:\n%s", host, out)
1259 }
1260 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
1261 t.Fatalf("no domains door — attach must never have been attempted: %v", fc.calls)
1262 }
1263 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
1264 t.Fatalf("deploy must still proceed to ship: %v", fc.calls)
1265 }
1266 if !strings.Contains(out, "live https://"+host) {
1267 t.Fatalf("deploy must still complete and confirm the URL:\n%s", out)
1268 }
1269}
1270
1271// TestDeployAutoAttachDegradesOnDomainsListServerError pins review fix #3:
1272// a transport-level or 5xx domainList failure must warn and continue, not
1273// abort a deploy that would otherwise succeed — the door exists but is
1274// unwell, which is not the same problem as an unauthorized credential.
1275func TestDeployAutoAttachDegradesOnDomainsListServerError(t *testing.T) {
1276 useTempCredentials(t)
1277 clearDeploymentEnv(t)
1278
1279 const (
1280 app = "sitely"
1281 host = "sitely.example.test"
1282 channel = "canary/site"
1283 )
1284 fc := newDeployFakeConsole(t, "sha0001")
1285 fc.setDomainsListStatus(http.StatusInternalServerError)
1286 credFile, err := loadCredentials()
1287 if err != nil {
1288 t.Fatal(err)
1289 }
1290 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1291 ExpiresAt: time.Now().Add(time.Hour)})
1292 if err := credFile.save(); err != nil {
1293 t.Fatal(err)
1294 }
1295
1296 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1297 w.Header().Set("X-Carlos-Version", "sha0001")
1298 }))
1299 defer edgeSrv.Close()
1300 origOrigin := watchOrigin
1301 watchOrigin = func(h string) string { return edgeSrv.URL }
1302 defer func() { watchOrigin = origOrigin }()
1303
1304 siteDir := t.TempDir()
1305 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
1306 t.Fatal(err)
1307 }
1308
1309 buf := captureStdout(t)
1310 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
1311 "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
1312 out := buf.String()
1313
1314 if !strings.Contains(out, "could not check custom domains") || !strings.Contains(out, host) {
1315 t.Fatalf("stdout missing the degrade warning naming %s:\n%s", host, out)
1316 }
1317 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
1318 t.Fatalf("a domainList 500 must not be treated as an unclaimed host — attach must never be attempted: %v", fc.calls)
1319 }
1320 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
1321 t.Fatalf("deploy must still proceed to ship despite the domainList 500: %v", fc.calls)
1322 }
1323 if !strings.Contains(out, "live https://"+host) {
1324 t.Fatalf("deploy must still complete and confirm the URL:\n%s", out)
1325 }
1326}
1327
1328// TestDeployAttachRefusalStopsBeforeShip pins the no-partial-ship
1329// invariant: when the console refuses the attach POST (422 — a policy
1330// refusal, e.g. an unshaped host), the deploy must exit nonzero WITHOUT
1331// ever reaching ship — a half-done deploy (bytes shipped, but the route
1332// never attached) would be worse than stopping before anything is written.
1333// Run as a subprocess: log.Fatal exits the process.
1334func TestDeployAttachRefusalStopsBeforeShip(t *testing.T) {
1335 bin := buildCarlos(t)
1336 const (
1337 app = "sitely"
1338 host = "sitely.example.test"
1339 channel = "canary/site"
1340 version = "sha0001"
1341 )
1342 fc := newDeployFakeConsole(t, version)
1343 fc.setAttachRefusal(http.StatusUnprocessableEntity, "not a usable hostname")
1344
1345 credPath := filepath.Join(t.TempDir(), "credentials")
1346 t.Setenv("CARLOS_CREDENTIALS", credPath)
1347 credFile, err := loadCredentials()
1348 if err != nil {
1349 t.Fatal(err)
1350 }
1351 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1352 ExpiresAt: time.Now().Add(time.Hour)})
1353 if err := credFile.save(); err != nil {
1354 t.Fatal(err)
1355 }
1356
1357 siteDir := t.TempDir()
1358 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
1359 t.Fatal(err)
1360 }
1361
1362 cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static",
1363 "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL,
1364 "-timeout", "5s", siteDir)
1365 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
1366 cmd.Dir = t.TempDir()
1367 out, err := cmd.CombinedOutput()
1368 if err == nil {
1369 t.Fatalf("deploy with a refused attach exited 0:\n%s", out)
1370 }
1371 if !strings.Contains(string(out), "not a usable hostname") {
1372 t.Fatalf("exit text missing the console's refusal verbatim:\n%s", out)
1373 }
1374 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx >= 0 {
1375 t.Fatalf("ship must never happen after a refused attach: %v", fc.calls)
1376 }
1377}
1378
1379// TestDeployTargetsStaticKindAware pins the kind-aware static gate (task-8
1380// review, important #1): a Kind=="static" provision record — the one a
1381// first-route domain attach writes — is a static-deploy TARGET, not an
1382// instance that pulls the deploy off the static path. Before this gate,
1383// the record the FIRST `deploy -kind static -host <h>` left behind made
1384// every redeploy lose the recheck note and the DNS-guidance exit, and made
1385// a second static -host fatal with "no instance with host".
1386func TestDeployTargetsStaticKindAware(t *testing.T) {
1387 staticRec := apiInstanceRecord{Host: "site.example.test", Channel: "canary/site", Kind: "static"}
1388 channelsCalled := false
1389 channels := func() (apiChannelsResult, error) {
1390 channelsCalled = true
1391 return apiChannelsResult{Channels: []apiChannel{{Channel: "canary/other"}}}, nil
1392 }
1393
1394 // Redeploy: -host names the record — static path, channel straight from
1395 // the record, no channels round trip.
1396 ch, hosts, static, err := deployTargets([]apiInstanceRecord{staticRec}, "static", "", "site.example.test", channels)
1397 if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" {
1398 t.Fatalf("redeploy = ch %q hosts %v static %v err %v, want the record's own channel on the static path", ch, hosts, static, err)
1399 }
1400 if channelsCalled {
1401 t.Fatal("redeploy consulted the channels listing — the record already knows its channel")
1402 }
1403
1404 // Second static host: unrecorded -host stays on the static path (it is
1405 // about to be attached), channel resolved from the app's channels.
1406 ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "second.example.test", channels)
1407 if err != nil || !static || len(hosts) != 1 || hosts[0] != "second.example.test" || ch != "canary/other" {
1408 t.Fatalf("second host = ch %q hosts %v static %v err %v, want the static path with the listed channel", ch, hosts, static, err)
1409 }
1410 if !channelsCalled {
1411 t.Fatal("second host never consulted the channels listing")
1412 }
1413
1414 // No -host at all — the obvious redeploy invocation once a domain is
1415 // attached: the records are the watch targets AND their agreed channel
1416 // is the promote target, with no -channel and NO channels round trip. A
1417 // listing that happened to hold one other channel must not win here —
1418 // promoting onto it while watching hosts that follow the record's
1419 // channel would verify the wrong thing (re-review, important #1).
1420 channelsCalled = false
1421 ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "", channels)
1422 if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" {
1423 t.Fatalf("no --host = ch %q hosts %v static %v err %v, want the record's host watched on the record's channel", ch, hosts, static, err)
1424 }
1425 if channelsCalled {
1426 t.Fatal("no --host redeploy consulted the channels listing — the records already agree on a channel")
1427 }
1428
1429 // Records DISAGREEING on a channel resolve nothing: fall through to the
1430 // channels listing (whose single entry answers, and whose plural case
1431 // refuses naming -channel) rather than guessing between the records.
1432 twoChannels := []apiInstanceRecord{
1433 {Host: "site.example.test", Channel: "canary/site", Kind: "static"},
1434 {Host: "blog.example.test", Channel: "canary/blog", Kind: "static"},
1435 }
1436 ch, hosts, static, err = deployTargets(twoChannels, "static", "", "", channels)
1437 if err != nil || !static || len(hosts) != 2 || ch != "canary/other" {
1438 t.Fatalf("disagreeing records = ch %q hosts %v static %v err %v, want the listing's channel via fallthrough", ch, hosts, static, err)
1439 }
1440 if !channelsCalled {
1441 t.Fatal("disagreeing records never fell through to the channels listing")
1442 }
1443
1444 // A real instance record still pulls a -kind static deploy onto the
1445 // instance path, exactly as before the gate existed.
1446 instRec := apiInstanceRecord{Host: "app.example.test", Channel: "stable"}
1447 ch, hosts, static, err = deployTargets([]apiInstanceRecord{instRec}, "static", "", "app.example.test", channels)
1448 if err != nil || static || ch != "stable" || len(hosts) != 1 {
1449 t.Fatalf("instance record = ch %q hosts %v static %v err %v, want the instance path", ch, hosts, static, err)
1450 }
1451}
1452
1453// TestDeployStaticRedeployWithRecordGetsDNSGuidance is review important #1's
1454// regression case (a): the SECOND `deploy -kind static -host <h>` — after
1455// the first one's attach left a static provision record — must still take
1456// the static path and still exit through the DNS-guidance report while the
1457// domain is unpointed, not through a bare watch timeout. No -channel either:
1458// the record resolves it. Subprocess, because the exit path is log.Fatal.
1459func TestDeployStaticRedeployWithRecordGetsDNSGuidance(t *testing.T) {
1460 bin := buildCarlos(t)
1461 const (
1462 app = "sitely"
1463 host = "127.0.0.1" // nothing listens on :443 — instant, hermetic "unreachable"
1464 channel = "canary/site"
1465 version = "sha0002"
1466 guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test"
1467 )
1468 fc := newDeployFakeConsole(t, version)
1469 fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel, Kind: "static"}})
1470 fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}})
1471
1472 credPath := filepath.Join(t.TempDir(), "credentials")
1473 t.Setenv("CARLOS_CREDENTIALS", credPath)
1474 credFile, err := loadCredentials()
1475 if err != nil {
1476 t.Fatal(err)
1477 }
1478 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1479 ExpiresAt: time.Now().Add(time.Hour)})
1480 if err := credFile.save(); err != nil {
1481 t.Fatal(err)
1482 }
1483 siteDir := t.TempDir()
1484 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil {
1485 t.Fatal(err)
1486 }
1487
1488 // No -channel: the static record must resolve it (kind-aware gate).
1489 cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static",
1490 "-version", version, "-host", host, "-console", fc.ts.URL, "-timeout", "100ms", siteDir)
1491 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
1492 cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule
1493 out, err := cmd.CombinedOutput()
1494 if err == nil {
1495 t.Fatalf("redeploy to an unpointed domain exited 0:\n%s", out)
1496 }
1497 want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide
1498 if !strings.Contains(string(out), want) {
1499 t.Fatalf("exit text = %q, want the DNS-guidance report %q", out, want)
1500 }
1501 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
1502 t.Fatalf("host already recorded and claimed — attach must not be re-POSTed: %v", fc.calls)
1503 }
1504 if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
1505 t.Fatalf("redeploy never shipped: %v", fc.calls)
1506 }
1507}
1508
1509// TestDeploySecondStaticHostAttaches is review important #1's case (b),
1510// fixed outright by the kind-aware gate: an app whose only record is the
1511// first domain's static record accepts a deploy to a SECOND fresh host —
1512// static path, attach POSTed before ship — instead of the pre-fix fatal
1513// `no instance with host "<h2>"`.
1514func TestDeploySecondStaticHostAttaches(t *testing.T) {
1515 useTempCredentials(t)
1516 clearDeploymentEnv(t)
1517 checkNoRegistry := requireNoRegistryTouch(t)
1518
1519 const (
1520 app = "sitely"
1521 first = "sitely.example.test"
1522 second = "more.example.test"
1523 channel = "canary/site"
1524 )
1525 fc := newDeployFakeConsole(t, "sha0002")
1526 fc.setInstances([]apiInstanceRecord{{Host: first, Channel: channel, Kind: "static"}})
1527 fc.setDomains([]map[string]string{{"host": first, "route": "", "dns_status": "pointing"}})
1528 credFile, err := loadCredentials()
1529 if err != nil {
1530 t.Fatal(err)
1531 }
1532 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1533 ExpiresAt: time.Now().Add(time.Hour)})
1534 if err := credFile.save(); err != nil {
1535 t.Fatal(err)
1536 }
1537
1538 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1539 w.Header().Set("X-Carlos-Version", "sha0002")
1540 }))
1541 defer edgeSrv.Close()
1542 origOrigin := watchOrigin
1543 watchOrigin = func(h string) string { return edgeSrv.URL }
1544 defer func() { watchOrigin = origOrigin }()
1545
1546 siteDir := t.TempDir()
1547 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil {
1548 t.Fatal(err)
1549 }
1550
1551 buf := captureStdout(t)
1552 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
1553 "-channel", channel, "-host", second, "-timeout", "5s", siteDir})
1554 out := buf.String()
1555
1556 attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains")
1557 if attachIdx < 0 {
1558 t.Fatalf("second static host was never attached: %v", fc.calls)
1559 }
1560 if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx {
1561 t.Fatalf("ship (index %d) ran before attach (index %d): %v", shipIdx, attachIdx, fc.calls)
1562 }
1563 if !strings.Contains(out, "attached "+second) {
1564 t.Fatalf("stdout missing the attach line for %s:\n%s", second, out)
1565 }
1566 if !strings.Contains(out, "live https://"+second) {
1567 t.Fatalf("deploy did not complete against the second host:\n%s", out)
1568 }
1569 checkNoRegistry()
1570}
1571
1572// TestDeployPromoteProposedExitsCleanly pins the RIDER from Task 10's
1573// review: when the ship succeeds but the promote a pipelined `carlos
1574// deploy` attempts lands on a GUARDED channel, the console answers 202 —
1575// *apiProposedError, not a refusal — and `carlos deploy` must exit CLEANLY
1576// (status 0), print a message that says BOTH halves of the truth (the ship
1577// already landed; the promotion needs confirming), and never reach the
1578// watch loop: nothing converges anywhere until the proposal is confirmed,
1579// so there is nothing yet to watch for. Before this fix, deploy.go's
1580// promote error handling was a bare log.Fatalf that would have reported
1581// the ship as failed outright (exit 1, a misleading "deploy: promote:
1582// ..." line) — the CLI-lies-about-its-outcome bug apiProposedError exists
1583// to close everywhere else it is checked (runPromote, runRollback,
1584// applyPipelinePut).
1585func TestDeployPromoteProposedExitsCleanly(t *testing.T) {
1586 useTempCredentials(t)
1587 clearDeploymentEnv(t)
1588 checkNoRegistry := requireNoRegistryTouch(t)
1589
1590 const app = "widgetco"
1591 fc := newDeployFakeConsole(t, "sha0009")
1592 // An instance record with hosts to watch, so a regression that reaches
1593 // the watch loop anyway has something to hang against — proof by
1594 // consequence that the fix's early return is what's keeping it away,
1595 // not an accidental "nothing to watch" shortcut.
1596 fc.setInstances([]apiInstanceRecord{{Host: "widgetco-a.example.test", Channel: "canary/x"}})
1597 fc.setPromoteProposed(true)
1598
1599 credFile, err := loadCredentials()
1600 if err != nil {
1601 t.Fatal(err)
1602 }
1603 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1604 ExpiresAt: time.Now().Add(time.Hour)})
1605 if err := credFile.save(); err != nil {
1606 t.Fatal(err)
1607 }
1608
1609 binDir := t.TempDir()
1610 binPath := filepath.Join(binDir, "widgetco")
1611 if err := os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
1612 t.Fatal(err)
1613 }
1614
1615 buf := captureStdout(t)
1616 runDeploy([]string{"-app", app, "-account", "ops", "-kind", "binary", "-version", "sha0009",
1617 "-channel", "canary/x", "--message", "Fix invoice rounding", "-timeout", "5s", binPath})
1618 out := buf.String()
1619
1620 if promoteIdx := fc.callIndex("POST /api/cli/apps/ops/widgetco/promote"); promoteIdx < 0 {
1621 t.Fatalf("promote was never called: %v", fc.calls)
1622 }
1623 for _, want := range []string{"shipped widgetco sha0009", "ship succeeded", "sha0009", "canary/x", "proposed",
1624 "2 confirmation", "console.example.test/a/ops/apps/sitely/pending/pend-1"} {
1625 if !strings.Contains(out, want) {
1626 t.Fatalf("stdout = %q, want it to contain %q", out, want)
1627 }
1628 }
1629 if strings.Contains(out, "deploy: promote:") {
1630 t.Fatalf("stdout = %q, must not print the generic promote-failure prefix — this is not a failure", out)
1631 }
1632 // The proof that the watch loop never ran: watchVersion's own success
1633 // line ("live https://<host>", the exact text
1634 // TestDeploySecondStaticHostAttaches pins) never appears — if deploy had
1635 // fallen through to the watch loop against a host
1636 // (widgetco-a.example.test) nothing serves, this test would instead
1637 // have blocked for close to -timeout before a log.Fatal, not returned
1638 // promptly with a clean message.
1639 if strings.Contains(out, "live https://") {
1640 t.Fatalf("stdout = %q, must never reach the watch loop (nothing converges until the proposal is confirmed)", out)
1641 }
1642 fc.mu.Lock()
1643 shipMessage, promoteMessage := fc.shipMessage, fc.promoteMessage
1644 fc.mu.Unlock()
1645 if shipMessage != "Fix invoice rounding" || promoteMessage != shipMessage {
1646 t.Fatalf("deploy messages: ship=%q promote=%q", shipMessage, promoteMessage)
1647 }
1648 checkNoRegistry()
1649}
1650
1651// TestDeployTargetsStaticNoHostIsTyped pins the refusal runDeploy answers by
1652// MINTING the app's platform host (static-platform-host design §3.2). It is
1653// typed for errAmbiguousChannel's reason: a refusal matched by text would
1654// eventually mint in answer to a different sentence that merely mentioned a
1655// hostname.
1656func TestDeployTargetsStaticNoHostIsTyped(t *testing.T) {
1657 _, _, static, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
1658 return apiChannelsResult{PipelineEntry: "edge"}, nil
1659 })
1660 if !static {
1661 t.Fatal("static = false, want true")
1662 }
1663 if !errors.Is(err, errNoStaticHost) {
1664 t.Fatalf("err = %v, want errNoStaticHost", err)
1665 }
1666}
1667
1668// The mint needs a channel, and the only place it can come from is the
1669// resolution deployTargets already does — so errNoStaticHost travels WITH a
1670// usable channel.
1671func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) {
1672 channel, hosts, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
1673 return apiChannelsResult{PipelineEntry: "edge"}, nil
1674 })
1675 if !errors.Is(err, errNoStaticHost) {
1676 t.Fatalf("err = %v, want errNoStaticHost", err)
1677 }
1678 if channel != "edge" {
1679 t.Fatalf("channel = %q, want %q — the mint needs it", channel, "edge")
1680 }
1681 if len(hosts) != 0 {
1682 t.Fatalf("hosts = %v, want none", hosts)
1683 }
1684}
1685
1686// Both unresolved: the host refusal names something the member can supply,
1687// so it is the one that surfaces.
1688func TestDeployTargetsStaticNoHostNoChannelPrefersHostRefusal(t *testing.T) {
1689 channel, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
1690 return apiChannelsResult{}, nil
1691 })
1692 if !errors.Is(err, errNoStaticHost) {
1693 t.Fatalf("err = %v, want errNoStaticHost", err)
1694 }
1695 // And the channel is EMPTY here, which is the whole reason runDeploy
1696 // guards its mint on one: a record written with no channel is defaulted
1697 // to stable by the door, and nothing can change it afterwards.
1698 if channel != "" {
1699 t.Fatalf("channel = %q, want empty — runDeploy must have nothing to mint onto", channel)
1700 }
1701}
1702
1703// Unchanged: a -host whose static record names a channel resolves from the
1704// record and never lists channels.
1705func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) {
1706 recs := []apiInstanceRecord{{Host: "site.example.com", Kind: "static", Channel: "beta"}}
1707 channel, hosts, static, err := deployTargets(recs, "static", "", "site.example.com", func() (apiChannelsResult, error) {
1708 t.Error("channels() called; the record already names the channel")
1709 return apiChannelsResult{}, nil
1710 })
1711 if err != nil || !static || channel != "beta" || len(hosts) != 1 || hosts[0] != "site.example.com" {
1712 t.Fatalf("got %q %v %v %v", channel, hosts, static, err)
1713 }
1714}
1715
1716// TestMintPlatformHostSendsKindAndChannel pins the two fields the mint
1717// cannot get wrong: the record's kind, and an EXPLICIT channel (an omitted
1718// one is defaulted to stable by the door, and no door can change a record's
1719// channel afterwards).
1720func TestMintPlatformHostSendsKindAndChannel(t *testing.T) {
1721 var create map[string]any
1722 var enabled bool
1723 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1724 switch {
1725 case strings.HasSuffix(r.URL.Path, "/instances-manifest"):
1726 enabled = true
1727 io.WriteString(w, `{"app":"mysite","domain":"bab.oncarlos.test"}`)
1728 case strings.HasSuffix(r.URL.Path, "/instances"):
1729 if err := json.NewDecoder(r.Body).Decode(&create); err != nil {
1730 t.Errorf("decode create: %v", err)
1731 }
1732 w.WriteHeader(http.StatusCreated)
1733 io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`)
1734 default:
1735 t.Errorf("unexpected path %s", r.URL.Path)
1736 }
1737 }))
1738 defer ts.Close()
1739
1740 out := captureStdout(t)
1741 host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge", "publish the new site")
1742 if err != nil {
1743 t.Fatalf("mintPlatformHost: %v", err)
1744 }
1745 if !enabled {
1746 t.Fatal("the manifest was never written; the create door refuses without one")
1747 }
1748 if host != "mysite.bab.oncarlos.test" {
1749 t.Fatalf("host = %q, want the app's label under the manifest's domain", host)
1750 }
1751 if create["kind"] != "static" {
1752 t.Fatalf("create kind = %#v, want static", create["kind"])
1753 }
1754 if create["channel"] != "edge" {
1755 t.Fatalf("create channel = %#v, want an EXPLICIT edge", create["channel"])
1756 }
1757 if create["message"] != "publish the new site" {
1758 t.Fatalf("create message = %#v", create["message"])
1759 }
1760 if got := out.String(); !strings.Contains(got, "minted") || !strings.Contains(got, host) {
1761 t.Fatalf("stdout = %q, want it to name what was minted", got)
1762 }
1763}
1764
1765// A deployment with no platform namespace (503) and one whose instance
1766// domain an operator pinned elsewhere (409) both mean the same thing: this
1767// deployment will not mint a platform host for this app. Classified by
1768// STATUS, never by reading the message.
1769func TestMintPlatformHostUnavailableIsClassifiedByStatus(t *testing.T) {
1770 for _, tc := range []struct {
1771 name string
1772 status int
1773 body string
1774 }{
1775 {"no apps domain", http.StatusServiceUnavailable,
1776 `{"error":"this console has no platform apps domain configured"}`},
1777 {"operator-pinned domain", http.StatusConflict,
1778 `{"error":"instances for mysite are managed under go.tito.io"}`},
1779 } {
1780 t.Run(tc.name, func(t *testing.T) {
1781 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1782 w.WriteHeader(tc.status)
1783 io.WriteString(w, tc.body)
1784 }))
1785 defer ts.Close()
1786 _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
1787 if err == nil {
1788 t.Fatal("want a refusal")
1789 }
1790 if !enableUnavailable(err) {
1791 t.Fatalf("err = %v, want it classified unavailable", err)
1792 }
1793 })
1794 }
1795}
1796
1797// Every other failure is a real one and must NOT be classified as "this
1798// deployment has no platform host" — a 403 means the credential lost
1799// authority, which is worth stopping the deploy for.
1800func TestMintPlatformHostForbiddenIsNotUnavailable(t *testing.T) {
1801 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1802 w.WriteHeader(http.StatusForbidden)
1803 io.WriteString(w, `{"error":"not a member"}`)
1804 }))
1805 defer ts.Close()
1806 _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
1807 if err == nil || enableUnavailable(err) {
1808 t.Fatalf("err = %v, want an unclassified failure", err)
1809 }
1810}
1811
1812// ---- the build probe: watching the app's own report, not the edge's header --
1813
1814// TestWatchProbeMatchesAWholeToken: the probe body must carry the build as a
1815// whitespace-delimited token. Substring matching would let abc123 satisfy a
1816// watch for abc1234 — and short git shas make that a real collision, not a
1817// theoretical one.
1818func TestWatchProbeMatchesAWholeToken(t *testing.T) {
1819 var body atomic.Value
1820 body.Store("ok abc123")
1821 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1822 if r.URL.Path != "/healthz" {
1823 t.Errorf("watch dialled %q, want /healthz", r.URL.Path)
1824 }
1825 if r.URL.Query().Get("carlos-deploy-probe") == "" {
1826 t.Error("probe poll carried no cache-buster — the edge caches a proxied 2xx for up to 5 minutes")
1827 }
1828 fmt.Fprintln(w, body.Load().(string))
1829 }))
1830 defer srv.Close()
1831
1832 targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
1833 err := watchVersion(io.Discard, targets, "abc1234", 300*time.Millisecond, 50*time.Millisecond, nil)
1834 if err == nil {
1835 t.Fatal(`watch for abc1234 succeeded against a body of "ok abc123" — substring match, not token match`)
1836 }
1837
1838 body.Store("ok abc1234")
1839 if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil {
1840 t.Fatalf("watch against the matching build = %v, want success", err)
1841 }
1842}
1843
1844// TestWatchProbeTimeoutNamesTheBody: a binary built without the version
1845// ldflag reports "dev" forever, so the deploy fails while the roll was
1846// perfectly healthy. The report has to make that self-diagnosing rather than
1847// saying "timed out".
1848func TestWatchProbeTimeoutNamesTheBody(t *testing.T) {
1849 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1850 fmt.Fprintln(w, "ok dev")
1851 }))
1852 defer srv.Close()
1853 var buf bytes.Buffer
1854 targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
1855 err := watchVersion(&buf, targets, "abc1234", 100*time.Millisecond, 50*time.Millisecond, nil)
1856 if err == nil {
1857 t.Fatal("want a timeout")
1858 }
1859 out := buf.String() + err.Error()
1860 if !strings.Contains(out, "dev") {
1861 t.Fatalf("timeout report %q does not name what the probe actually said — the ldflags trap stays invisible", out)
1862 }
1863}
1864
1865// TestWatchProbeNon2xxIsNotLive: a probe path can answer with the right build
1866// in the body and still be failing — the console's own /healthz is served by
1867// the same process that would 500. Status gates the success arm on this leg
1868// exactly as it does on the header leg.
1869func TestWatchProbeNon2xxIsNotLive(t *testing.T) {
1870 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1871 w.WriteHeader(http.StatusInternalServerError)
1872 fmt.Fprintln(w, "ok abc1234")
1873 }))
1874 defer srv.Close()
1875 var buf bytes.Buffer
1876 targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
1877 err := watchVersion(&buf, targets, "abc1234", 100*time.Millisecond, 50*time.Millisecond, nil)
1878 if err == nil {
1879 t.Fatal("a 500 whose body names the build must not be reported live")
1880 }
1881 if !strings.Contains(err.Error(), "HTTP 500") {
1882 t.Fatalf("diagnostic must name the failing status: %v", err)
1883 }
1884 if strings.Contains(buf.String(), "live") {
1885 t.Fatalf("must not print live: %q", buf.String())
1886 }
1887}
1888
1889// TestWatchWithoutAProbeStillReadsTheHeader: an app that declares no probe
1890// keeps today's behaviour byte for byte.
1891func TestWatchWithoutAProbeStillReadsTheHeader(t *testing.T) {
1892 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1893 if r.URL.Path != "/" {
1894 t.Errorf("header watch dialled %q, want /", r.URL.Path)
1895 }
1896 w.Header().Set("X-Carlos-Version", "abc1234")
1897 w.WriteHeader(200)
1898 }))
1899 defer srv.Close()
1900 targets := []watchTarget{{Host: "app.example", Origin: srv.URL}}
1901 if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil {
1902 t.Fatalf("header watch = %v, want success", err)
1903 }
1904}
1905
1906// TestDeployWatchesTheProbeTheShipResponseNames is the whole point of the
1907// probe, end to end: the edge's X-Carlos-Version is stamped from the route
1908// row, so here it still says the OLD build — exactly what a long-running
1909// unit looks like between "adoption moved the symlink" and "the process
1910// restarted". The header watch would have reported this deploy live while
1911// the old binary was still serving; the probe, answered by the app's own
1912// process, is what actually turns green.
1913func TestDeployWatchesTheProbeTheShipResponseNames(t *testing.T) {
1914 useTempCredentials(t)
1915 clearDeploymentEnv(t)
1916
1917 const (
1918 app = "console"
1919 host = "console.example.test"
1920 channel = "canary/dev"
1921 )
1922 fc := newDeployFakeConsole(t, "sha0002")
1923 fc.setShipProbe("/healthz")
1924 fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}})
1925 credFile, err := loadCredentials()
1926 if err != nil {
1927 t.Fatal(err)
1928 }
1929 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1930 ExpiresAt: time.Now().Add(time.Hour)})
1931 if err := credFile.save(); err != nil {
1932 t.Fatal(err)
1933 }
1934
1935 var probed atomic.Int64
1936 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1937 // The edge answers BOTH legs: the header leg with the stale route-row
1938 // version, the probe leg with what the process is really running.
1939 w.Header().Set("X-Carlos-Version", "sha0001")
1940 if r.URL.Path == "/healthz" {
1941 probed.Add(1)
1942 fmt.Fprintln(w, "ok sha0002")
1943 return
1944 }
1945 w.WriteHeader(http.StatusOK)
1946 }))
1947 defer edgeSrv.Close()
1948 origOrigin := watchOrigin
1949 watchOrigin = func(h string) string { return edgeSrv.URL }
1950 defer func() { watchOrigin = origOrigin }()
1951
1952 bin := filepath.Join(t.TempDir(), "console")
1953 if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil {
1954 t.Fatal(err)
1955 }
1956
1957 buf := captureStdout(t)
1958 runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002",
1959 "-channel", channel, "-timeout", "5s", bin})
1960 out := buf.String()
1961
1962 if probed.Load() == 0 {
1963 t.Fatalf("the watch never polled /healthz — it read the (stale) header instead:\n%s", out)
1964 }
1965 if !strings.Contains(out, "live https://"+host) {
1966 t.Fatalf("deploy did not complete against the probe:\n%s", out)
1967 }
1968}
1969
1970// TestDeployWithNoProbeStillWatchesTheHeader: a ship response with no
1971// build_probe key — every app that declares none, and every console built
1972// before the field existed — leaves the header watch exactly as it was.
1973func TestDeployWithNoProbeStillWatchesTheHeader(t *testing.T) {
1974 useTempCredentials(t)
1975 clearDeploymentEnv(t)
1976
1977 const (
1978 app = "titogo"
1979 host = "titogo.example.test"
1980 channel = "canary/dev"
1981 )
1982 fc := newDeployFakeConsole(t, "sha0002")
1983 fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}})
1984 credFile, err := loadCredentials()
1985 if err != nil {
1986 t.Fatal(err)
1987 }
1988 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
1989 ExpiresAt: time.Now().Add(time.Hour)})
1990 if err := credFile.save(); err != nil {
1991 t.Fatal(err)
1992 }
1993
1994 var paths sync.Map
1995 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1996 paths.Store(r.URL.Path, true)
1997 w.Header().Set("X-Carlos-Version", "sha0002")
1998 w.WriteHeader(http.StatusOK)
1999 }))
2000 defer edgeSrv.Close()
2001 origOrigin := watchOrigin
2002 watchOrigin = func(h string) string { return edgeSrv.URL }
2003 defer func() { watchOrigin = origOrigin }()
2004
2005 bin := filepath.Join(t.TempDir(), "titogo")
2006 if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil {
2007 t.Fatal(err)
2008 }
2009
2010 buf := captureStdout(t)
2011 runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002",
2012 "-channel", channel, "-timeout", "5s", bin})
2013 out := buf.String()
2014
2015 if _, dialled := paths.Load("/healthz"); dialled {
2016 t.Fatalf("an app with no probe must never be polled anywhere but /:\n%s", out)
2017 }
2018 if _, dialled := paths.Load("/"); !dialled {
2019 t.Fatalf("the header watch never dialled /:\n%s", out)
2020 }
2021 if !strings.Contains(out, "live https://"+host) {
2022 t.Fatalf("header watch did not complete:\n%s", out)
2023 }
2024}
2025
2026// TestDeployRefusesAMalformedProbeAndFallsBackToTheHeader pins the re-check
2027// on the READ side. Both grammar gates live at the write door; this value
2028// arrives from a response and is CONCATENATED onto an origin, so a probe
2029// that does not start with "/" is a redirection primitive:
2030// "https://" + host + "@evil.example/x" parses host as USERINFO and
2031// evil.example as the host, and the watch would report a machine of someone
2032// else's choosing as this deploy's build. Degrade to the header watch, say
2033// why, and never dial the named host at all.
2034func TestDeployRefusesAMalformedProbeAndFallsBackToTheHeader(t *testing.T) {
2035 useTempCredentials(t)
2036 clearDeploymentEnv(t)
2037
2038 const (
2039 app = "console"
2040 host = "console.example.test"
2041 channel = "canary/dev"
2042 )
2043 fc := newDeployFakeConsole(t, "sha0002")
2044 fc.setShipProbe("@evil.example/healthz") // no leading slash: the userinfo trick
2045 fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}})
2046 credFile, err := loadCredentials()
2047 if err != nil {
2048 t.Fatal(err)
2049 }
2050 credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
2051 ExpiresAt: time.Now().Add(time.Hour)})
2052 if err := credFile.save(); err != nil {
2053 t.Fatal(err)
2054 }
2055
2056 var paths sync.Map
2057 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2058 paths.Store(r.URL.Path, true)
2059 w.Header().Set("X-Carlos-Version", "sha0002")
2060 w.WriteHeader(http.StatusOK)
2061 }))
2062 defer edgeSrv.Close()
2063 origOrigin := watchOrigin
2064 watchOrigin = func(h string) string { return edgeSrv.URL }
2065 defer func() { watchOrigin = origOrigin }()
2066
2067 bin := filepath.Join(t.TempDir(), "console")
2068 if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil {
2069 t.Fatal(err)
2070 }
2071
2072 buf := captureStdout(t)
2073 runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002",
2074 "-channel", channel, "-timeout", "5s", bin})
2075 out := buf.String()
2076
2077 if !strings.Contains(out, "ignoring the console's build probe") {
2078 t.Fatalf("a malformed probe must be named and dropped, not silently used:\n%s", out)
2079 }
2080 paths.Range(func(k, _ any) bool {
2081 if k.(string) != "/" {
2082 t.Errorf("a refused probe was still dialled: %q", k)
2083 }
2084 return true
2085 })
2086 if _, dialled := paths.Load("/"); !dialled {
2087 t.Fatalf("the header watch never ran:\n%s", out)
2088 }
2089 if !strings.Contains(out, "live https://"+host) {
2090 t.Fatalf("deploy must still complete on the header watch:\n%s", out)
2091 }
2092}
2093
2094// initGitRepo builds a git repo in a fresh temp dir with one committed file.
2095// With dirty=true, the file is then modified so the working tree is dirty;
2096// with dirty=false, the tree is left exactly as committed — the branch whose
2097// failure mode is "refuses every ordinary deploy" and which nothing else in
2098// this suite exercises, since every runDeploy call site passes --version
2099// explicitly and CI itself checks out a clean tree. Skips the test if git is
2100// unavailable in this environment.
2101func initGitRepo(t *testing.T, dirty bool) string {
2102 t.Helper()
2103 if _, err := exec.LookPath("git"); err != nil {
2104 t.Skip("git not available")
2105 }
2106 dir := t.TempDir()
2107 run := func(args ...string) {
2108 t.Helper()
2109 cmd := exec.Command("git", args...)
2110 cmd.Dir = dir
2111 cmd.Env = append(os.Environ(),
2112 "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
2113 "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")
2114 if out, err := cmd.CombinedOutput(); err != nil {
2115 t.Fatalf("git %v: %v\n%s", args, err, out)
2116 }
2117 }
2118 run("init")
2119 run("config", "user.email", "test@example.com")
2120 run("config", "user.name", "test")
2121 f := filepath.Join(dir, "file.txt")
2122 if err := os.WriteFile(f, []byte("committed\n"), 0o644); err != nil {
2123 t.Fatal(err)
2124 }
2125 run("add", "file.txt")
2126 run("commit", "-m", "initial")
2127 if dirty {
2128 if err := os.WriteFile(f, []byte("dirty\n"), 0o644); err != nil {
2129 t.Fatal(err)
2130 }
2131 }
2132 return dir
2133}
2134
2135// initDirtyGitRepo is initGitRepo(t, true) — kept as its own name since it
2136// reads better at call sites that only ever want a dirty tree.
2137func initDirtyGitRepo(t *testing.T) string {
2138 t.Helper()
2139 return initGitRepo(t, true)
2140}
2141
2142// TestDeployRefusesADerivedVersionFromADirtyTree: the default --version is
2143// the git short sha, so a dirty tree ships a stamp naming a commit the binary
2144// was not built from — which is precisely how a false "verified" happens, and
2145// why roll-console.sh refused it. An EXPLICIT --version is the operator saying
2146// they mean it and is not second-guessed. The argument is now runDeploy's
2147// flagWasSet answer ("the operator typed the flag"), not the value.
2148func TestDeployRefusesADerivedVersionFromADirtyTree(t *testing.T) {
2149 dir := initDirtyGitRepo(t)
2150 err := deployPreflight(dir, false /* --version not typed */)
2151 if err == nil {
2152 t.Fatal("a dirty tree with a derived version was accepted")
2153 }
2154 if !strings.Contains(err.Error(), "--version") {
2155 t.Fatalf("refusal %q should name the flag that overrides it", err)
2156 }
2157 if !strings.Contains(err.Error(), dir) {
2158 t.Fatalf("refusal %q should name the tree it inspected (%s)", err, dir)
2159 }
2160 if err := deployPreflight(dir, true /* --version typed */); err != nil {
2161 t.Fatalf("an explicit --version from a dirty tree = %v, want accepted", err)
2162 }
2163}
2164
2165// TestDeployAcceptsADerivedVersionFromACleanTree: the branch every other
2166// test in this file skips over. All 13 runDeploy call sites pass --version
2167// explicitly, and CI itself checks out a clean tree, so a preflight that
2168// wrongly refused every derived-version deploy from a clean checkout would
2169// ship green with nothing else in the suite to catch it.
2170func TestDeployAcceptsADerivedVersionFromACleanTree(t *testing.T) {
2171 dir := initGitRepo(t, false)
2172 if err := deployPreflight(dir, false /* --version not typed */); err != nil {
2173 t.Fatalf("a clean tree with a derived version = %v, want accepted", err)
2174 }
2175}
2176
2177// TestDeployPreflightAsksWhetherTheFlagWasTyped pins the wiring the guard now
2178// rests on: runDeploy passes flagWasSet(fs, "version"), which is alias-aware,
2179// so -v names the release just as explicitly as --version does — and neither
2180// is second-guessed from a dirty tree. Without this, the ordering hazard the
2181// old explicitVersion capture warned about would simply move into flags.go
2182// with nothing pointing at it.
2183func TestDeployPreflightAsksWhetherTheFlagWasTyped(t *testing.T) {
2184 for _, typed := range []string{"--version", "-v"} {
2185 fs := flag.NewFlagSet("deploy", flag.ContinueOnError)
2186 fs.SetOutput(io.Discard)
2187 versionFlag(fs, "")
2188 if err := fs.Parse([]string{typed, "abc1234"}); err != nil {
2189 t.Fatalf("parse %s: %v", typed, err)
2190 }
2191 if !flagWasSet(fs, "version") {
2192 t.Fatalf("%s typed but flagWasSet said no — the dirty-tree guard would refuse it", typed)
2193 }
2194 }
2195 fs := flag.NewFlagSet("deploy", flag.ContinueOnError)
2196 fs.SetOutput(io.Discard)
2197 versionFlag(fs, "")
2198 if err := fs.Parse(nil); err != nil {
2199 t.Fatal(err)
2200 }
2201 if flagWasSet(fs, "version") {
2202 t.Fatal("no version flag typed, but flagWasSet said yes — the guard would never fire")
2203 }
2204}
2205
2206// A deploy that ends with a "failed" domain must not print DNS guidance.
2207// Their records are correct — that is precisely what "failed" means — so
2208// "Create: ..." would send them to change the one part that is working,
2209// while the actual problem (ours) went unnamed.
2210func TestDeployDNSGuidanceOnAFailedCertificateNeverAsksForRecords(t *testing.T) {
2211 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2212 json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]any{{
2213 "host": "x.test",
2214 "state": "failed",
2215 "reason": "cert-failed",
2216 "detail": "DNS is correct, but we have not been able to obtain a certificate.",
2217 // A console that still stamps guidance alongside it must not
2218 // make this print "Create: ..." either.
2219 "guidance": "create a CNAME to edge.oncarlos.test",
2220 }}})
2221 }))
2222 defer ts.Close()
2223 domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
2224 if err != nil {
2225 t.Fatal(err)
2226 }
2227 msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"})
2228 if !ok {
2229 t.Fatal("a failed certificate should be reported, not silently dropped")
2230 }
2231 if strings.Contains(msg, "Create:") || strings.Contains(msg, "CNAME") {
2232 t.Errorf("message asks the member to change DNS that is already correct: %q", msg)
2233 }
2234 if !strings.Contains(msg, "not been able to obtain a certificate") {
2235 t.Errorf("message does not name the actual problem: %q", msg)
2236 }
2237}
2238