carlos / platform Public

deploy: static records are static-deploy targets; door answers from the core

Review round (task 8):

- deployTargets judges "no records" kind-aware: a Kind=="static" provision
  record — the one a first-route domain attach writes — keeps a -kind
  static deploy ON the static path instead of pulling it onto the instance
  path. Fixes both regressions the first attach's record caused: a
  redeploy while DNS is unpointed exits through the DNS-guidance report
  again (not a bare watch timeout), and a SECOND static -host attaches
  through the same door instead of fataling with 'no instance with host'.
  The record also resolves the redeploy's channel (no -channel needed) and
  its hosts are the watch targets when -host is absent. apiInstanceRecord
  now decodes the kind field the list wire has carried all along.

- handleAPIDomainAttach's kind=static branch answers from the CORE's
  result (FirstRoute -> host, else the claim's alias route) and the
  duplicate readyRouteHost pre-read is gone — the core decides
  alias-vs-first-route itself.

- Door-extension unit tests: route+kind 400, kind!=static 400, first-route
  success (empty-route claim + static record), ready-route alias
  fallthrough (claim names the route, no record minted).

- Deploy tests: kind-aware gate table, redeploy-with-record DNS-guidance
  subprocess, second-host attach; the real-console e2e grows the
  second-host leg. Stale 409 comments reworded to the idempotent-200
  reality; spec states the POST/GET route asymmetry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 41fc6070de3c49ce2f809d436c33f8195f108752 parent 2a29b63
7 files changed, +458 −59
  • apimode.go +7 −0
  • deploy.go +85 −24
  • deploy_test.go +197 −8
  • docs/superpowers/specs/2026-08-12-domain-attach-design.md +9 −1
  • domains_e2e_test.go +25 −0
  • internal/console/apidomains.go +23 −26
  • internal/console/apidomains_test.go +112 −0
diff --git a/apimode.go b/apimode.go
index 2614f2174..c7120636d 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1251,6 +1251,13 @@ type apiInstanceRecord struct {
Channel string `json:"channel"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
+ // Kind is the record's route class ("instance" | "service" | "static";
+ // empty means instance — provision.Record's own contract). The wire has
+ // carried it since the route-records design (the list embeds the whole
+ // record); the client decodes it so deploy can tell a first-route
+ // STATIC record (#82's attach writes one) from a real instance — a
+ // static record is a static-deploy target, not an instance to adopt.
+ Kind string `json:"kind"`
// StagedVersion is the console's additive staged_version join (Task 8,
// issue #78): whichever version a box's adopt pass last reported for
// this host, via status/boxes/<label>.json. Fresh-ish for as long as
diff --git a/deploy.go b/deploy.go
index 99f903241..03f75ad44 100644
--- a/deploy.go
+++ b/deploy.go
@@ -183,16 +183,33 @@ func deployingLine(hosts []string, channel string) string {
// "no instance follows this channel — promoted, nothing to watch" shape.
// It keeps its old behaviour verbatim: this branch must not demand -host
// from a binary app, nor tell it about static recheck windows.
+//
+// "No records" is judged KIND-AWARE, not by len(recs) == 0: a first-route
+// domain attach (#82) writes a Kind=="static" provision record, so the very
+// first `deploy -kind static -host <h>` leaves a record behind and every
+// redeploy after it would otherwise fall off the static path — losing the
+// recheck note and the DNS-guidance exit, and refusing a SECOND static
+// host with "no instance with host". Static records ARE static-deploy
+// targets; only a real instance/service record pulls a -kind static deploy
+// onto the instance path.
func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
channels func() ([]apiChannel, error)) (channel string, hosts []string, static bool, err error) {
- if len(recs) == 0 && kind == "static" {
- hosts, err := staticDeployHosts(hostFlag)
+ if kind == "static" && staticOnlyRecords(recs) {
+ hosts, err := staticDeployHosts(hostFlag, recs)
if err != nil {
return "", nil, true, err
}
if channelFlag != "" {
return channelFlag, hosts, true, nil
}
+ // A static record that names the deploy's own -host already knows
+ // its channel — the record the first deploy's attach wrote. Same
+ // convenience the instance path has always had (resolveDeployChannel
+ // resolves from the -host record); a host with no record yet — the
+ // SECOND static domain — falls through to the channels listing.
+ if ch := staticRecordChannel(recs, hostFlag); ch != "" {
+ return ch, hosts, true, nil
+ }
chans, err := channels()
if err != nil {
return "", nil, true, fmt.Errorf("list channels: %w", err)
@@ -221,19 +238,61 @@ func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
return channel, hosts, false, err
}
-// staticDeployHosts resolves the hosts a NO-INSTANCE deploy watches. There is
-// nothing to resolve them FROM — that is the whole point — so the flag is
-// required, and its absence is refused before ship and promote run rather
-// than after, when the only remaining honest report would be "promoted,
-// nothing to watch".
-func staticDeployHosts(hostFlag string) ([]string, error) {
- if hostFlag == "" {
- return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
- "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
- "Name the hostname the site answers on, so the deploy can watch it:\n" +
- " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
- }
- return []string{hostFlag}, nil
+// staticOnlyRecords reports whether recs holds nothing but Kind=="static"
+// records — the condition under which a -kind static deploy stays on the
+// static path. Zero records is trivially true (the original no-records
+// static shape, issue #112); any instance/service record pulls the deploy
+// onto the instance path exactly as before this predicate existed.
+func staticOnlyRecords(recs []apiInstanceRecord) bool {
+ for _, r := range recs {
+ if r.Kind != "static" {
+ return false
+ }
+ }
+ return true
+}
+
+// staticRecordChannel is the channel of the static record naming host, or ""
+// — the record a first-route attach wrote already knows what its domain
+// follows, so a redeploy needs no -channel and no channels-listing round
+// trip. "" (host absent, or no -host at all) sends the caller to the
+// channels listing instead.
+func staticRecordChannel(recs []apiInstanceRecord, host string) string {
+ if host == "" {
+ return ""
+ }
+ for _, r := range recs {
+ if r.Host == host {
+ return r.Channel
+ }
+ }
+ return ""
+}
+
+// staticDeployHosts resolves the hosts a static-path deploy watches. -host
+// names one explicitly (an unrecorded host is fine — it is the SECOND
+// static domain, about to be attached on the way through). Without the
+// flag, the app's own static records are the next best source: each one
+// names a host this app serves, written by an earlier deploy's attach. Only
+// an app with neither — no flag, no records — has nothing to resolve a host
+// from, and that absence is refused before ship and promote run rather than
+// after, when the only remaining honest report would be "promoted, nothing
+// to watch".
+func staticDeployHosts(hostFlag string, recs []apiInstanceRecord) ([]string, error) {
+ if hostFlag != "" {
+ return []string{hostFlag}, nil
+ }
+ var hosts []string
+ for _, r := range recs {
+ hosts = append(hosts, r.Host)
+ }
+ if len(hosts) > 0 {
+ return hosts, nil
+ }
+ return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
+ "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
+ "Name the hostname the site answers on, so the deploy can watch it:\n" +
+ " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
}
// staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to,
@@ -276,11 +335,12 @@ func staticDeployChannel(chans []apiChannel) (string, error) {
// 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 "hostname 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.
+// domainList is read BEFORE any attach POST, and its answer is decisive:
+// re-attaching a host this app already holds is a harmless idempotent 200
+// on the merged door (same-owner converge — #151's claim-writer contract),
+// but it is still a write request this deploy has no business sending: the
+// list already proves there is nothing to do, and only a host held by a
+// DIFFERENT owner answers 409 "hostname unavailable".
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 {
@@ -353,10 +413,11 @@ func hostHasInstance(host string, recs []apiInstanceRecord) bool {
// 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 "hostname unavailable"), which is
-// keeps a repeated `carlos deploy` idempotent rather than erroring on its
-// own prior success.
+// match means false. The merged door would answer a same-owner re-attach
+// with an idempotent 200 (not the 409 an EARLIER contract draft promised),
+// so skipping here is POST-avoidance, not failure-avoidance: the list
+// already said there is nothing to do, and a repeated `carlos deploy` should
+// send zero attach writes, not one no-op per run.
func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomainRecord) bool {
if hostHasInstance(host, recs) {
return false
diff --git a/deploy_test.go b/deploy_test.go
index f914f484e..4172aaabf 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -74,18 +74,25 @@ func TestFilterChannelHosts(t *testing.T) {
}
}
-// TestStaticDeployHosts pins the no-instance refusal (issue #112): a static
-// site has no instance records to resolve a host from, so -host is required —
-// and required BEFORE ship and promote run, since afterwards the only honest
-// report left is "promoted, nothing to watch".
+// 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")
+ hosts, err := staticDeployHosts("carlosframework.com", nil)
if err != nil || len(hosts) != 1 || hosts[0] != "carlosframework.com" {
t.Fatalf("with -host: %v %v", hosts, err)
}
- _, err = staticDeployHosts("")
+ 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 instances and no -host must be refused")
+ t.Fatal("an app with no records and no -host must be refused")
}
for _, want := range []string{
"no instance records",
@@ -603,6 +610,7 @@ type deployFakeConsole struct {
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
@@ -615,7 +623,13 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
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{}})
+ 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)
@@ -676,6 +690,12 @@ func (fc *deployFakeConsole) setDomains(domains []map[string]string) {
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()
@@ -1112,3 +1132,172 @@ func TestDeployAttachRefusalStopsBeforeShip(t *testing.T) {
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 <h>` 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() ([]apiChannel, error) {
+ channelsCalled = true
+ return []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 static records themselves are the watch targets.
+ _, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "canary/site", "", channels)
+ if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" {
+ t.Fatalf("no -host = hosts %v static %v err %v, want the record's host watched", hosts, static, err)
+ }
+
+ // 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 <h>` — 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 "<h2>"`.
+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()
+}
diff --git a/docs/superpowers/specs/2026-08-12-domain-attach-design.md b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
index ecaee696c..0860a218e 100644
--- a/docs/superpowers/specs/2026-08-12-domain-attach-design.md
+++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
@@ -129,11 +129,19 @@ authority is identical — that is #137's contract.
One endpoint family (built by #151, extended here):
```
-POST /api/cli/apps/{account}/{app}/domains/{host} attach
+POST /api/cli/apps/{account}/{app}/domains attach ({host, route?|kind?, channel?} body)
GET /api/cli/apps/{account}/{app}/domains list + stamps
DELETE /api/cli/apps/{account}/{app}/domains/{host} detach
```
+One deliberate asymmetry, stated so nobody reads it as a bug: the POST
+answers the RESOLVED route — the alias target, or the host itself on a
+first-route attach (the record just declared makes the host the app's
+primary) — while the GET reports the claim's STORED `route`, which for a
+first-route claim is empty (the claim marks the host as its own primary,
+not an alias of anything). Both are honest; they answer different
+questions ("where did my hostname land?" vs "what does the claim say?").
+
Gating follows the CLI-API family's pattern, not the browser doors':
bearer token (app-token tried first, exact sqid+app binding), byte-
identical 404 for every non-answer, rate limit on the token hash, audit
diff --git a/domains_e2e_test.go b/domains_e2e_test.go
index 9e0b26919..87488d55f 100644
--- a/domains_e2e_test.go
+++ b/domains_e2e_test.go
@@ -177,6 +177,31 @@ func TestE2EDomainsAttachThroughDeploy(t *testing.T) {
t.Fatalf("record = %+v, want a static record on %s", *rec, channel)
}
+ // --- 1b. a SECOND static host on the same app: the first deploy's
+ // record must not pull this one off the static path (the kind-aware
+ // gate), and the fresh host attaches through the same door on the way.
+ const second = "e2e-domains-more.example.test"
+ out, err = run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", "sha0002", "-channel", channel, "-host", second, "-console", ts.URL,
+ "-timeout", "3s", siteDir)
+ if err == nil {
+ t.Fatalf("second-host deploy to an unpointed domain exited 0:\n%s", out)
+ }
+ for _, want := range []string{
+ "attached " + second,
+ "DNS for " + second + " is not pointing at this deployment yet",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("second-host deploy output missing %q:\n%s", want, out)
+ }
+ }
+ if _, err := domains.GetClaim(context.Background(), relStore, second); err != nil {
+ t.Fatalf("claim for the second host: %v", err)
+ }
+ if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, second); err != nil {
+ t.Fatalf("record for the second host: %v", err)
+ }
+
// --- 2. the same host from ANOTHER account: neutral refusal ----------
out, err = run(rivalCreds, "domains", "attach", "-app", rivalApp, "-account", rivalSqid,
"-route", "web.cdef.oncarlos.test", "-console", ts.URL, host)
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 4d4807519..00e2cd0c0 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -141,15 +141,14 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a
// route if it moved, and a hostname anyone ELSE holds is the neutral 409.
//
// The optional kind/channel body fields are `carlos deploy`'s first-route
-// extras (#82): kind "static" means "this hostname should BECOME the app's
-// primary route if it has none" — when the app holds no ready route, the
-// route-defaulting below (which needs an instance record a static app never
-// has) is short-circuited and the attach runs through the shared
-// attachDomain core instead, writing the claim AND a declared static
-// provision record the reconciler materializes as the app's first route.
-// An app that already has a ready route falls through to the plain alias
-// path, kind or no kind. route and kind together are refused: an alias
-// target and a first-route mint are two different instructions.
+// extras (#82): kind "static" routes the attach through the shared
+// attachDomain core (domainattach.go) instead of the route-defaulting below
+// (which needs an instance record a static app never has). The core itself
+// decides what the attach means — an app holding a ready route gets an
+// alias claim naming it; one holding none gets the claim AND a declared
+// static provision record the reconciler materializes as the app's first
+// route. route and kind together are refused: an alias target and a
+// first-route mint are two different instructions.
func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
principal, acct, app, ok := s.instanceGate(w, r)
if !ok {
@@ -190,26 +189,24 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
if in.Kind == "static" {
- ready, _, err := s.readyRouteHost(acct.Sqid, app, host)
- if err != nil {
- log.Printf("console: api domains attach %s/%s %s: registry: %v", acct.Sqid, app, host, err)
- jsonError(w, http.StatusBadGateway, "could not read this deployment's routes — try again")
+ // The shared core decides alias-vs-first-route itself (readyRouteHost
+ // is ITS question — no pre-read here): a ready route makes this an
+ // ordinary alias claim naming it, none makes the host the app's
+ // first route (claim + declared static record). The answer comes
+ // from the core's result: on the first-route branch the host IS the
+ // route the record just declared; on the alias branch it is the
+ // route the claim names.
+ res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal)
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
return
}
- if ready == "" {
- res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal)
- if ref != nil {
- jsonError(w, ref.Status, ref.Msg)
- return
- }
- // The host IS the route on this branch — the record just
- // declared makes it the app's primary. Reported as such, so a
- // caller that reads route back learns where the hostname lands.
- writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": host, "created": res.Created})
- return
+ route := res.Claim.Route
+ if res.FirstRoute {
+ route = host
}
- // A ready route exists, so "static" has nothing to mint — the attach
- // is an ordinary alias, resolved on the standard path below.
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": res.Created})
+ return
}
route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route)
if !ok {
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index b5462cc23..76c702ec2 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -23,6 +23,7 @@ import (
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/status"
)
@@ -478,3 +479,114 @@ func TestAPIDomainSerialBump(t *testing.T) {
t.Fatalf("audit rows after a no-op detach = %d, want it unmoved at 3", got)
}
}
+
+// attachBodyKind is attachBody with the deploy extras (#82): kind and/or
+// channel ride the same POST body; empty fields are simply absent, matching
+// what the client sends (omitempty).
+func attachBodyKind(host, route, kind, channel string) string {
+ m := map[string]string{"host": host}
+ if route != "" {
+ m["route"] = route
+ }
+ if kind != "" {
+ m["kind"] = kind
+ }
+ if channel != "" {
+ m["channel"] = channel
+ }
+ b, err := json.Marshal(m)
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
+// TestAPIDomainAttachKindValidation pins the two 400s the kind extension
+// adds: route and kind together (an alias target and a first-route mint are
+// two different instructions), and any kind but "static" (nothing else can
+// be minted from a domain attach). Neither writes a claim.
+func TestAPIDomainAttachKindValidation(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-kind-400")
+ for name, body := range map[string]string{
+ "route and kind together": attachBodyKind("both.example.com", "go.tito.io", "static", ""),
+ "kind other than static": attachBodyKind("both.example.com", "", "binary", ""),
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusBadRequest {
+ t.Errorf("%s = %d %s, want 400", name, code, out)
+ }
+ claimAbsent(t, fx, "both.example.com")
+ }
+}
+
+// TestAPIDomainAttachKindStaticFirstRoute: kind=static against an app with
+// no ready route runs the shared attach core's first-route branch — the
+// claim (empty stored route) AND a declared static provision record land,
+// and the answer's route is the host itself, the primary the record just
+// declared. This is `carlos deploy -kind static -host <h>`'s server half.
+func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-kind-first")
+ // The fixture registry holds no titogo routes, so the core has nothing
+ // to alias; the first-route branch needs something shipped to resolve
+ // the record's channel from.
+ shipVersion(t, fx, "titogo", "v1")
+ seedChannel(t, fx, "titogo", "canary/live", "v1")
+ const host = "fresh.example.com"
+
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBodyKind(host, "", "static", ""))
+ if code != http.StatusOK {
+ t.Fatalf("attach = %d %s, want 200", code, out)
+ }
+ var got domainAttachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("attach body %s: %v", out, err)
+ }
+ if got != (domainAttachResp{Host: host, Route: host, Created: true}) {
+ t.Fatalf("attach answer = %+v, want route == host (the first-route primary) and created true", got)
+ }
+ c := claimOf(t, fx, host)
+ if c.Route != "" || c.Account != "bab" || c.App != "titogo" {
+ t.Fatalf("claim = %+v, want a first-route claim (empty stored route) for bab/titogo", *c)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host)
+ if err != nil {
+ t.Fatalf("record: %v, want the declared static record", err)
+ }
+ if rec.Kind != "static" || rec.Channel != "canary/live" || rec.Slug != "" {
+ t.Fatalf("record = %+v, want a static record on canary/live", *rec)
+ }
+}
+
+// TestAPIDomainAttachKindStaticWithReadyRouteAliases: the same kind=static
+// body against an app that DOES hold a ready route is an ordinary alias —
+// the core answers its own question and nothing static is minted: the claim
+// stores the ready route, the answer reports it, and no provision record
+// appears at the host's key.
+func TestAPIDomainAttachKindStaticWithReadyRouteAliases(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-kind-alias")
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: "titogo.bab.oncarlos.test", Kind: "static", App: "titogo",
+ Account: "bab", Channel: "canary/live", Status: "ready"},
+ )
+ const host = "alias.example.com"
+
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBodyKind(host, "", "static", ""))
+ if code != http.StatusOK {
+ t.Fatalf("attach = %d %s, want 200", code, out)
+ }
+ var got domainAttachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("attach body %s: %v", out, err)
+ }
+ if got != (domainAttachResp{Host: host, Route: "titogo.bab.oncarlos.test", Created: true}) {
+ t.Fatalf("attach answer = %+v, want the ready route as the alias target", got)
+ }
+ if c := claimOf(t, fx, host); c.Route != "titogo.bab.oncarlos.test" {
+ t.Fatalf("claim = %+v, want the alias claim naming the ready route", *c)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("record: %v, want NONE — an alias attach must not mint a static record", err)
+ }
+}