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>
7 files changed,
+458
−59
apimode.go+7 −0deploy.go+85 −24deploy_test.go+197 −8docs/superpowers/specs/2026-08-12-domain-attach-design.md+9 −1domains_e2e_test.go+25 −0internal/console/apidomains.go+23 −26internal/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) |
| + } |
| +} |