carlos / platform Public

cli: deploy -host auto-attaches an unclaimed custom domain

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 91198c0cf04d7d7189d9ab5dcebacff98000da97 parent b82cb28
3 files changed, +542 −0
  • apimode.go +66 −0
  • deploy.go +122 −0
  • deploy_test.go +354 −0
diff --git a/apimode.go b/apimode.go
index a26cd81bb..d9f517264 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1381,6 +1381,72 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
return nil
}
+// apiDomain is one custom domain attached to an app, as the console's
+// domains door answers it (Task 8; wire contract agreed with #151). Route
+// names what the domain currently serves ("" for a fresh first-route
+// attach whose record has not yet materialized); DNS is the console's last
+// checked pointing verdict — "pointing" | "elsewhere" | "" (not yet
+// checked), the same vocabulary registry.Route.DNSStatus already carries
+// (internal/registry/registry.go). Guidance is the point-your-DNS-here
+// text (internal/console/overview.go's own field), present only while DNS
+// is not "pointing".
+type apiDomain struct {
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ DNS string `json:"dns,omitempty"`
+ Guidance string `json:"guidance,omitempty"`
+}
+
+// domainList reads every custom domain attached to app — GET .../domains.
+// Wrapped in a "domains" envelope (matching instanceList's own wrapped
+// shape) rather than a bare array, so a later additive field can join it
+// the same way instanceList's total/truncated did.
+func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomain, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Domains []apiDomain `json:"domains"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("domains list: unreadable response: %w", err)
+ }
+ return res.Domains, nil
+}
+
+// domainAttach asks the console to attach host to app — POST
+// .../domains/{host} with {"kind","channel"} (both optional; empty strings
+// are omitted, leaving inference to the server exactly as an operator's own
+// `carlos domains attach` with no flags would). 201 is the only success
+// status. A host this caller (or another account) already holds answers
+// 409 with a body containing "host unavailable" — deploy's own caller MUST
+// consult domainList first and only reach this call for a host absent from
+// it, since a re-attach of an already-held host is refused, not a no-op.
+// Both that 409 and a 422 shape refusal decode through apiErrorFromBody and
+// surface verbatim — the console's own diagnosis, never a CLI rewrite.
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, kind, channel string) (apiDomain, error) {
+ in := struct {
+ Kind string `json:"kind,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ }{kind, channel}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)), in)
+ if err != nil {
+ return apiDomain{}, err
+ }
+ if status != http.StatusCreated {
+ return apiDomain{}, apiErrorFromBody(status, body)
+ }
+ var out apiDomain
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiDomain{}, fmt.Errorf("domain attach: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// shipTimeout bounds ship's own context — generous on purpose. The console
// gives itself up to 10 minutes to read the request body and 2 minutes to
// store it (internal/console/apicli.go), well past the 30s absolute Timeout
diff --git a/deploy.go b/deploy.go
index 9b1693322..a87c45f5b 100644
--- a/deploy.go
+++ b/deploy.go
@@ -74,6 +74,9 @@ func runDeploy(args []string) {
if line := deployingLine(hosts, channel); line != "" {
fmt.Fprintln(stdout, line)
}
+ if staticDeploy && *hostFlag != "" {
+ attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs)
+ }
// printBanner=false: the "using ..." line already printed above, before
// the instance list — so a listing failure still names the console —
// and runShipAPI would otherwise print it a second time.
@@ -126,6 +129,9 @@ func runDeploy(args []string) {
logsHint: fmt.Sprintf("carlos logs -app %s -account %s -stream platform", *app, acct.Seg),
}
if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second, diag); err != nil {
+ if staticDeploy && *hostFlag != "" && isConnectionClassFailure(err) {
+ failDeployDNSGuidance(ctx, sess, acct, *app, *hostFlag)
+ }
log.Fatalf("deploy: %v", err)
}
}
@@ -262,6 +268,122 @@ func staticDeployChannel(chans []apiChannel) (string, error) {
"pass -channel to pick one: %s", strings.Join(names, ", "))
}
+// attachUnclaimedHost auto-attaches -host before shipping, when this deploy
+// is targeting a fresh static host with no route of its own yet (self-serve
+// custom domains, #82/#151): the app's first deploy to a brand-new hostname
+// should not need a separate `carlos domains attach` first. recs is the
+// SAME instance list runDeploy already fetched for deployTargets — checked
+// again here rather than assumed empty, so this stays correct even if a
+// future caller ever reaches it with records present.
+//
+// domainList is read BEFORE any attach POST, and its answer is decisive: a
+// host this account already holds is refused with 409 "host unavailable"
+// (review binding note), so attaching a host domainList already names would
+// turn a repeated `carlos deploy` into a hard failure instead of the
+// idempotent no-op it has to be.
+func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host, kind, channel string, recs []apiInstanceRecord) {
+ domains, err := sess.domainList(ctx, acct, app)
+ if err != nil {
+ if domainsDoorMissing(err) {
+ // A console that predates the /domains door (Task 8 wires the
+ // real one; not every box has adopted it yet) — nothing to
+ // auto-attach through, so deploy proceeds exactly as it did
+ // before this feature existed rather than refusing outright.
+ return
+ }
+ log.Fatalf("deploy: list domains: %v", err)
+ }
+ if !domainNeedsAttach(host, recs, domains) {
+ return
+ }
+ // kind travels only when it is "static" — the one shape a first-route
+ // attach accepts (attachDomain, internal/console/domainattach.go); empty
+ // leaves the field out of the wire body entirely rather than sending a
+ // value the server would refuse.
+ attachKind := ""
+ if kind == "static" {
+ attachKind = kind
+ }
+ if _, err := sess.domainAttach(ctx, acct, app, host, attachKind, channel); err != nil {
+ log.Fatalf("deploy: attach %s: %v", host, err)
+ }
+ fmt.Fprintf(stdout, "%s %s (first route: static, channel %s)\n", colorFor(stdout).ok("attached"), host, channel)
+}
+
+// domainNeedsAttach reports whether host is a fresh target this deploy must
+// attach before shipping: present in NEITHER the app's instance records (an
+// existing instance already answers it) NOR its already-claimed domains (an
+// earlier deploy's attach, or a manual `carlos domains attach`). Either
+// match means false — a host this account already holds must never be
+// re-posted to attach (it is refused 409 "host unavailable"), which is what
+// keeps a repeated `carlos deploy` idempotent rather than erroring on its
+// own prior success.
+func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomain) bool {
+ for _, r := range recs {
+ if r.Host == host {
+ return false
+ }
+ }
+ for _, d := range domains {
+ if d.Host == host {
+ return false
+ }
+ }
+ return true
+}
+
+// domainsDoorMissing reports whether err is exactly the generic "not found"
+// notFoundErr() (apimode.go) constructs — the shape apiErrorFromBody gives
+// ANY 404 whose body carries no JSON "error" field, which is exactly what
+// an unregistered route (a console that predates the /domains door, Task 8)
+// answers. By the time attachUnclaimedHost calls domainList, runDeploy has
+// already listed this exact account+app's instances successfully — so
+// "unknown account/app" is already ruled out, and a domainList 404 here can
+// only mean the door itself does not exist yet. Exact string match, not a
+// sentinel error, for the same reason noCredentialsMsg (apimode.go) is:
+// notFoundErr's text carries no interpolated value, so every instance of it
+// is byte-identical.
+func domainsDoorMissing(err error) bool {
+ return err != nil && err.Error() == notFoundErr().Error()
+}
+
+// isConnectionClassFailure reports whether a watchVersion timeout's error
+// text names a target that never even answered — the "unreachable: ..."
+// diag watchVersion emits when http.Client.Get itself failed (DNS
+// resolution, connection refused, TLS handshake: every shape a domain whose
+// DNS is not yet pointing here produces), as opposed to a reachable origin
+// that simply isn't serving the right version yet. watchVersion reports one
+// combined string, not a structured per-host reason, so a substring check is
+// what there is to go on here — precise enough because deploy only ever
+// reaches this with -host naming the single target that timed out.
+func isConnectionClassFailure(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "unreachable:")
+}
+
+// failDeployDNSGuidance turns a connection-class watch timeout into the
+// honest report the spec asks for: the deploy itself already succeeded
+// (shipped and promoted), so the exit text says what's still missing — DNS
+// — and how to fix it, never "deploy failed". It still exits nonzero
+// (log.Fatalf, same as every other refusal in this file): the URL does not
+// yet serve the release, and this must never read as success. Falls through
+// silently (letting the caller's own generic watch-error message stand) when
+// domainList itself cannot be read, or names no entry for this host — a
+// guessed-at guidance line this call cannot confirm would be worse than the
+// plain timeout report already in hand.
+func failDeployDNSGuidance(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) {
+ domains, err := sess.domainList(ctx, acct, app)
+ if err != nil {
+ return
+ }
+ for _, d := range domains {
+ if d.Host == host {
+ log.Fatalf("DNS for %s is not pointing at this deployment yet. Create: %s. "+
+ "Watch with 'carlos domains -app %s'; re-run this deploy any time — the shipped release is already promoted.",
+ host, d.Guidance, app)
+ }
+ }
+}
+
// requireDeployAPISession resolves signed-in mode for deploy, refusing
// direct-bucket mode with deploy's OWN message — not instances_cli.go's
// requireAPISession, whose hardcoded text ("carlos instances %s works
diff --git a/deploy_test.go b/deploy_test.go
index 6ea48fdab..3073ba395 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -2,9 +2,15 @@ package main
import (
"bytes"
+ "context"
+ "encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -467,3 +473,351 @@ func TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint(t *testing.T) {
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 (Task 8) has to answer: GET .../domains for the list, POST
+// .../domains/{host} with a {"kind","channel"} body for attach, decoding a
+// 201 into an apiDomain.
+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", "dns": "pointing"}},
+ })
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/domains/"):
+ gotAttachPath, gotAttachMethod = r.URL.RequestURI(), r.Method
+ b, _ := io.ReadAll(r.Body)
+ gotAttachBody = string(b)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]string{"host": "fresh.example.test", "route": "", "dns": ""})
+ 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].DNS != "pointing" {
+ t.Fatalf("domains = %+v", domains)
+ }
+
+ got, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely",
+ "fresh.example.test", "static", "canary/live")
+ 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/fresh.example.test" {
+ t.Fatalf("path = %s", gotAttachPath)
+ }
+ if !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) {
+ t.Fatalf("body = %s, want kind+channel", gotAttachBody)
+ }
+ if got.Host != "fresh.example.test" {
+ t.Fatalf("attach result = %+v", got)
+ }
+}
+
+// TestAPISessionDomainAttachRefusalSurfacesVerbatim pins the two refusal
+// shapes the wire contract names: 409 "host 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, "host 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")
+ 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 := []apiDomain{{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 {
+ ts *httptest.Server
+
+ mu sync.Mutex
+ calls []string // "METHOD /path", in hit order
+ domains []map[string]string
+ shipVersion string
+}
+
+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)
+ json.NewEncoder(w).Encode(map[string]any{"instances": []apiInstanceRecord{}})
+ })
+ mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ fc.mu.Lock()
+ domains := fc.domains
+ fc.mu.Unlock()
+ json.NewEncoder(w).Encode(map[string]any{"domains": domains})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains/{host}", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]string{"host": r.PathValue("host"), "route": "", "dns": ""})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ fc.mu.Lock()
+ ver := fc.shipVersion
+ fc.mu.Unlock()
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]string{"version": ver})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false})
+ })
+ fc.ts = httptest.NewServer(mux)
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+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/" + host)
+ 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": "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/" + host); 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()
+}
+
+// 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.domains = []map[string]string{{"host": host, "route": "", "dns": "elsewhere", "guidance": guide}}
+
+ scratch := t.TempDir()
+ credPath := filepath.Join(scratch, "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)
+ }
+
+ env := append(os.Environ(),
+ "CARLOS_CREDENTIALS="+credPath,
+ "HOME="+scratch,
+ "CARLOS_CONSOLE=",
+ "CARLOS_DEPLOYMENT_BUCKET=",
+ "CARLOS_DEPLOYMENT_DIR=",
+ )
+
+ 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)
+ cmd.Env = env
+ 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 -app " + app + "'; 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/" + host); idx >= 0 {
+ t.Fatalf("host already in domainList must not have triggered an attach POST: %v", fc.calls)
+ }
+}