carlos / platform Public

cli: deploy domain-attach review fixes — DNS/cert gating, degrade paths, no-partial-ship

- failDeployDNSGuidance no longer fires on host-match alone: gated on
  DNS != "pointing" && Guidance != "" for the not-pointing report, with a
  new arm for DNS == "pointing" (certificate not issued yet, re-run
  shortly) and a silent fall-through otherwise (dnsGuidanceMessage).
- domainsDoorMissing (404) now warns and continues when the host is
  genuinely unclaimed, rather than staying silent unconditionally.
- domainList failures now classify via a typed domainListError (status
  preserved without changing domainList's public signature): 404 stands
  auto-attach down (with a note if needed), a transport failure or 5xx
  warns and continues (domainListDegradable), and 401/403 (or anything
  else) stays fatal.
- Added TestDeployAttachRefusalStopsBeforeShip: a 422 on the attach POST
  exits nonzero with no ship call ever recorded.
- Test/env hygiene: locked deployFakeConsole.domains writes uniformly,
  subprocess tests build their env through carlosTestEnv, and the
  DNS-guidance hint now carries -account, matching logsHint's shape.
- Collapsed the attachKind/print duplication: the printed "first route"
  line now names the same variable the POST body sends.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b8d984e8493f6f77da10174907fd55903c08e752 parent 91198c0
3 files changed, +444 −57
  • apimode.go +20 −2
  • deploy.go +122 −37
  • deploy_test.go +302 −18
diff --git a/apimode.go b/apimode.go
index d9f517264..d1005b27b 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1397,6 +1397,24 @@ type apiDomain struct {
Guidance string `json:"guidance,omitempty"`
}
+// domainListError wraps a domainList failure with the one extra fact its
+// own signature has no room for: the HTTP status (0 when the request never
+// got an HTTP response at all — a transport failure). deploy.go's callers
+// need to tell apart three meanings a bare error string cannot carry: "this
+// console predates the /domains door" (404), "this credential is not
+// allowed to see it" (401/403 — a real problem, must stay fatal), and "the
+// door exists but is unwell right now" (a transport failure, or any other
+// 5xx — worth a warning, not aborting an otherwise-successful deploy over).
+// Error()/Unwrap() make it a transparent drop-in wherever domainList's
+// error was already being printed or compared.
+type domainListError struct {
+ status int
+ err error
+}
+
+func (e *domainListError) Error() string { return e.err.Error() }
+func (e *domainListError) Unwrap() error { return e.err }
+
// 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
@@ -1404,10 +1422,10 @@ type apiDomain struct {
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
+ return nil, &domainListError{err: err}
}
if status != http.StatusOK {
- return nil, apiErrorFromBody(status, body)
+ return nil, &domainListError{status: status, err: apiErrorFromBody(status, body)}
}
var res struct {
Domains []apiDomain `json:"domains"`
diff --git a/deploy.go b/deploy.go
index a87c45f5b..98f5ff303 100644
--- a/deploy.go
+++ b/deploy.go
@@ -284,14 +284,34 @@ func staticDeployChannel(chans []apiChannel) (string, error) {
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) {
+ switch {
+ case 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.
+ // real one; not every box has adopted it yet). Silence is only
+ // honest when nothing was going to be attached anyway — an
+ // instance already answering this host means so, regardless of
+ // the door. A genuinely unclaimed host (no instance, and now no
+ // way to check domainList either) gets a note instead of a
+ // silent skip: without the door this deploy cannot attach it,
+ // and a member watching "deploying https://<host>" scroll by
+ // with no explanation would have no idea DNS was never even
+ // checked.
+ if !hostHasInstance(host, recs) {
+ mutedf(stdout, "this console has no domains door yet — attach %s to %s in the console before "+
+ "DNS can be checked; continuing without auto-attach\n", host, app)
+ }
+ return
+ case domainListDegradable(err):
+ // A transport failure or 5xx: the door exists but is unwell
+ // right now, not a reason to abort a deploy that would
+ // otherwise succeed. 401/403 do NOT take this branch — those
+ // mean the credential itself lacks authority, which is worth
+ // stopping for.
+ mutedf(stdout, "could not check custom domains for %s (%v) — continuing without auto-attach\n", host, err)
return
+ default:
+ log.Fatalf("deploy: list domains: %v", err)
}
- log.Fatalf("deploy: list domains: %v", err)
}
if !domainNeedsAttach(host, recs, domains) {
return
@@ -299,7 +319,12 @@ func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, ap
// 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.
+ // value the server would refuse. attachUnclaimedHost is only ever
+ // reached with kind == "static" (runDeploy gates the call on
+ // staticDeploy, which deployTargets only sets true for that kind), so
+ // this collapses to "static" in practice — kept as one variable rather
+ // than a second hardcoded literal in the printed line below, so the two
+ // can never drift apart and say different things.
attachKind := ""
if kind == "static" {
attachKind = kind
@@ -307,7 +332,21 @@ func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, ap
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)
+ fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel)
+}
+
+// hostHasInstance reports whether host is already answered by one of the
+// app's instance records — the one fact attachUnclaimedHost can still trust
+// even when domainList itself cannot be read (the door is missing, or
+// unwell): an instance already serving this host never needed an attach in
+// the first place, regardless of what domainList would have said.
+func hostHasInstance(host string, recs []apiInstanceRecord) bool {
+ for _, r := range recs {
+ if r.Host == host {
+ return true
+ }
+ }
+ return false
}
// domainNeedsAttach reports whether host is a fresh target this deploy must
@@ -319,10 +358,8 @@ func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, ap
// 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
- }
+ if hostHasInstance(host, recs) {
+ return false
}
for _, d := range domains {
if d.Host == host {
@@ -332,19 +369,34 @@ func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomai
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.
+// domainsDoorMissing reports whether err is a domainList 404 — 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.
func domainsDoorMissing(err error) bool {
- return err != nil && err.Error() == notFoundErr().Error()
+ var dle *domainListError
+ return errors.As(err, &dle) && dle.status == http.StatusNotFound
+}
+
+// domainListDegradable reports whether a domainList failure should stand
+// auto-attach down with a warning rather than aborting the whole deploy: a
+// transport-level failure (status 0 — the request never got an HTTP
+// response at all) or any 5xx. 401/403 are deliberately excluded — those
+// mean the credential itself lacks authority to see this app's domains,
+// which is worth stopping the deploy for, not silently continuing past. An
+// error that isn't even a *domainListError (never expected from
+// domainList, but defensive) is treated as NOT degradable, so an unknown
+// shape stays fatal rather than silently swallowed.
+func domainListDegradable(err error) bool {
+ var dle *domainListError
+ if !errors.As(err, &dle) {
+ return false
+ }
+ return dle.status == 0 || (dle.status >= 500 && dle.status < 600)
}
// isConnectionClassFailure reports whether a watchVersion timeout's error
@@ -360,27 +412,60 @@ func isConnectionClassFailure(err error) bool {
return err != nil && strings.Contains(err.Error(), "unreachable:")
}
+// dnsGuidanceMessage decides the honest, spec'd exit text for a
+// connection-class watch timeout, from the app's domainList entry for
+// host — or reports ok=false when neither arm applies, telling the caller
+// to fall back to the plain watchVersion timeout report instead of
+// guessing. Two arms, keyed off domainList's own DNS verdict for the host:
+//
+// - DNS == "pointing": DNS is right, so a connection-class failure means
+// something else is missing — the certificate, almost always, since
+// first-route attach mints the record before ACME has had a chance to
+// run. Says so, and to re-run SHORTLY (this should resolve itself).
+// - DNS != "pointing" (elsewhere, or never checked) WITH guidance: the
+// DNS-not-pointing report, the console's own Create: text verbatim.
+//
+// A host absent from domains, or present with DNS != "pointing" and no
+// guidance yet (checked, but the console has nothing to say), is
+// deliberately ok=false — a guessed-at guidance line this call cannot back
+// up would be worse than the generic timeout report already in hand.
+func dnsGuidanceMessage(domains []apiDomain, host, app string, acct apiAcct) (msg string, ok bool) {
+ for _, d := range domains {
+ if d.Host != host {
+ continue
+ }
+ switch {
+ case d.DNS == "pointing":
+ return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+
+ "Watch with 'carlos domains -app %s -account %s'; re-run this deploy shortly — the shipped release is already promoted.",
+ host, app, acct.Seg), true
+ case d.DNS != "pointing" && d.Guidance != "":
+ return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+
+ "Watch with 'carlos domains -app %s -account %s'; re-run this deploy any time — the shipped release is already promoted.",
+ host, d.Guidance, app, acct.Seg), true
+ default:
+ return "", false
+ }
+ }
+ return "", false
+}
+
// 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.
+// (shipped and promoted), so the exit text says what's still missing and
+// how to fix it, never "deploy failed". It still exits nonzero (log.Fatal,
+// 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 dnsGuidanceMessage finds neither arm
+// applies.
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)
- }
+ if msg, ok := dnsGuidanceMessage(domains, host, app, acct); ok {
+ log.Fatal(msg)
}
}
diff --git a/deploy_test.go b/deploy_test.go
index 3073ba395..ebe0a0be9 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -596,10 +596,13 @@ func TestDomainNeedsAttach(t *testing.T) {
type deployFakeConsole struct {
ts *httptest.Server
- mu sync.Mutex
- calls []string // "METHOD /path", in hit order
- domains []map[string]string
- shipVersion string
+ mu sync.Mutex
+ calls []string // "METHOD /path", in hit order
+ domains []map[string]string
+ 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
}
func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
@@ -613,12 +616,29 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
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
+ 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/{host}", func(w http.ResponseWriter, r *http.Request) {
fc.record(r)
+ fc.mu.Lock()
+ status, errMsg := fc.attachStatus, fc.attachErrMsg
+ fc.mu.Unlock()
+ if status != 0 {
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": errMsg})
+ return
+ }
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"host": r.PathValue("host"), "route": "", "dns": ""})
})
@@ -639,6 +659,28 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
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) 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) record(r *http.Request) {
fc.mu.Lock()
defer fc.mu.Unlock()
@@ -775,10 +817,9 @@ func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) {
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}}
+ fc.setDomains([]map[string]string{{"host": host, "route": "", "dns": "elsewhere", "guidance": guide}})
- scratch := t.TempDir()
- credPath := filepath.Join(scratch, "credentials")
+ credPath := filepath.Join(t.TempDir(), "credentials")
t.Setenv("CARLOS_CREDENTIALS", credPath)
credFile, err := loadCredentials()
if err != nil {
@@ -790,14 +831,6 @@ func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) {
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)
@@ -806,14 +839,21 @@ func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) {
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
+ // 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 -app " + app + "'; re-run this deploy any time — the shipped release is already promoted."
+ ". Watch with 'carlos domains -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)
}
@@ -821,3 +861,247 @@ func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) {
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 apiDomain 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) []apiDomain {
+ 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": "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 -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": "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 -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")
+ }
+}
+
+// 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/" + host); 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/" + host); 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)
+ }
+}