package main import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) func TestResolveDeployChannel(t *testing.T) { recs := []apiInstanceRecord{ {Host: "a.test", Channel: "canary/dev"}, {Host: "b.test", Channel: "canary/dev"}, } ch, hosts, err := resolveDeployChannel(recs, "", "") if err != nil || ch != "canary/dev" || len(hosts) != 2 { t.Fatalf("%q %v %v", ch, hosts, err) } recs[1].Channel = "edge" if _, _, err := resolveDeployChannel(recs, "", ""); err == nil { t.Fatal("two channels with no --channel/--host must error") } ch, hosts, err = resolveDeployChannel(recs, "b.test", "") if err != nil || ch != "edge" || len(hosts) != 1 || hosts[0] != "b.test" { t.Fatalf("host-scoped: %q %v %v", ch, hosts, err) } if _, _, err := resolveDeployChannel(nil, "", ""); err == nil { t.Fatal("no instances, no pipeline entry must error (hint: carlos instances create)") } // A pipelined app (release pipelines v2, Task 11): zero records resolves // to the pipeline's entry channel instead of erroring, with nothing to // watch yet. ch, hosts, err = resolveDeployChannel(nil, "", "edge") if err != nil || ch != "edge" || len(hosts) != 0 { t.Fatalf("no instances, pipeline entry edge: %q %v %v, want it resolved with no error", ch, hosts, err) } } // TestFilterChannelHosts pins the -channel+-host validation fix (task-9 // review, finding 3): a -host that names no instance at all must error — // not silently produce zero hosts, which would let deploy ship and promote // against a typo'd host and only then shrug "no instance follows ", // blaming the channel and never naming the host. A host that DOES exist but // simply isn't on the requested channel is not an error (the caller may be // pointing a channel at a host for the first time). func TestFilterChannelHosts(t *testing.T) { recs := []apiInstanceRecord{ {Host: "a.test", Channel: "edge"}, {Host: "b.test", Channel: "beta"}, } hosts, err := filterChannelHosts(recs, "edge", "") if err != nil || len(hosts) != 1 || hosts[0] != "a.test" { t.Fatalf("unscoped: %v %v", hosts, err) } hosts, err = filterChannelHosts(recs, "edge", "a.test") if err != nil || len(hosts) != 1 || hosts[0] != "a.test" { t.Fatalf("host-scoped, matching channel: %v %v", hosts, err) } hosts, err = filterChannelHosts(recs, "edge", "b.test") if err != nil { t.Fatalf("an existing host on a different channel must not error: %v", err) } if len(hosts) != 0 { t.Fatalf("b.test follows beta, not edge — must not appear: %v", hosts) } if _, err := filterChannelHosts(recs, "edge", "typo.test"); err == nil { t.Fatal("a --host naming no instance at all must error") } else if !strings.Contains(err.Error(), "typo.test") { t.Fatalf("error must name the offending host: %v", err) } } // TestStaticDeployHosts pins the host resolution (issue #112, extended by // #82's kind-aware gate): -host names one explicitly; without it the app's // own static records are the watch targets (each names a host an earlier // attach wrote); with neither, the deploy is refused — BEFORE ship and // promote run, since afterwards the only honest report left is "promoted, // nothing to watch". func TestStaticDeployHosts(t *testing.T) { hosts, err := staticDeployHosts("carlosframework.com", nil) if err != nil || len(hosts) != 1 || hosts[0] != "carlosframework.com" { t.Fatalf("with --host: %v %v", hosts, err) } recs := []apiInstanceRecord{{Host: "a.example.test", Kind: "static"}, {Host: "b.example.test", Kind: "static"}} hosts, err = staticDeployHosts("", recs) if err != nil || len(hosts) != 2 || hosts[0] != "a.example.test" || hosts[1] != "b.example.test" { t.Fatalf("no --host, records present: %v %v, want both record hosts watched", hosts, err) } _, err = staticDeployHosts("", nil) if err == nil { t.Fatal("an app with no records and no --host must be refused") } for _, want := range []string{ "no instance records", "--host carlosframework.com", } { if !strings.Contains(err.Error(), want) { t.Fatalf("refusal must mention %q:\n%s", want, err) } } } // TestStaticDeployChannel: one channel resolves silently; zero and several // are both refused, naming what was found and the flag that settles it. func TestStaticDeployChannel(t *testing.T) { ch, err := staticDeployChannel([]apiChannel{{Channel: "canary/site"}}) if err != nil || ch != "canary/site" { t.Fatalf("single channel: %q %v", ch, err) } _, err = staticDeployChannel(nil) if err == nil || !strings.Contains(err.Error(), "-channel") { t.Fatalf("no channels must be refused, asking for --channel: %v", err) } // A single channel the console could not verify is NOT a resolution: the // channels endpoint reports it as a row with an error, and promoting onto // it would be this verb writing over a pointer nobody can read. _, err = staticDeployChannel([]apiChannel{{Channel: "canary/site", Err: "bad signature"}}) if err == nil { t.Fatal("a single UNREADABLE channel must not resolve") } for _, want := range []string{"canary/site", "bad signature", "-channel"} { if !strings.Contains(err.Error(), want) { t.Fatalf("broken-channel refusal must mention %q:\n%s", want, err) } } _, err = staticDeployChannel([]apiChannel{{Channel: "stable"}, {Channel: "canary/site"}}) if err == nil { t.Fatal("several channels with no instances must be refused") } for _, want := range []string{"-channel", "canary/site, stable"} { if !strings.Contains(err.Error(), want) { t.Fatalf("refusal must mention %q (sorted, both listed):\n%s", want, err) } } } // TestDefaultDeployTimeoutOutlastsAStaticRecheck pins the one thing that // keeps a static deploy from failing on the clock rather than on the facts: a // static route trusts its resolved channel pointer for a whole recheck window // (internal/edge/static.go), and the deploy probe's cache-bust does not // shorten that — it defeats the edge's HTTP response cache, not the handler's // trust window. So the DEFAULT watch has to outlast one window with room for // the pointer read, the extract and a poll interval. Lower the default below // that and this test says so, instead of production saying "timed out" about a // deploy that had already landed. func TestDefaultDeployTimeoutOutlastsAStaticRecheck(t *testing.T) { if min := staticRecheckWindow + 30*time.Second; defaultDeployTimeout < min { t.Fatalf("defaultDeployTimeout %s must be at least %s (one %s recheck window plus slack)", defaultDeployTimeout, min, staticRecheckWindow) } } // TestDeployTargetsStaticVsBinary pins the kind gate. Zero instance records // mean two different things, and only ONE of them is the static shape: // // - static: no records by design — -host is required, and the channel comes // from -channel or the app's single channel. // - binary: records just haven't been created yet — the pre-existing // "promoted, nothing to watch" path, which must not grow a -host demand // or a static-flavoured watch line. func TestDeployTargetsStaticVsBinary(t *testing.T) { noChannels := func() (apiChannelsResult, error) { t.Helper() t.Error("the channels API was consulted on a path that must not need it") return apiChannelsResult{}, nil } oneChannel := func() (apiChannelsResult, error) { return apiChannelsResult{Channels: []apiChannel{{Channel: "canary/site"}}}, nil } // legacyNoPipeline is consulted (unlike noChannels above), but answers // with no channels and no pipeline — the "no instances + NO pipeline" // case, whose refusal must stay byte-identical to before Task 11. legacyNoPipeline := func() (apiChannelsResult, error) { return apiChannelsResult{}, nil } // Static, no instances: channel resolved from the app's one channel. ch, hosts, static, err := deployTargets(nil, "static", "", "site.test", oneChannel) if err != nil || ch != "canary/site" || len(hosts) != 1 || hosts[0] != "site.test" || !static { t.Fatalf("static/no-instances: %q %v %v %v", ch, hosts, static, err) } // Static, no instances, explicit -channel: the channels API is not needed. ch, _, static, err = deployTargets(nil, "static", "canary/other", "site.test", noChannels) if err != nil || ch != "canary/other" || !static { t.Fatalf("static/--channel: %q %v %v", ch, static, err) } // Static, no instances, no -host: refused. if _, _, _, err = deployTargets(nil, "static", "canary/site", "", noChannels); err == nil { t.Fatal("static with no instances and no --host must be refused") } // BINARY, no instances, explicit -channel: unchanged from before #112 — // no -host demanded, no channels lookup, no static treatment, and zero // hosts so runDeploy prints "promoted, nothing to watch" and exits 0. ch, hosts, static, err = deployTargets(nil, "binary", "canary/dev", "", noChannels) if err != nil { t.Fatalf("binary/no-instances/--channel must not error: %v", err) } if ch != "canary/dev" || len(hosts) != 0 || static { t.Fatalf("binary/no-instances/--channel: %q %v static=%v", ch, hosts, static) } // BINARY, no instances, no -channel, NO pipeline: still the old "create // one" refusal, never the static one — the channels API IS now // consulted (release pipelines v2, Task 11 — see // TestDeployTargetsNoInstancesPipelineEntry for the pipelined case), but // an app with no declared pipeline gets today's error text unchanged. _, _, static, err = deployTargets(nil, "binary", "", "", legacyNoPipeline) if err == nil || !strings.Contains(err.Error(), "carlos instances create") { t.Fatalf("binary/no-instances must keep its own refusal: %v", err) } if strings.Contains(err.Error(), "static site") { t.Fatalf("a binary app must never be told about static sites: %v", err) } if static { t.Fatal("binary must never take the static path") } // With records, kind is irrelevant: the instance path is byte-identical // for both kinds, and the channels API is not needed either way. recs := []apiInstanceRecord{{Host: "a.test", Channel: "canary/dev"}} for _, kind := range []string{"binary", "static"} { ch, hosts, static, err = deployTargets(recs, kind, "", "", noChannels) if err != nil || ch != "canary/dev" || len(hosts) != 1 || hosts[0] != "a.test" || static { t.Fatalf("%s with records: %q %v %v %v", kind, ch, hosts, static, err) } } } // TestDeployTargetsNoInstancesPipelineEntry pins the pipeline-entry fallback // itself (release pipelines v2, Task 11, spec §2's "the entry channel is // where deploy lands"): a brand-new app with no instance records and no // resolvable channel — no explicit -channel, no existing channel rows — // resolves straight to the pipeline's entry channel instead of erroring, on // BOTH the static and the non-static (binary/instance) path. The ambiguity // error and -channel override behaviour are otherwise untouched (pinned by // TestDeployTargetsStaticVsBinary/TestStaticDeployChannel/ // TestResolveDeployChannel). func TestDeployTargetsNoInstancesPipelineEntry(t *testing.T) { pipelined := func() (apiChannelsResult, error) { return apiChannelsResult{PipelineEntry: "edge", PipelineChannels: []string{"edge", "production"}}, nil } // Non-static (binary/instance) path: no records, no -channel, a // pipelined app — resolves to the entry channel, no error, and (like // the pre-existing no-instances shape) nothing to watch yet. ch, hosts, static, err := deployTargets(nil, "binary", "", "", pipelined) if err != nil { t.Fatalf("binary/no-instances/pipelined must not error: %v", err) } if ch != "edge" || len(hosts) != 0 || static { t.Fatalf("binary/no-instances/pipelined = %q %v static=%v, want the entry channel edge", ch, hosts, static) } // Static path: no records, -host given (required on the static path // regardless), no -channel, a pipelined app whose channels listing is // empty (nothing has ever been promoted onto it yet) — resolves to the // entry channel rather than staticDeployChannel's "no channels yet" // refusal. ch, hosts, static, err = deployTargets(nil, "static", "", "site.test", pipelined) if err != nil { t.Fatalf("static/no-instances/pipelined must not error: %v", err) } if ch != "edge" || len(hosts) != 1 || hosts[0] != "site.test" || !static { t.Fatalf("static/no-instances/pipelined = %q %v static=%v, want the entry channel edge, host watched", ch, hosts, static) } } func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) { var mu sync.Mutex ver := "old1234" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() w.Header().Set("X-Carlos-Version", ver) mu.Unlock() w.WriteHeader(http.StatusOK) // the 200 path this test pins stays unchanged })) defer srv.Close() go func() { time.Sleep(30 * time.Millisecond); mu.Lock(); ver = "new5678"; mu.Unlock() }() var out bytes.Buffer err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 2*time.Second, 10*time.Millisecond, nil) if err != nil { t.Fatal(err) } if !strings.Contains(out.String(), "x.test") { t.Fatalf("live line missing: %q", out.String()) } } func TestWatchVersionTimeoutDiagnostics(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", "old1234") })) defer srv.Close() var out bytes.Buffer err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 50*time.Millisecond, 10*time.Millisecond, nil) if err == nil { t.Fatal("must time out") } if !strings.Contains(err.Error(), "old1234") { t.Fatalf("diagnostic must name the version still serving: %v", err) } } // TestWatchVersionMatchingHeaderNon2xxNotLive pins the fix: the edge stamps // X-Carlos-Version before proxying, so a tenant that dies on boot can answer // with the new version header attached to a 502. Matching the header alone // used to report that as "live" and exit 0 on a crash-looping deploy — the // success arm must gate on status too. func TestWatchVersionMatchingHeaderNon2xxNotLive(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", "new5678") w.WriteHeader(http.StatusBadGateway) })) defer srv.Close() var out bytes.Buffer err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 50*time.Millisecond, 10*time.Millisecond, nil) if err == nil { t.Fatal("a 502 carrying the new version header must NOT be reported live") } if strings.Contains(out.String(), "live") { t.Fatalf("must not print live: %q", out.String()) } if !strings.Contains(err.Error(), "serving new5678 but HTTP 502") { t.Fatalf("diagnostic must name the version and the failing status: %v", err) } } // TestWatchVersionNoHeaderDiagnostic: 2xx with no header is the ONE shape // that really means "this edge predates X-Carlos-Version" — something is // being served here, it just isn't stamped. func TestWatchVersionNoHeaderDiagnostic(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer srv.Close() var out bytes.Buffer err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 50*time.Millisecond, 10*time.Millisecond, nil) if err == nil || !strings.Contains(err.Error(), "no X-Carlos-Version") { t.Fatalf("old-edge diagnostic: %v", err) } } // TestWatchVersionNonOKNoHeaderDiagnostic pins the split (review finding 2): // an activator 503 (wake failed) and a 404 (route not minted yet) also arrive // with no X-Carlos-Version, and calling those "the edge predates this // feature — the hourly pass will adopt it" sends the operator off to wait an // hour for a pass that cannot help. A non-2xx must name its status code and // the two things that actually produce it. func TestWatchVersionNonOKNoHeaderDiagnostic(t *testing.T) { for _, code := range []int{http.StatusServiceUnavailable, http.StatusNotFound} { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(code) })) var out bytes.Buffer err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 50*time.Millisecond, 10*time.Millisecond, nil) srv.Close() if err == nil { t.Fatalf("HTTP %d: must time out", code) } if !strings.Contains(err.Error(), fmt.Sprintf("not serving (HTTP %d)", code)) { t.Fatalf("HTTP %d diagnostic must name the status: %v", code, err) } if strings.Contains(err.Error(), "predates this feature") { t.Fatalf("HTTP %d must not be reported as an old edge: %v", code, err) } } } // TestWatchVersionSweepsTargetsConcurrently pins the concurrency fix: with N // targets, a single tick must probe them all in parallel, not one after // another. Each origin bumps a counter shared across all three servers on // entry and drops it on exit, tracking the high-water mark: sequential // probing can never push that mark above 1, while true concurrency pushes it // to len(targets). Asserting on the high-water mark rather than a wall-clock // budget means CI scheduler noise can slow the run down without flaking the // test — only true sequential probing can fail it. The 100ms sleep just // needs to be long enough that three goroutines' Gets overlap inside it. func TestWatchVersionSweepsTargetsConcurrently(t *testing.T) { const delay = 100 * time.Millisecond var inFlight atomic.Int64 var maxInFlight atomic.Int64 newSrv := func() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := inFlight.Add(1) for { cur := maxInFlight.Load() if n <= cur || maxInFlight.CompareAndSwap(cur, n) { break } } time.Sleep(delay) inFlight.Add(-1) w.Header().Set("X-Carlos-Version", "new5678") })) } srvs := []*httptest.Server{newSrv(), newSrv(), newSrv()} defer func() { for _, s := range srvs { s.Close() } }() targets := make([]watchTarget, len(srvs)) for i, s := range srvs { targets[i] = watchTarget{Host: fmt.Sprintf("h%d.test", i), Origin: s.URL} } var out bytes.Buffer err := watchVersion(&out, targets, "new5678", 5*time.Second, 10*time.Millisecond, nil) if err != nil { t.Fatal(err) } if got := maxInFlight.Load(); got != int64(len(targets)) { t.Fatalf("max in-flight = %d, want %d — targets were probed sequentially, not concurrently", got, len(targets)) } for _, target := range targets { if !strings.Contains(out.String(), target.Host) { t.Fatalf("live line missing for %s: %q", target.Host, out.String()) } } } // TestWatchVersionCacheBustsEveryPoll pins the fix for the edge's up-to-5min // cache TTL on X-Carlos-Version (internal/edge/edge.go, versionHeader): a // watch with no cache-bust could poll the same cached response for the whole // default 120s timeout and false-FAIL a deploy that already converged. Every // poll must carry a query parameter that differs from the previous one, so // the edge cache never serves a stale hit back to the watch. func TestWatchVersionCacheBustsEveryPoll(t *testing.T) { var mu sync.Mutex var queries []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() queries = append(queries, r.URL.RawQuery) mu.Unlock() w.Header().Set("X-Carlos-Version", "old1234") })) defer srv.Close() var out bytes.Buffer _ = watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 35*time.Millisecond, 10*time.Millisecond, nil) mu.Lock() defer mu.Unlock() if len(queries) < 2 { t.Fatalf("expected multiple polls against a version that never matches, got %d: %v", len(queries), queries) } for _, q := range queries { if q == "" { t.Fatalf("poll missing cache-bust query param: %v", queries) } } if queries[0] == queries[1] { t.Fatalf("cache-bust param did not vary between polls: %v", queries) } } // TestDeployingLine pins the destination line deploy prints before ship: // the one place every later path (held, watch, even a ship failure) gets // its "where is this going" from. Zero hosts is the no-instance path, // which keeps its own honest "nothing to watch" message instead. func TestDeployingLine(t *testing.T) { cases := []struct { name string hosts []string channel string want string }{ {"no hosts", nil, "stable", ""}, {"one host", []string{"hello.baf.oncarlos.com"}, "stable", "deploying https://hello.baf.oncarlos.com"}, {"pair", []string{"a.example.com", "b.example.com"}, "stable", "deploying 2 instances on stable"}, {"fleet", []string{"a.example.com", "b.example.com", "c.example.com"}, "stable", "deploying 3 instances on stable"}, } for _, c := range cases { if got := deployingLine(c.hosts, c.channel); got != c.want { t.Errorf("%s: deployingLine = %q, want %q", c.name, got, c.want) } } } // TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint (issue #131's tail): // the timeout report used to end with a hint that could not help — the wake // reason was journal-only and the suggested command was the box-local form. // With diagnostics wired, the report carries the host's last platform-stream // error and names the command that shows the whole timeline. func TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer srv.Close() var out bytes.Buffer var asked []string diag := &watchDiagnostics{ wakeReason: func(host string) string { asked = append(asked, host) return "wake failed: nothing is promoted for this instance's channel" }, logsHint: "carlos logs --app ledgerpay --account bde --stream platform", } err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 50*time.Millisecond, 10*time.Millisecond, diag) if err == nil { t.Fatal("watch should have timed out") } for _, want := range []string{ "wake failed: nothing is promoted", "full timeline: carlos logs --app ledgerpay --account bde --stream platform", } { if !strings.Contains(err.Error(), want) { t.Fatalf("timeout report missing %q:\n%v", want, err) } } if len(asked) != 1 || asked[0] != "x.test" { t.Fatalf("wakeReason asked for %v, want the one pending host", asked) } // A reason lookup that finds nothing adds nothing — no empty line. diag.wakeReason = func(string) string { return "" } err = watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678", 50*time.Millisecond, 10*time.Millisecond, diag) if err == nil || strings.Contains(err.Error(), "\n \n") { t.Fatalf("empty reason printed a blank line: %v", err) } } // ---- domain attach (Task 6, #82/#151) -------------------------------- // // The server half of the wire contract below does not exist yet (Task 8 // builds it, gated on #151 landing); these tests pin what THIS client sends // and expects against a hand-rolled fake console, exactly like // TestAPISessionRestartWireShape (restart_test.go) does for a door that // already shipped. // TestAPISessionDomainListAndAttachWireShape pins the wire shape the // console door (apidomains.go, #151's contract) answers: GET .../domains // for the list (rows carry dns_status/guidance), POST .../domains with a // {"host","kind","channel"} body for attach, decoding a 200 into // (created, resolvedRoute). func TestAPISessionDomainListAndAttachWireShape(t *testing.T) { var gotListPath string var gotAttachPath, gotAttachMethod, gotAttachBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/domains"): gotListPath = r.URL.RequestURI() json.NewEncoder(w).Encode(map[string]any{ "domains": []map[string]string{{"host": "existing.example.test", "route": "static", "status": "live", "dns_status": "pointing"}}, }) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"): gotAttachPath, gotAttachMethod = r.URL.RequestURI(), r.Method b, _ := io.ReadAll(r.Body) gotAttachBody = string(b) json.NewEncoder(w).Encode(map[string]any{"host": "fresh.example.test", "route": "fresh.example.test", "created": true}) default: http.NotFound(w, r) } })) defer ts.Close() sess := newTestSession(ts.URL) domains, err := sess.domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") if err != nil { t.Fatalf("domainList: %v", err) } if gotListPath != "/api/cli/apps/ops/sitely/domains" { t.Fatalf("list path = %s", gotListPath) } if len(domains) != 1 || domains[0].Host != "existing.example.test" || domains[0].DNSStatus != "pointing" { t.Fatalf("domains = %+v", domains) } created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", "fresh.example.test", "", "static", "canary/live", nil, nil, "publish the new site") if err != nil { t.Fatalf("domainAttach: %v", err) } if gotAttachMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotAttachMethod) } if gotAttachPath != "/api/cli/apps/ops/sitely/domains" { t.Fatalf("path = %s", gotAttachPath) } if !strings.Contains(gotAttachBody, `"host":"fresh.example.test"`) || !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) || !strings.Contains(gotAttachBody, `"message":"publish the new site"`) { t.Fatalf("body = %s, want host+kind+channel+message", gotAttachBody) } if strings.Contains(gotAttachBody, `"route"`) { t.Fatalf("body = %s, empty route must be omitted", gotAttachBody) } if !created || resolvedRoute != "fresh.example.test" { t.Fatalf("attach result = created %v route %q", created, resolvedRoute) } } // TestAPISessionDomainAttachRefusalSurfacesVerbatim pins the two refusal // shapes the wire contract names: 409 "hostname unavailable" (a host // already held — the binding note deploy's own gate exists to avoid ever // triggering) and a 422 policy refusal. Both must reach the caller in the // console's own words, via apiErrorFromBody, never rewritten. func TestAPISessionDomainAttachRefusalSurfacesVerbatim(t *testing.T) { for _, tc := range []struct { status int msg string }{ {http.StatusConflict, "hostname unavailable"}, {http.StatusUnprocessableEntity, "not a usable hostname"}, } { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status) json.NewEncoder(w).Encode(map[string]string{"error": tc.msg}) })) _, _, err := newTestSession(ts.URL).domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", "x.test", "", "static", "canary/live", nil, nil) ts.Close() if err == nil || !strings.Contains(err.Error(), tc.msg) { t.Fatalf("status %d: err = %v, want %q verbatim", tc.status, err, tc.msg) } } } // TestDomainNeedsAttach pins the gate itself, independent of any HTTP call: // a host answered by an existing instance record, or already named in // domainList, must never be attached again. func TestDomainNeedsAttach(t *testing.T) { recs := []apiInstanceRecord{{Host: "has-instance.test"}} domains := []apiDomainRecord{{Host: "already-claimed.test"}} cases := []struct { host string want bool }{ {"fresh.test", true}, {"has-instance.test", false}, {"already-claimed.test", false}, } for _, c := range cases { if got := domainNeedsAttach(c.host, recs, domains); got != c.want { t.Errorf("domainNeedsAttach(%q) = %v, want %v", c.host, got, c.want) } } } // deployFakeConsole is a minimal console double for `carlos deploy`'s own // tests: only the doors deploy itself calls (instances, domains, releases, // promote) — not internal/console's real handlers, since the CLI-facing // domains door does not exist on this branch yet (Task 8 builds it, // integrating against #151's real one). calls records every hit, in order, // so a test can assert attach happened before ship rather than merely that // it happened at all. type deployFakeConsole struct { shipMessage, promoteMessage string ts *httptest.Server mu sync.Mutex calls []string // "METHOD /path", in hit order domains []map[string]string instances []apiInstanceRecord // nil = the app has no records shipVersion string 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) attachStatus int // 0 = normal 201; else this status with an {"error": attachErrMsg} body attachErrMsg string // attachRouteOverride, when non-empty, is the "route" the POST /domains // response names instead of echoing the attached host — simulating the // door aliasing this host onto an existing route rather than minting it // as a first route, the review#1 case attachUnclaimedHost must read off // the door's own answer rather than assume. attachRouteOverride string // shipProbe is the app's build_probe, echoed by the ship door exactly as // handleAPIShip echoes it. "" means the app declares none and the key is // absent from the response — which is also what every console built // before the field existed answers. shipProbe string // promoteProposed, when true, makes POST .../promote answer 202 (a // guarded channel's proposal shape, release pipelines v2 §4) instead of // the normal 200 — the RIDER case, Task 11. promoteProposed bool } func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole { t.Helper() fc := &deployFakeConsole{shipVersion: shipVersion} mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", func(w http.ResponseWriter, r *http.Request) { fc.record(r) fc.mu.Lock() recs := fc.instances fc.mu.Unlock() if recs == nil { recs = []apiInstanceRecord{} } json.NewEncoder(w).Encode(map[string]any{"instances": recs}) }) mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) { fc.record(r) fc.mu.Lock() domains, status := fc.domains, fc.domainsListStatus fc.mu.Unlock() if status != 0 { // A plain body with no JSON "error" field, on purpose: this is // what an unregistered route (old console) OR a bare 5xx (an // unwell one) both look like on the wire — neither carries a // server diagnosis, which is exactly the shape apiErrorFromBody // treats as "not a real diagnosis, just a status". w.WriteHeader(status) return } json.NewEncoder(w).Encode(map[string]any{"domains": domains}) }) mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) { fc.record(r) fc.mu.Lock() status, errMsg, routeOverride := fc.attachStatus, fc.attachErrMsg, fc.attachRouteOverride fc.mu.Unlock() if status != 0 { w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": errMsg}) return } var in struct { Host string `json:"host"` } json.NewDecoder(r.Body).Decode(&in) route := in.Host if routeOverride != "" { route = routeOverride } json.NewEncoder(w).Encode(map[string]any{"host": in.Host, "route": route, "created": true}) }) mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) { fc.record(r) mr, err := r.MultipartReader() if err != nil { t.Error(err) return } part, err := mr.NextPart() if err != nil { t.Error(err) return } var meta struct { Message string `json:"message"` } if err := json.NewDecoder(part).Decode(&meta); err != nil { t.Error(err) return } fc.mu.Lock() fc.shipMessage = meta.Message fc.mu.Unlock() fc.mu.Lock() ver, probe := fc.shipVersion, fc.shipProbe fc.mu.Unlock() w.WriteHeader(http.StatusCreated) body := map[string]string{"version": ver} if probe != "" { body["build_probe"] = probe } json.NewEncoder(w).Encode(body) }) mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) { fc.record(r) var input struct { Message string `json:"message"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Error(err) return } fc.mu.Lock() fc.promoteMessage = input.Message fc.mu.Unlock() fc.mu.Lock() proposed := fc.promoteProposed fc.mu.Unlock() if proposed { w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(map[string]any{ "result": "proposed", "pending_id": "pend-1", "needed": 2, "confirmed": 0, "console_url": "https://console.example.test/a/ops/apps/sitely/pending/pend-1", }) return } json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false}) }) fc.ts = httptest.NewServer(mux) t.Cleanup(fc.ts.Close) return fc } // setDomains is the mutex-guarded way to change what the fake's GET // .../domains answers once the server is already live (t.Setenv-style // direct field writes are only safe before the httptest.Server starts // serving requests). func (fc *deployFakeConsole) setDomains(domains []map[string]string) { fc.mu.Lock() defer fc.mu.Unlock() fc.domains = domains } func (fc *deployFakeConsole) setInstances(recs []apiInstanceRecord) { fc.mu.Lock() defer fc.mu.Unlock() fc.instances = recs } func (fc *deployFakeConsole) setDomainsListStatus(status int) { fc.mu.Lock() defer fc.mu.Unlock() fc.domainsListStatus = status } func (fc *deployFakeConsole) setAttachRefusal(status int, msg string) { fc.mu.Lock() defer fc.mu.Unlock() fc.attachStatus, fc.attachErrMsg = status, msg } func (fc *deployFakeConsole) setShipProbe(probe string) { fc.mu.Lock() defer fc.mu.Unlock() fc.shipProbe = probe } func (fc *deployFakeConsole) setAttachRouteOverride(route string) { fc.mu.Lock() defer fc.mu.Unlock() fc.attachRouteOverride = route } func (fc *deployFakeConsole) setPromoteProposed(proposed bool) { fc.mu.Lock() defer fc.mu.Unlock() fc.promoteProposed = proposed } func (fc *deployFakeConsole) record(r *http.Request) { fc.mu.Lock() defer fc.mu.Unlock() fc.calls = append(fc.calls, r.Method+" "+r.URL.Path) } // callIndex returns the index of the first recorded call containing sub, or // -1 — used both to assert a call happened and, via two calls, that one // happened before another. func (fc *deployFakeConsole) callIndex(sub string) int { fc.mu.Lock() defer fc.mu.Unlock() for i, c := range fc.calls { if strings.Contains(c, sub) { return i } } return -1 } // TestDeployAutoAttachesUnclaimedStaticHost pins the auto-attach behaviour // end to end: a -host deploy against a fresh static target (no instance // record, no domainList entry) attaches the host BEFORE shipping and says // so; a repeated deploy against the SAME host, once domainList already // names it, must not re-POST attach at all — the binding note's 409 "host // unavailable" is what a re-attach gets, so this is what keeps a repeated // `carlos deploy` idempotent rather than erroring on its own prior success. func TestDeployAutoAttachesUnclaimedStaticHost(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const ( app = "sitely" host = "sitely.example.test" channel = "canary/site" ) fc := newDeployFakeConsole(t, "sha0001") credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } // The watch leg's own edge: always answers whatever version this run // expects, so the test is about the attach step, not about racing a // slow-to-converge edge. var edgeVersion atomic.Value edgeVersion.Store("sha0001") edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", edgeVersion.Load().(string)) })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) out := buf.String() attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains") if attachIdx < 0 { t.Fatalf("attach was never POSTed: %v", fc.calls) } if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx { t.Fatalf("ship (index %d) happened before attach (index %d): %v", shipIdx, attachIdx, fc.calls) } if want := "attached " + host + " (first route: static, channel " + channel + ")"; !strings.Contains(out, want) { t.Fatalf("stdout missing %q:\n%s", want, out) } // --- re-run: domainList now names the host, as the console would once // the first attach landed. The second deploy must NOT re-POST attach. -- fc.mu.Lock() fc.domains = []map[string]string{{"host": host, "route": "static", "dns_status": "pointing"}} fc.calls = nil fc.shipVersion = "sha0002" fc.mu.Unlock() edgeVersion.Store("sha0002") buf.Reset() runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) out = buf.String() if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { t.Fatalf("a host already in domainList must not trigger an attach POST: %v", fc.calls) } if strings.Contains(out, "attached "+host) { t.Fatalf("stdout claims an attach that must not have happened:\n%s", out) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { t.Fatalf("ship never happened on the re-run: %v", fc.calls) } checkNoRegistry() } // TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute is final-review // finding #1: attachUnclaimedHost must print off the door's OWN answer, not // assume "first route" just because that's what it asked for. A console // that resolves this deploy's -host to a DIFFERENT route than the host // itself — the alias case, e.g. because the app already had a ready route // by the time the door decided (spec §1's yes/no split lives door-side, // not in what this call sent) — must get the "aliases " line with // no channel named; a channel belongs to the primary route, and an alias // serves whatever channel that primary already follows. func TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const ( app = "sitely" host = "alias.example.test" primary = "sitely.canary.oncarlos.test" channel = "canary/site" ) fc := newDeployFakeConsole(t, "sha0001") fc.setAttachRouteOverride(primary) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", "sha0001") })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) out := buf.String() if want := "attached " + host + " (aliases " + primary + ")"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want the alias wording naming the door's resolved route %q", out, primary) } if strings.Contains(out, "first route") || strings.Contains(out, "channel "+channel) { t.Fatalf("stdout wrongly named a channel for an alias attach (a channel belongs to the primary):\n%s", out) } checkNoRegistry() } // TestDeployExitsHonestlyWhenDNSIsNotPointingYet pins the exit contract for // a connection-class watch timeout: the deploy itself already succeeded // (shipped, promoted) but the host never answered at all — the shape a // domain that has not been pointed here yet produces — so the exit text // must say DNS specifically, carry the console's own guidance, and still // exit nonzero: "deploy failed" would be a lie (the release IS live), and a // zero exit would be a worse one (the URL does not serve it). // // Run as a subprocess (buildCarlos + exec.Command, carlosTestEnv's own // pattern): log.Fatal exits the process, which would take the rest of this // package's tests down with it if this ran in-process. The host is a bare // loopback address with nothing listening on :443 — an instant, hermetic // "unreachable" that needs no real DNS or outbound network access, and // exercises the exact diag class (watchVersion's "unreachable: ...", from // http.Client.Get itself failing) a not-yet-pointing domain produces. The // fake console already lists the host as claimed (domainList has an entry // with guidance), so auto-attach is a no-op here — this is the SECOND deploy // to an already-attached host shape, not the first. func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) { bin := buildCarlos(t) const ( app = "sitely" host = "127.0.0.1" channel = "canary/site" version = "sha0001" guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test" ) fc := newDeployFakeConsole(t, version) fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}}) credPath := filepath.Join(t.TempDir(), "credentials") t.Setenv("CARLOS_CREDENTIALS", credPath) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static", "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL, "-timeout", "100ms", siteDir) // carlosTestEnv is the repo rule for a subprocess that shells out to the // carlos binary — it masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE to its // own scratch space; the explicit CARLOS_CREDENTIALS below wins over // that default because os/exec keeps only the LAST value for a // duplicate key, landing the subprocess on the exact credential file // this test just wrote to (the same path this process's own // CARLOS_CREDENTIALS was just pointed at, via t.Setenv above). cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule (carlosTestEnv's own reasoning) out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("DNS-not-pointing deploy exited 0:\n%s", out) } want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide + ". Watch with 'carlos domains list --app " + app + " --account ops'; re-run this deploy any time — the shipped release is already promoted." if !strings.Contains(string(out), want) { t.Fatalf("exit text = %q, want it to contain %q", out, want) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { t.Fatalf("host already in domainList must not have triggered an attach POST: %v", fc.calls) } } // TestDeployDNSGuidanceMessageBothArms pins review fix #1: a host-match alone is // not enough to say "DNS is not pointing" — DNS == "pointing" with a // connection-class failure means something else (the certificate, almost // always) is what's still missing, and printing an empty "Create: ." would // be a lie by omission. Both arms are driven through a real domainList call // against a fake console (not hand-built apiDomainRecord values), so the test // also proves the DNS/Guidance fields survive the wire round trip into the // message. A third case pins the fall-through: no DNS verdict and no // guidance yet must defer to the generic timeout report rather than guess. func TestDeployDNSGuidanceMessageBothArms(t *testing.T) { fakeDomainsServer := func(t *testing.T, domain map[string]string) []apiDomainRecord { t.Helper() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{domain}}) })) defer ts.Close() domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") if err != nil { t.Fatal(err) } return domains } // Arm 1: DNS is pointing, so a connection-class failure means the // certificate isn't issued yet — never "DNS is not pointing" (that // would be false), and never an empty "Create: ." (pointing carries no // guidance at all). domains := fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "pointing"}) msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}) if !ok { t.Fatal("pointing arm: want ok") } for _, want := range []string{ "DNS for x.test is pointing here, but the certificate is not issued yet", "re-run this deploy shortly", "carlos domains list --app sitely --account ops", } { if !strings.Contains(msg, want) { t.Fatalf("pointing-arm message = %q, missing %q", msg, want) } } if strings.Contains(msg, "Create:") || strings.Contains(msg, "not pointing") { t.Fatalf("pointing arm must not claim DNS is not pointing or print an empty guidance line: %q", msg) } // Arm 2: DNS is elsewhere, with real guidance — the original report. domains = fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "elsewhere", "guidance": "create a CNAME"}) msg, ok = dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}) if !ok { t.Fatal("not-pointing arm: want ok") } for _, want := range []string{ "DNS for x.test is not pointing at this deployment yet. Create: create a CNAME", "re-run this deploy any time", "carlos domains list --app sitely --account ops", } { if !strings.Contains(msg, want) { t.Fatalf("not-pointing-arm message = %q, missing %q", msg, want) } } // Fall-through: DNS never checked at all (no dns field, no guidance) — // neither arm applies, so the caller must fall back to the generic // watchVersion timeout report rather than print a guessed-at message. domains = fakeDomainsServer(t, map[string]string{"host": "x.test"}) if _, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { t.Fatal("no DNS verdict and no guidance yet must fall through, not print a message") } // A host absent from domains entirely must also fall through. if _, ok := dnsGuidanceMessage(domains, "not-listed.test", "sitely", apiAcct{Seg: "ops"}); ok { t.Fatal("a host absent from domainList must fall through, not print a message") } } // TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure // pins the third status: "unresolved" means the DNS LOOKUP failed (our // resolver, a timeout, a SERVFAIL) — not that the member's record is // missing. Printing "DNS ... is not pointing ... Create: ..." here would // tell them to fix something that was never wrong; the honest move is the // same fall-through as "never checked", so the caller reports the plain // watchVersion timeout instead of a guess. func TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{ {"host": "x.test", "dns_status": "unresolved"}, }}) })) defer ts.Close() domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") if err != nil { t.Fatal(err) } if msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { t.Fatalf("unresolved must fall through, not print a message: ok=%v msg=%q", ok, msg) } // Defensive: even if some future server bug stamped guidance alongside // "unresolved", this function must still refuse it rather than trust an // upstream promise it cannot verify. ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{ {"host": "x.test", "dns_status": "unresolved", "guidance": "create a CNAME"}, }}) })) defer ts2.Close() domains2, err := newTestSession(ts2.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") if err != nil { t.Fatal(err) } if msg, ok := dnsGuidanceMessage(domains2, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { t.Fatalf("unresolved with stray guidance must still fall through: ok=%v msg=%q", ok, msg) } } // TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor pins review // fix #2: silence is only right when nothing needed attaching anyway. A // console that predates the /domains door 404s the list; when the target // host is genuinely unclaimed (absent from the instance list — always true // on the no-instance static path), the member is told the door is missing // and the host needs a console-side attach, and the deploy still PROCEEDS // (ships and promotes) rather than fataling over a feature this console // simply cannot offer yet. func TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) const ( app = "sitely" host = "sitely.example.test" channel = "canary/site" ) fc := newDeployFakeConsole(t, "sha0001") fc.setDomainsListStatus(http.StatusNotFound) // old console: the route itself is unregistered credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", "sha0001") })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) out := buf.String() if !strings.Contains(out, "no domains door") || !strings.Contains(out, host) { t.Fatalf("stdout missing the no-domains-door note naming %s:\n%s", host, out) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { t.Fatalf("no domains door — attach must never have been attempted: %v", fc.calls) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { t.Fatalf("deploy must still proceed to ship: %v", fc.calls) } if !strings.Contains(out, "live https://"+host) { t.Fatalf("deploy must still complete and confirm the URL:\n%s", out) } } // TestDeployAutoAttachDegradesOnDomainsListServerError pins review fix #3: // a transport-level or 5xx domainList failure must warn and continue, not // abort a deploy that would otherwise succeed — the door exists but is // unwell, which is not the same problem as an unauthorized credential. func TestDeployAutoAttachDegradesOnDomainsListServerError(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) const ( app = "sitely" host = "sitely.example.test" channel = "canary/site" ) fc := newDeployFakeConsole(t, "sha0001") fc.setDomainsListStatus(http.StatusInternalServerError) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", "sha0001") })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) out := buf.String() if !strings.Contains(out, "could not check custom domains") || !strings.Contains(out, host) { t.Fatalf("stdout missing the degrade warning naming %s:\n%s", host, out) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { t.Fatalf("a domainList 500 must not be treated as an unclaimed host — attach must never be attempted: %v", fc.calls) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { t.Fatalf("deploy must still proceed to ship despite the domainList 500: %v", fc.calls) } if !strings.Contains(out, "live https://"+host) { t.Fatalf("deploy must still complete and confirm the URL:\n%s", out) } } // TestDeployAttachRefusalStopsBeforeShip pins the no-partial-ship // invariant: when the console refuses the attach POST (422 — a policy // refusal, e.g. an unshaped host), the deploy must exit nonzero WITHOUT // ever reaching ship — a half-done deploy (bytes shipped, but the route // never attached) would be worse than stopping before anything is written. // Run as a subprocess: log.Fatal exits the process. func TestDeployAttachRefusalStopsBeforeShip(t *testing.T) { bin := buildCarlos(t) const ( app = "sitely" host = "sitely.example.test" channel = "canary/site" version = "sha0001" ) fc := newDeployFakeConsole(t, version) fc.setAttachRefusal(http.StatusUnprocessableEntity, "not a usable hostname") credPath := filepath.Join(t.TempDir(), "credentials") t.Setenv("CARLOS_CREDENTIALS", credPath) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static", "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL, "-timeout", "5s", siteDir) cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") cmd.Dir = t.TempDir() out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("deploy with a refused attach exited 0:\n%s", out) } if !strings.Contains(string(out), "not a usable hostname") { t.Fatalf("exit text missing the console's refusal verbatim:\n%s", out) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx >= 0 { t.Fatalf("ship must never happen after a refused attach: %v", fc.calls) } } // TestDeployTargetsStaticKindAware pins the kind-aware static gate (task-8 // review, important #1): a Kind=="static" provision record — the one a // first-route domain attach writes — is a static-deploy TARGET, not an // instance that pulls the deploy off the static path. Before this gate, // the record the FIRST `deploy -kind static -host ` left behind made // every redeploy lose the recheck note and the DNS-guidance exit, and made // a second static -host fatal with "no instance with host". func TestDeployTargetsStaticKindAware(t *testing.T) { staticRec := apiInstanceRecord{Host: "site.example.test", Channel: "canary/site", Kind: "static"} channelsCalled := false channels := func() (apiChannelsResult, error) { channelsCalled = true return apiChannelsResult{Channels: []apiChannel{{Channel: "canary/other"}}}, nil } // Redeploy: -host names the record — static path, channel straight from // the record, no channels round trip. ch, hosts, static, err := deployTargets([]apiInstanceRecord{staticRec}, "static", "", "site.example.test", channels) if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" { 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) } if channelsCalled { t.Fatal("redeploy consulted the channels listing — the record already knows its channel") } // Second static host: unrecorded -host stays on the static path (it is // about to be attached), channel resolved from the app's channels. ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "second.example.test", channels) if err != nil || !static || len(hosts) != 1 || hosts[0] != "second.example.test" || ch != "canary/other" { t.Fatalf("second host = ch %q hosts %v static %v err %v, want the static path with the listed channel", ch, hosts, static, err) } if !channelsCalled { t.Fatal("second host never consulted the channels listing") } // No -host at all — the obvious redeploy invocation once a domain is // attached: the records are the watch targets AND their agreed channel // is the promote target, with no -channel and NO channels round trip. A // listing that happened to hold one other channel must not win here — // promoting onto it while watching hosts that follow the record's // channel would verify the wrong thing (re-review, important #1). channelsCalled = false ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "", channels) if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" { 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) } if channelsCalled { t.Fatal("no --host redeploy consulted the channels listing — the records already agree on a channel") } // Records DISAGREEING on a channel resolve nothing: fall through to the // channels listing (whose single entry answers, and whose plural case // refuses naming -channel) rather than guessing between the records. twoChannels := []apiInstanceRecord{ {Host: "site.example.test", Channel: "canary/site", Kind: "static"}, {Host: "blog.example.test", Channel: "canary/blog", Kind: "static"}, } ch, hosts, static, err = deployTargets(twoChannels, "static", "", "", channels) if err != nil || !static || len(hosts) != 2 || ch != "canary/other" { t.Fatalf("disagreeing records = ch %q hosts %v static %v err %v, want the listing's channel via fallthrough", ch, hosts, static, err) } if !channelsCalled { t.Fatal("disagreeing records never fell through to the channels listing") } // A real instance record still pulls a -kind static deploy onto the // instance path, exactly as before the gate existed. instRec := apiInstanceRecord{Host: "app.example.test", Channel: "stable"} ch, hosts, static, err = deployTargets([]apiInstanceRecord{instRec}, "static", "", "app.example.test", channels) if err != nil || static || ch != "stable" || len(hosts) != 1 { t.Fatalf("instance record = ch %q hosts %v static %v err %v, want the instance path", ch, hosts, static, err) } } // TestDeployStaticRedeployWithRecordGetsDNSGuidance is review important #1's // regression case (a): the SECOND `deploy -kind static -host ` — after // the first one's attach left a static provision record — must still take // the static path and still exit through the DNS-guidance report while the // domain is unpointed, not through a bare watch timeout. No -channel either: // the record resolves it. Subprocess, because the exit path is log.Fatal. func TestDeployStaticRedeployWithRecordGetsDNSGuidance(t *testing.T) { bin := buildCarlos(t) const ( app = "sitely" host = "127.0.0.1" // nothing listens on :443 — instant, hermetic "unreachable" channel = "canary/site" version = "sha0002" guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test" ) fc := newDeployFakeConsole(t, version) fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel, Kind: "static"}}) fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}}) credPath := filepath.Join(t.TempDir(), "credentials") t.Setenv("CARLOS_CREDENTIALS", credPath) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil { t.Fatal(err) } // No -channel: the static record must resolve it (kind-aware gate). cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static", "-version", version, "-host", host, "-console", fc.ts.URL, "-timeout", "100ms", siteDir) cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("redeploy to an unpointed domain exited 0:\n%s", out) } want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide if !strings.Contains(string(out), want) { t.Fatalf("exit text = %q, want the DNS-guidance report %q", out, want) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { t.Fatalf("host already recorded and claimed — attach must not be re-POSTed: %v", fc.calls) } if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { t.Fatalf("redeploy never shipped: %v", fc.calls) } } // TestDeploySecondStaticHostAttaches is review important #1's case (b), // fixed outright by the kind-aware gate: an app whose only record is the // first domain's static record accepts a deploy to a SECOND fresh host — // static path, attach POSTed before ship — instead of the pre-fix fatal // `no instance with host "

"`. func TestDeploySecondStaticHostAttaches(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const ( app = "sitely" first = "sitely.example.test" second = "more.example.test" channel = "canary/site" ) fc := newDeployFakeConsole(t, "sha0002") fc.setInstances([]apiInstanceRecord{{Host: first, Channel: channel, Kind: "static"}}) fc.setDomains([]map[string]string{{"host": first, "route": "", "dns_status": "pointing"}}) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Carlos-Version", "sha0002") })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", "-channel", channel, "-host", second, "-timeout", "5s", siteDir}) out := buf.String() attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains") if attachIdx < 0 { t.Fatalf("second static host was never attached: %v", fc.calls) } if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx { t.Fatalf("ship (index %d) ran before attach (index %d): %v", shipIdx, attachIdx, fc.calls) } if !strings.Contains(out, "attached "+second) { t.Fatalf("stdout missing the attach line for %s:\n%s", second, out) } if !strings.Contains(out, "live https://"+second) { t.Fatalf("deploy did not complete against the second host:\n%s", out) } checkNoRegistry() } // TestDeployPromoteProposedExitsCleanly pins the RIDER from Task 10's // review: when the ship succeeds but the promote a pipelined `carlos // deploy` attempts lands on a GUARDED channel, the console answers 202 — // *apiProposedError, not a refusal — and `carlos deploy` must exit CLEANLY // (status 0), print a message that says BOTH halves of the truth (the ship // already landed; the promotion needs confirming), and never reach the // watch loop: nothing converges anywhere until the proposal is confirmed, // so there is nothing yet to watch for. Before this fix, deploy.go's // promote error handling was a bare log.Fatalf that would have reported // the ship as failed outright (exit 1, a misleading "deploy: promote: // ..." line) — the CLI-lies-about-its-outcome bug apiProposedError exists // to close everywhere else it is checked (runPromote, runRollback, // applyPipelinePut). func TestDeployPromoteProposedExitsCleanly(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const app = "widgetco" fc := newDeployFakeConsole(t, "sha0009") // An instance record with hosts to watch, so a regression that reaches // the watch loop anyway has something to hang against — proof by // consequence that the fix's early return is what's keeping it away, // not an accidental "nothing to watch" shortcut. fc.setInstances([]apiInstanceRecord{{Host: "widgetco-a.example.test", Channel: "canary/x"}}) fc.setPromoteProposed(true) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } binDir := t.TempDir() binPath := filepath.Join(binDir, "widgetco") if err := os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "binary", "-version", "sha0009", "-channel", "canary/x", "--message", "Fix invoice rounding", "-timeout", "5s", binPath}) out := buf.String() if promoteIdx := fc.callIndex("POST /api/cli/apps/ops/widgetco/promote"); promoteIdx < 0 { t.Fatalf("promote was never called: %v", fc.calls) } for _, want := range []string{"shipped widgetco sha0009", "ship succeeded", "sha0009", "canary/x", "proposed", "2 confirmation", "console.example.test/a/ops/apps/sitely/pending/pend-1"} { if !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } } if strings.Contains(out, "deploy: promote:") { t.Fatalf("stdout = %q, must not print the generic promote-failure prefix — this is not a failure", out) } // The proof that the watch loop never ran: watchVersion's own success // line ("live https://", the exact text // TestDeploySecondStaticHostAttaches pins) never appears — if deploy had // fallen through to the watch loop against a host // (widgetco-a.example.test) nothing serves, this test would instead // have blocked for close to -timeout before a log.Fatal, not returned // promptly with a clean message. if strings.Contains(out, "live https://") { t.Fatalf("stdout = %q, must never reach the watch loop (nothing converges until the proposal is confirmed)", out) } fc.mu.Lock() shipMessage, promoteMessage := fc.shipMessage, fc.promoteMessage fc.mu.Unlock() if shipMessage != "Fix invoice rounding" || promoteMessage != shipMessage { t.Fatalf("deploy messages: ship=%q promote=%q", shipMessage, promoteMessage) } checkNoRegistry() } // TestDeployTargetsStaticNoHostIsTyped pins the refusal runDeploy answers by // MINTING the app's platform host (static-platform-host design §3.2). It is // typed for errAmbiguousChannel's reason: a refusal matched by text would // eventually mint in answer to a different sentence that merely mentioned a // hostname. func TestDeployTargetsStaticNoHostIsTyped(t *testing.T) { _, _, static, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{PipelineEntry: "edge"}, nil }) if !static { t.Fatal("static = false, want true") } if !errors.Is(err, errNoStaticHost) { t.Fatalf("err = %v, want errNoStaticHost", err) } } // The mint needs a channel, and the only place it can come from is the // resolution deployTargets already does — so errNoStaticHost travels WITH a // usable channel. func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) { channel, hosts, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{PipelineEntry: "edge"}, nil }) if !errors.Is(err, errNoStaticHost) { t.Fatalf("err = %v, want errNoStaticHost", err) } if channel != "edge" { t.Fatalf("channel = %q, want %q — the mint needs it", channel, "edge") } if len(hosts) != 0 { t.Fatalf("hosts = %v, want none", hosts) } } // Both unresolved: the host refusal names something the member can supply, // so it is the one that surfaces. func TestDeployTargetsStaticNoHostNoChannelPrefersHostRefusal(t *testing.T) { channel, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil }) if !errors.Is(err, errNoStaticHost) { t.Fatalf("err = %v, want errNoStaticHost", err) } // And the channel is EMPTY here, which is the whole reason runDeploy // guards its mint on one: a record written with no channel is defaulted // to stable by the door, and nothing can change it afterwards. if channel != "" { t.Fatalf("channel = %q, want empty — runDeploy must have nothing to mint onto", channel) } } // Unchanged: a -host whose static record names a channel resolves from the // record and never lists channels. func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) { recs := []apiInstanceRecord{{Host: "site.example.com", Kind: "static", Channel: "beta"}} channel, hosts, static, err := deployTargets(recs, "static", "", "site.example.com", func() (apiChannelsResult, error) { t.Error("channels() called; the record already names the channel") return apiChannelsResult{}, nil }) if err != nil || !static || channel != "beta" || len(hosts) != 1 || hosts[0] != "site.example.com" { t.Fatalf("got %q %v %v %v", channel, hosts, static, err) } } // TestMintPlatformHostSendsKindAndChannel pins the two fields the mint // cannot get wrong: the record's kind, and an EXPLICIT channel (an omitted // one is defaulted to stable by the door, and no door can change a record's // channel afterwards). func TestMintPlatformHostSendsKindAndChannel(t *testing.T) { var create map[string]any var enabled bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/instances-manifest"): enabled = true io.WriteString(w, `{"app":"mysite","domain":"bab.oncarlos.test"}`) case strings.HasSuffix(r.URL.Path, "/instances"): if err := json.NewDecoder(r.Body).Decode(&create); err != nil { t.Errorf("decode create: %v", err) } w.WriteHeader(http.StatusCreated) io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`) default: t.Errorf("unexpected path %s", r.URL.Path) } })) defer ts.Close() out := captureStdout(t) host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge", "publish the new site") if err != nil { t.Fatalf("mintPlatformHost: %v", err) } if !enabled { t.Fatal("the manifest was never written; the create door refuses without one") } if host != "mysite.bab.oncarlos.test" { t.Fatalf("host = %q, want the app's label under the manifest's domain", host) } if create["kind"] != "static" { t.Fatalf("create kind = %#v, want static", create["kind"]) } if create["channel"] != "edge" { t.Fatalf("create channel = %#v, want an EXPLICIT edge", create["channel"]) } if create["message"] != "publish the new site" { t.Fatalf("create message = %#v", create["message"]) } if got := out.String(); !strings.Contains(got, "minted") || !strings.Contains(got, host) { t.Fatalf("stdout = %q, want it to name what was minted", got) } } // A deployment with no platform namespace (503) and one whose instance // domain an operator pinned elsewhere (409) both mean the same thing: this // deployment will not mint a platform host for this app. Classified by // STATUS, never by reading the message. func TestMintPlatformHostUnavailableIsClassifiedByStatus(t *testing.T) { for _, tc := range []struct { name string status int body string }{ {"no apps domain", http.StatusServiceUnavailable, `{"error":"this console has no platform apps domain configured"}`}, {"operator-pinned domain", http.StatusConflict, `{"error":"instances for mysite are managed under go.tito.io"}`}, } { t.Run(tc.name, func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status) io.WriteString(w, tc.body) })) defer ts.Close() _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge") if err == nil { t.Fatal("want a refusal") } if !enableUnavailable(err) { t.Fatalf("err = %v, want it classified unavailable", err) } }) } } // Every other failure is a real one and must NOT be classified as "this // deployment has no platform host" — a 403 means the credential lost // authority, which is worth stopping the deploy for. func TestMintPlatformHostForbiddenIsNotUnavailable(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) io.WriteString(w, `{"error":"not a member"}`) })) defer ts.Close() _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge") if err == nil || enableUnavailable(err) { t.Fatalf("err = %v, want an unclassified failure", err) } } // ---- the build probe: watching the app's own report, not the edge's header -- // TestWatchProbeMatchesAWholeToken: the probe body must carry the build as a // whitespace-delimited token. Substring matching would let abc123 satisfy a // watch for abc1234 — and short git shas make that a real collision, not a // theoretical one. func TestWatchProbeMatchesAWholeToken(t *testing.T) { var body atomic.Value body.Store("ok abc123") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/healthz" { t.Errorf("watch dialled %q, want /healthz", r.URL.Path) } if r.URL.Query().Get("carlos-deploy-probe") == "" { t.Error("probe poll carried no cache-buster — the edge caches a proxied 2xx for up to 5 minutes") } fmt.Fprintln(w, body.Load().(string)) })) defer srv.Close() targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}} err := watchVersion(io.Discard, targets, "abc1234", 300*time.Millisecond, 50*time.Millisecond, nil) if err == nil { t.Fatal(`watch for abc1234 succeeded against a body of "ok abc123" — substring match, not token match`) } body.Store("ok abc1234") if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil { t.Fatalf("watch against the matching build = %v, want success", err) } } // TestWatchProbeTimeoutNamesTheBody: a binary built without the version // ldflag reports "dev" forever, so the deploy fails while the roll was // perfectly healthy. The report has to make that self-diagnosing rather than // saying "timed out". func TestWatchProbeTimeoutNamesTheBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok dev") })) defer srv.Close() var buf bytes.Buffer targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}} err := watchVersion(&buf, targets, "abc1234", 100*time.Millisecond, 50*time.Millisecond, nil) if err == nil { t.Fatal("want a timeout") } out := buf.String() + err.Error() if !strings.Contains(out, "dev") { t.Fatalf("timeout report %q does not name what the probe actually said — the ldflags trap stays invisible", out) } } // TestWatchProbeNon2xxIsNotLive: a probe path can answer with the right build // in the body and still be failing — the console's own /healthz is served by // the same process that would 500. Status gates the success arm on this leg // exactly as it does on the header leg. func TestWatchProbeNon2xxIsNotLive(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintln(w, "ok abc1234") })) defer srv.Close() var buf bytes.Buffer targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}} err := watchVersion(&buf, targets, "abc1234", 100*time.Millisecond, 50*time.Millisecond, nil) if err == nil { t.Fatal("a 500 whose body names the build must not be reported live") } if !strings.Contains(err.Error(), "HTTP 500") { t.Fatalf("diagnostic must name the failing status: %v", err) } if strings.Contains(buf.String(), "live") { t.Fatalf("must not print live: %q", buf.String()) } } // TestWatchWithoutAProbeStillReadsTheHeader: an app that declares no probe // keeps today's behaviour byte for byte. func TestWatchWithoutAProbeStillReadsTheHeader(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { t.Errorf("header watch dialled %q, want /", r.URL.Path) } w.Header().Set("X-Carlos-Version", "abc1234") w.WriteHeader(200) })) defer srv.Close() targets := []watchTarget{{Host: "app.example", Origin: srv.URL}} if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil { t.Fatalf("header watch = %v, want success", err) } } // TestDeployWatchesTheProbeTheShipResponseNames is the whole point of the // probe, end to end: the edge's X-Carlos-Version is stamped from the route // row, so here it still says the OLD build — exactly what a long-running // unit looks like between "adoption moved the symlink" and "the process // restarted". The header watch would have reported this deploy live while // the old binary was still serving; the probe, answered by the app's own // process, is what actually turns green. func TestDeployWatchesTheProbeTheShipResponseNames(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) const ( app = "console" host = "console.example.test" channel = "canary/dev" ) fc := newDeployFakeConsole(t, "sha0002") fc.setShipProbe("/healthz") fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}}) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } var probed atomic.Int64 edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // The edge answers BOTH legs: the header leg with the stale route-row // version, the probe leg with what the process is really running. w.Header().Set("X-Carlos-Version", "sha0001") if r.URL.Path == "/healthz" { probed.Add(1) fmt.Fprintln(w, "ok sha0002") return } w.WriteHeader(http.StatusOK) })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() bin := filepath.Join(t.TempDir(), "console") if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002", "-channel", channel, "-timeout", "5s", bin}) out := buf.String() if probed.Load() == 0 { t.Fatalf("the watch never polled /healthz — it read the (stale) header instead:\n%s", out) } if !strings.Contains(out, "live https://"+host) { t.Fatalf("deploy did not complete against the probe:\n%s", out) } } // TestDeployWithNoProbeStillWatchesTheHeader: a ship response with no // build_probe key — every app that declares none, and every console built // before the field existed — leaves the header watch exactly as it was. func TestDeployWithNoProbeStillWatchesTheHeader(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) const ( app = "titogo" host = "titogo.example.test" channel = "canary/dev" ) fc := newDeployFakeConsole(t, "sha0002") fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}}) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } var paths sync.Map edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths.Store(r.URL.Path, true) w.Header().Set("X-Carlos-Version", "sha0002") w.WriteHeader(http.StatusOK) })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() bin := filepath.Join(t.TempDir(), "titogo") if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002", "-channel", channel, "-timeout", "5s", bin}) out := buf.String() if _, dialled := paths.Load("/healthz"); dialled { t.Fatalf("an app with no probe must never be polled anywhere but /:\n%s", out) } if _, dialled := paths.Load("/"); !dialled { t.Fatalf("the header watch never dialled /:\n%s", out) } if !strings.Contains(out, "live https://"+host) { t.Fatalf("header watch did not complete:\n%s", out) } } // TestDeployRefusesAMalformedProbeAndFallsBackToTheHeader pins the re-check // on the READ side. Both grammar gates live at the write door; this value // arrives from a response and is CONCATENATED onto an origin, so a probe // that does not start with "/" is a redirection primitive: // "https://" + host + "@evil.example/x" parses host as USERINFO and // evil.example as the host, and the watch would report a machine of someone // else's choosing as this deploy's build. Degrade to the header watch, say // why, and never dial the named host at all. func TestDeployRefusesAMalformedProbeAndFallsBackToTheHeader(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) const ( app = "console" host = "console.example.test" channel = "canary/dev" ) fc := newDeployFakeConsole(t, "sha0002") fc.setShipProbe("@evil.example/healthz") // no leading slash: the userinfo trick fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}}) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := credFile.save(); err != nil { t.Fatal(err) } var paths sync.Map edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths.Store(r.URL.Path, true) w.Header().Set("X-Carlos-Version", "sha0002") w.WriteHeader(http.StatusOK) })) defer edgeSrv.Close() origOrigin := watchOrigin watchOrigin = func(h string) string { return edgeSrv.URL } defer func() { watchOrigin = origOrigin }() bin := filepath.Join(t.TempDir(), "console") if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002", "-channel", channel, "-timeout", "5s", bin}) out := buf.String() if !strings.Contains(out, "ignoring the console's build probe") { t.Fatalf("a malformed probe must be named and dropped, not silently used:\n%s", out) } paths.Range(func(k, _ any) bool { if k.(string) != "/" { t.Errorf("a refused probe was still dialled: %q", k) } return true }) if _, dialled := paths.Load("/"); !dialled { t.Fatalf("the header watch never ran:\n%s", out) } if !strings.Contains(out, "live https://"+host) { t.Fatalf("deploy must still complete on the header watch:\n%s", out) } } // initGitRepo builds a git repo in a fresh temp dir with one committed file. // With dirty=true, the file is then modified so the working tree is dirty; // with dirty=false, the tree is left exactly as committed — the branch whose // failure mode is "refuses every ordinary deploy" and which nothing else in // this suite exercises, since every runDeploy call site passes --version // explicitly and CI itself checks out a clean tree. Skips the test if git is // unavailable in this environment. func initGitRepo(t *testing.T, dirty bool) string { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } dir := t.TempDir() run := func(args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } run("init") run("config", "user.email", "test@example.com") run("config", "user.name", "test") f := filepath.Join(dir, "file.txt") if err := os.WriteFile(f, []byte("committed\n"), 0o644); err != nil { t.Fatal(err) } run("add", "file.txt") run("commit", "-m", "initial") if dirty { if err := os.WriteFile(f, []byte("dirty\n"), 0o644); err != nil { t.Fatal(err) } } return dir } // initDirtyGitRepo is initGitRepo(t, true) — kept as its own name since it // reads better at call sites that only ever want a dirty tree. func initDirtyGitRepo(t *testing.T) string { t.Helper() return initGitRepo(t, true) } // TestDeployRefusesADerivedVersionFromADirtyTree: the default --version is // the git short sha, so a dirty tree ships a stamp naming a commit the binary // was not built from — which is precisely how a false "verified" happens, and // why roll-console.sh refused it. An EXPLICIT --version is the operator saying // they mean it and is not second-guessed. The argument is now runDeploy's // flagWasSet answer ("the operator typed the flag"), not the value. func TestDeployRefusesADerivedVersionFromADirtyTree(t *testing.T) { dir := initDirtyGitRepo(t) err := deployPreflight(dir, false /* --version not typed */) if err == nil { t.Fatal("a dirty tree with a derived version was accepted") } if !strings.Contains(err.Error(), "--version") { t.Fatalf("refusal %q should name the flag that overrides it", err) } if !strings.Contains(err.Error(), dir) { t.Fatalf("refusal %q should name the tree it inspected (%s)", err, dir) } if err := deployPreflight(dir, true /* --version typed */); err != nil { t.Fatalf("an explicit --version from a dirty tree = %v, want accepted", err) } } // TestDeployAcceptsADerivedVersionFromACleanTree: the branch every other // test in this file skips over. All 13 runDeploy call sites pass --version // explicitly, and CI itself checks out a clean tree, so a preflight that // wrongly refused every derived-version deploy from a clean checkout would // ship green with nothing else in the suite to catch it. func TestDeployAcceptsADerivedVersionFromACleanTree(t *testing.T) { dir := initGitRepo(t, false) if err := deployPreflight(dir, false /* --version not typed */); err != nil { t.Fatalf("a clean tree with a derived version = %v, want accepted", err) } } // TestDeployPreflightAsksWhetherTheFlagWasTyped pins the wiring the guard now // rests on: runDeploy passes flagWasSet(fs, "version"), which is alias-aware, // so -v names the release just as explicitly as --version does — and neither // is second-guessed from a dirty tree. Without this, the ordering hazard the // old explicitVersion capture warned about would simply move into flags.go // with nothing pointing at it. func TestDeployPreflightAsksWhetherTheFlagWasTyped(t *testing.T) { for _, typed := range []string{"--version", "-v"} { fs := flag.NewFlagSet("deploy", flag.ContinueOnError) fs.SetOutput(io.Discard) versionFlag(fs, "") if err := fs.Parse([]string{typed, "abc1234"}); err != nil { t.Fatalf("parse %s: %v", typed, err) } if !flagWasSet(fs, "version") { t.Fatalf("%s typed but flagWasSet said no — the dirty-tree guard would refuse it", typed) } } fs := flag.NewFlagSet("deploy", flag.ContinueOnError) fs.SetOutput(io.Discard) versionFlag(fs, "") if err := fs.Parse(nil); err != nil { t.Fatal(err) } if flagWasSet(fs, "version") { t.Fatal("no version flag typed, but flagWasSet said yes — the guard would never fire") } } // A deploy that ends with a "failed" domain must not print DNS guidance. // Their records are correct — that is precisely what "failed" means — so // "Create: ..." would send them to change the one part that is working, // while the actual problem (ours) went unnamed. func TestDeployDNSGuidanceOnAFailedCertificateNeverAsksForRecords(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]any{{ "host": "x.test", "state": "failed", "reason": "cert-failed", "detail": "DNS is correct, but we have not been able to obtain a certificate.", // A console that still stamps guidance alongside it must not // make this print "Create: ..." either. "guidance": "create a CNAME to edge.oncarlos.test", }}}) })) defer ts.Close() domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") if err != nil { t.Fatal(err) } msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}) if !ok { t.Fatal("a failed certificate should be reported, not silently dropped") } if strings.Contains(msg, "Create:") || strings.Contains(msg, "CNAME") { t.Errorf("message asks the member to change DNS that is already correct: %q", msg) } if !strings.Contains(msg, "not been able to obtain a certificate") { t.Errorf("message does not name the actual problem: %q", msg) } }