Merge pull request #175 from carlosframework/worktree-domain-attach
Domain attach: self-serve custom domains from Settings, CLI and deploy (#82)
20 files changed,
+4720
−82
apimode.go+48 −6deploy.go+330 −15deploy_test.go+941 −7docs/superpowers/plans/2026-08-12-domain-attach.md+757 −0docs/superpowers/specs/2026-08-12-domain-attach-design.md+256 −0domains_cli.go+49 −5domains_cli_test.go+63 −19domains_e2e_test.go+253 −0internal/console/apidomains.go+69 −3internal/console/apidomains_test.go+112 −0internal/console/appdomains.go+106 −0internal/console/appdomains_test.go+213 −0internal/console/appsettings.go+98 −4internal/console/appsettings_test.go+106 −0internal/console/domainattach.go+424 −0internal/console/domainattach_test.go+711 −0internal/console/server.go+12 −1internal/console/templates/app_settings.html+37 −17internal/console/view.go+27 −5internal/provision/reconcile_test.go+108 −0
diff --git a/apimode.go b/apimode.go| index 259a25cb3..a5ede5b1b 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1547,6 +1547,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 |
| @@ -1759,7 +1766,10 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st |
| // Wildcard/Catchall are always present (true or false, never omitted — the |
| // door knows both for every claim); DelegationStatus is "delegated"/ |
| // "missing", empty when the deployment has no delegation zone configured or |
| -// the claim doesn't have Wildcard. |
| +// the claim doesn't have Wildcard. Guidance is the point-your-DNS-here text |
| +// (the console Settings page's own wording), present only while DNSStatus |
| +// is not "pointing" — additive, so a console that predates it simply never |
| +// sends it. |
| type apiDomainRecord struct { |
| Host string `json:"host"` |
| Route string `json:"route"` |
| @@ -1769,8 +1779,27 @@ type apiDomainRecord struct { |
| Wildcard bool `json:"wildcard"` |
| Catchall bool `json:"catchall"` |
| DelegationStatus string `json:"delegation_status,omitempty"` |
| + 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 } |
| + |
| // domainAttach asks the console to claim host for app, pointed at route (a |
| // route hostname, or "" to let the console default it to the app's single |
| // instance record). wildcard/catchall are the Task 5 capability flips — nil |
| @@ -1785,13 +1814,24 @@ type apiDomainRecord struct { |
| // hostname) surfaces as "hostname unavailable" verbatim, and a 422 (bad |
| // wildcard/catchall request) surfaces its own message verbatim too — |
| // apiErrorFromBody's json-first decode doing the work either way. |
| -func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string, wildcard, catchall *bool) (created bool, resolvedRoute string, err error) { |
| +// |
| +// kind and channel are `carlos deploy`'s first-route extras (#82): kind |
| +// "static" tells a console holding no ready route for this app to mint the |
| +// host AS the app's first route (a declared static record) instead of |
| +// refusing to default an alias target, and channel names the channel that |
| +// record follows. Both empty is the plain alias attach, and empty strings |
| +// are omitted from the body, so a console that predates the fields never |
| +// sees them. route and kind together are the door's 400 — an alias target |
| +// and a first-route mint are two different instructions. |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool) (created bool, resolvedRoute string, err error) { |
| in := struct { |
| Host string `json:"host"` |
| Route string `json:"route,omitempty"` |
| + Kind string `json:"kind,omitempty"` |
| + Channel string `json:"channel,omitempty"` |
| Wildcard *bool `json:"wildcard,omitempty"` |
| Catchall *bool `json:"catchall,omitempty"` |
| - }{host, route, wildcard, catchall} |
| + }{host, route, kind, channel, wildcard, catchall} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in) |
| if err != nil { |
| return false, "", err |
| @@ -1833,14 +1873,16 @@ func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host stri |
| } |
| // domainList reads the app's hostname claims, each joined to the fleet's |
| -// readings sweep for status/dns_status/cert_expires_at. |
| +// readings sweep for status/dns_status/cert_expires_at. Failures come back |
| +// wrapped in *domainListError so deploy's degrade paths can read the HTTP |
| +// status; the wrapper is transparent (Error/Unwrap) everywhere else. |
| func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, 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 []apiDomainRecord `json:"domains"` |
diff --git a/deploy.go b/deploy.go| index 14327c5ac..aea3d8402 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -74,6 +74,9 @@ func runDeploy(args []string) { |
| if line := deployingLine(hosts, channel); line != "" { |
| fmt.Fprintln(stdout, line) |
| } |
| + if staticDeploy && *hostFlag != "" { |
| + attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs) |
| + } |
| // printBanner=false: the "using ..." line already printed above, before |
| // the instance list — so a listing failure still names the console — |
| // and runShipAPI would otherwise print it a second time. |
| @@ -126,6 +129,9 @@ func runDeploy(args []string) { |
| logsHint: fmt.Sprintf("carlos logs -app %s -account %s -stream platform", *app, acct.Seg), |
| } |
| if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second, diag); err != nil { |
| + if staticDeploy && *hostFlag != "" && isConnectionClassFailure(err) { |
| + failDeployDNSGuidance(ctx, sess, acct, *app, *hostFlag) |
| + } |
| log.Fatalf("deploy: %v", err) |
| } |
| } |
| @@ -177,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) |
| @@ -215,19 +238,84 @@ 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 resolves a static-path deploy's channel from the |
| +// records themselves — 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. With -host, it is that host's record's |
| +// channel; without, it is the single distinct non-empty channel the records |
| +// agree on — the no-host redeploy is watching THOSE hosts, so their own |
| +// channel is the only one that keeps the promote and the watch pointed at |
| +// the same thing (a channels listing that happened to hold one OTHER |
| +// channel would promote onto it while watching hosts following another). |
| +// "" — host unrecorded, records disagreeing, or nothing recorded — sends |
| +// the caller to the channels listing (and its refusal names -channel). |
| +func staticRecordChannel(recs []apiInstanceRecord, host string) string { |
| + if host != "" { |
| + for _, r := range recs { |
| + if r.Host == host { |
| + return r.Channel |
| + } |
| + } |
| + return "" |
| + } |
| + channel := "" |
| + for _, r := range recs { |
| + if r.Channel == "" { |
| + continue |
| + } |
| + if channel == "" { |
| + channel = r.Channel |
| + } else if r.Channel != channel { |
| + return "" // disagreeing records: a human picks, via -channel |
| + } |
| + } |
| + return channel |
| +} |
| + |
| +// 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 { |
| + // Static records only, so the helper holds its own invariant |
| + // rather than trusting the caller's gate: an instance record's |
| + // host is adopted and watched via the instance path, never here. |
| + if r.Kind != "static" { |
| + continue |
| + } |
| + 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, |
| @@ -262,6 +350,233 @@ func staticDeployChannel(chans []apiChannel) (string, error) { |
| "pass -channel to pick one: %s", strings.Join(names, ", ")) |
| } |
| +// attachUnclaimedHost auto-attaches -host before shipping, when this deploy |
| +// is targeting a fresh static host with no route of its own yet (self-serve |
| +// custom domains, #82/#151): the app's first deploy to a brand-new hostname |
| +// should not need a separate `carlos domains attach` first. recs is the |
| +// SAME instance list runDeploy already fetched for deployTargets — checked |
| +// again here rather than assumed empty, so this stays correct even if a |
| +// future caller ever reaches it with records present. |
| +// |
| +// domainList is read BEFORE any attach POST, and its answer is decisive: |
| +// 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". |
| +// |
| +// Since the kind-aware static gate, this runs on REDEPLOYS too (records |
| +// present), not just the first deploy — so a 401/403 on the GET aborting |
| +// the whole deploy is a question worth answering: the domains door sits |
| +// behind the SAME instanceGate as the instanceList runDeploy has already |
| +// passed, so a token that got this far can list domains too, and a 401/403 |
| +// here means the credential genuinely lost authority mid-deploy — worth |
| +// stopping for, not degrading past. |
| +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 { |
| + switch { |
| + case domainsDoorMissing(err): |
| + // A console that predates the /domains door (Task 8 wires the |
| + // 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) |
| + } |
| + } |
| + if !domainNeedsAttach(host, recs, domains) { |
| + return |
| + } |
| + // kind travels only when it is "static" — the one shape a first-route |
| + // attach accepts (attachDomain, internal/console/domainattach.go); empty |
| + // leaves the field out of the wire body entirely rather than sending a |
| + // value the server would refuse. 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 |
| + } |
| + // The door, not this call's own request, decides alias-vs-first-route |
| + // (spec §4's "one deliberate asymmetry"): resolvedRoute is the host |
| + // itself on a genuine first-route attach, or a DIFFERENT route when the |
| + // app already had one ready and the door aliased onto it instead — |
| + // which can happen even though attachKind asked for "static", since a |
| + // ready route always wins over minting a second one (§1's yes/no |
| + // split lives door-side). Printing attachKind/channel unconditionally |
| + // here would claim a first route was minted when the door actually |
| + // aliased, so the two cases get two different lines, and the alias one |
| + // never names a channel — an alias serves the primary's channel, not |
| + // one of its own. |
| + _, resolvedRoute, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel, nil, nil) |
| + if err != nil { |
| + log.Fatalf("deploy: attach %s: %v", host, err) |
| + } |
| + if resolvedRoute == host { |
| + fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel) |
| + } else { |
| + fmt.Fprintf(stdout, "%s %s (aliases %s)\n", colorFor(stdout).ok("attached"), host, resolvedRoute) |
| + } |
| +} |
| + |
| +// 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 |
| +// 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. 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 |
| + } |
| + for _, d := range domains { |
| + if d.Host == host { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// 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 { |
| + 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 |
| +// text names a target that never even answered — the "unreachable: ..." |
| +// diag watchVersion emits when http.Client.Get itself failed (DNS |
| +// resolution, connection refused, TLS handshake: every shape a domain whose |
| +// DNS is not yet pointing here produces), as opposed to a reachable origin |
| +// that simply isn't serving the right version yet. watchVersion reports one |
| +// combined string, not a structured per-host reason, so a substring check is |
| +// what there is to go on here — precise enough because deploy only ever |
| +// reaches this with -host naming the single target that timed out. |
| +func isConnectionClassFailure(err error) bool { |
| + return err != nil && strings.Contains(err.Error(), "unreachable:") |
| +} |
| + |
| +// 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: |
| +// |
| +// - DNSStatus == "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). |
| +// - DNSStatus != "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 DNSStatus != "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 []apiDomainRecord, host, app string, acct apiAcct) (msg string, ok bool) { |
| + for _, d := range domains { |
| + if d.Host != host { |
| + continue |
| + } |
| + switch { |
| + case d.DNSStatus == "pointing": |
| + return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ |
| + "Watch with 'carlos domains list -app %s -account %s'; re-run this deploy shortly — the shipped release is already promoted.", |
| + host, app, acct.Seg), true |
| + case d.DNSStatus != "pointing" && d.Guidance != "": |
| + return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+ |
| + "Watch with 'carlos domains list -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 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 |
| + } |
| + if msg, ok := dnsGuidanceMessage(domains, host, app, acct); ok { |
| + log.Fatal(msg) |
| + } |
| +} |
| + |
| // requireDeployAPISession resolves signed-in mode for deploy, refusing |
| // direct-bucket mode with deploy's OWN message — not instances_cli.go's |
| // requireAPISession, whose hardcoded text ("carlos instances %s works |
diff --git a/deploy_test.go b/deploy_test.go| index 6ea48fdab..992aedc0c 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -2,9 +2,15 @@ package main |
| import ( |
| "bytes" |
| + "context" |
| + "encoding/json" |
| "fmt" |
| + "io" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -68,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", |
| @@ -467,3 +480,924 @@ func TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint(t *testing.T) { |
| t.Fatalf("empty reason printed a blank line: %v", err) |
| } |
| } |
| + |
| +// ---- domain attach (Task 6, #82/#151) -------------------------------- |
| +// |
| +// The server half of the wire contract below does not exist yet (Task 8 |
| +// builds it, gated on #151 landing); these tests pin what THIS client sends |
| +// and expects against a hand-rolled fake console, exactly like |
| +// TestAPISessionRestartWireShape (restart_test.go) does for a door that |
| +// already shipped. |
| + |
| +// TestAPISessionDomainListAndAttachWireShape pins the wire shape the |
| +// console door (apidomains.go, #151's contract) answers: GET .../domains |
| +// for the list (rows carry dns_status/guidance), POST .../domains with a |
| +// {"host","kind","channel"} body for attach, decoding a 200 into |
| +// (created, resolvedRoute). |
| +func TestAPISessionDomainListAndAttachWireShape(t *testing.T) { |
| + var gotListPath string |
| + var gotAttachPath, gotAttachMethod, gotAttachBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/domains"): |
| + gotListPath = r.URL.RequestURI() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "domains": []map[string]string{{"host": "existing.example.test", "route": "static", "status": "live", "dns_status": "pointing"}}, |
| + }) |
| + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"): |
| + gotAttachPath, gotAttachMethod = r.URL.RequestURI(), r.Method |
| + b, _ := io.ReadAll(r.Body) |
| + gotAttachBody = string(b) |
| + json.NewEncoder(w).Encode(map[string]any{"host": "fresh.example.test", "route": "fresh.example.test", "created": true}) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + domains, err := sess.domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatalf("domainList: %v", err) |
| + } |
| + if gotListPath != "/api/cli/apps/ops/sitely/domains" { |
| + t.Fatalf("list path = %s", gotListPath) |
| + } |
| + if len(domains) != 1 || domains[0].Host != "existing.example.test" || domains[0].DNSStatus != "pointing" { |
| + t.Fatalf("domains = %+v", domains) |
| + } |
| + |
| + created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", |
| + "fresh.example.test", "", "static", "canary/live", nil, nil) |
| + if err != nil { |
| + t.Fatalf("domainAttach: %v", err) |
| + } |
| + if gotAttachMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotAttachMethod) |
| + } |
| + if gotAttachPath != "/api/cli/apps/ops/sitely/domains" { |
| + t.Fatalf("path = %s", gotAttachPath) |
| + } |
| + if !strings.Contains(gotAttachBody, `"host":"fresh.example.test"`) || |
| + !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) { |
| + t.Fatalf("body = %s, want host+kind+channel", gotAttachBody) |
| + } |
| + if strings.Contains(gotAttachBody, `"route"`) { |
| + t.Fatalf("body = %s, empty route must be omitted", gotAttachBody) |
| + } |
| + if !created || resolvedRoute != "fresh.example.test" { |
| + t.Fatalf("attach result = created %v route %q", created, resolvedRoute) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainAttachRefusalSurfacesVerbatim pins the two refusal |
| +// shapes the wire contract names: 409 "hostname unavailable" (a host |
| +// already held — the binding note deploy's own gate exists to avoid ever |
| +// triggering) and a 422 policy refusal. Both must reach the caller in the |
| +// console's own words, via apiErrorFromBody, never rewritten. |
| +func TestAPISessionDomainAttachRefusalSurfacesVerbatim(t *testing.T) { |
| + for _, tc := range []struct { |
| + status int |
| + msg string |
| + }{ |
| + {http.StatusConflict, "hostname unavailable"}, |
| + {http.StatusUnprocessableEntity, "not a usable hostname"}, |
| + } { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(tc.status) |
| + json.NewEncoder(w).Encode(map[string]string{"error": tc.msg}) |
| + })) |
| + _, _, err := newTestSession(ts.URL).domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", |
| + "x.test", "", "static", "canary/live", nil, nil) |
| + ts.Close() |
| + if err == nil || !strings.Contains(err.Error(), tc.msg) { |
| + t.Fatalf("status %d: err = %v, want %q verbatim", tc.status, err, tc.msg) |
| + } |
| + } |
| +} |
| + |
| +// TestDomainNeedsAttach pins the gate itself, independent of any HTTP call: |
| +// a host answered by an existing instance record, or already named in |
| +// domainList, must never be attached again. |
| +func TestDomainNeedsAttach(t *testing.T) { |
| + recs := []apiInstanceRecord{{Host: "has-instance.test"}} |
| + domains := []apiDomainRecord{{Host: "already-claimed.test"}} |
| + cases := []struct { |
| + host string |
| + want bool |
| + }{ |
| + {"fresh.test", true}, |
| + {"has-instance.test", false}, |
| + {"already-claimed.test", false}, |
| + } |
| + for _, c := range cases { |
| + if got := domainNeedsAttach(c.host, recs, domains); got != c.want { |
| + t.Errorf("domainNeedsAttach(%q) = %v, want %v", c.host, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// deployFakeConsole is a minimal console double for `carlos deploy`'s own |
| +// tests: only the doors deploy itself calls (instances, domains, releases, |
| +// promote) — not internal/console's real handlers, since the CLI-facing |
| +// domains door does not exist on this branch yet (Task 8 builds it, |
| +// integrating against #151's real one). calls records every hit, in order, |
| +// so a test can assert attach happened before ship rather than merely that |
| +// it happened at all. |
| +type deployFakeConsole struct { |
| + ts *httptest.Server |
| + |
| + mu sync.Mutex |
| + calls []string // "METHOD /path", in hit order |
| + domains []map[string]string |
| + instances []apiInstanceRecord // nil = the app has no records |
| + shipVersion string |
| + domainsListStatus int // 0 = normal 200 JSON list; else this status with a plain (non-JSON) body — simulates an old console (404) or an unwell one (5xx) |
| + attachStatus int // 0 = normal 201; else this status with an {"error": attachErrMsg} body |
| + attachErrMsg string |
| + // attachRouteOverride, when non-empty, is the "route" the POST /domains |
| + // response names instead of echoing the attached host — simulating the |
| + // door aliasing this host onto an existing route rather than minting it |
| + // as a first route, the review#1 case attachUnclaimedHost must read off |
| + // the door's own answer rather than assume. |
| + attachRouteOverride string |
| +} |
| + |
| +func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole { |
| + t.Helper() |
| + fc := &deployFakeConsole{shipVersion: shipVersion} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + fc.mu.Lock() |
| + recs := fc.instances |
| + fc.mu.Unlock() |
| + if recs == nil { |
| + recs = []apiInstanceRecord{} |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"instances": recs}) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + fc.mu.Lock() |
| + domains, status := fc.domains, fc.domainsListStatus |
| + fc.mu.Unlock() |
| + if status != 0 { |
| + // A plain body with no JSON "error" field, on purpose: this is |
| + // what an unregistered route (old console) OR a bare 5xx (an |
| + // unwell one) both look like on the wire — neither carries a |
| + // server diagnosis, which is exactly the shape apiErrorFromBody |
| + // treats as "not a real diagnosis, just a status". |
| + w.WriteHeader(status) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"domains": domains}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + fc.mu.Lock() |
| + status, errMsg, routeOverride := fc.attachStatus, fc.attachErrMsg, fc.attachRouteOverride |
| + fc.mu.Unlock() |
| + if status != 0 { |
| + w.WriteHeader(status) |
| + json.NewEncoder(w).Encode(map[string]string{"error": errMsg}) |
| + return |
| + } |
| + var in struct { |
| + Host string `json:"host"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&in) |
| + route := in.Host |
| + if routeOverride != "" { |
| + route = routeOverride |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"host": in.Host, "route": route, "created": true}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + fc.mu.Lock() |
| + ver := fc.shipVersion |
| + fc.mu.Unlock() |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]string{"version": ver}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false}) |
| + }) |
| + fc.ts = httptest.NewServer(mux) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +// setDomains is the mutex-guarded way to change what the fake's GET |
| +// .../domains answers once the server is already live (t.Setenv-style |
| +// direct field writes are only safe before the httptest.Server starts |
| +// serving requests). |
| +func (fc *deployFakeConsole) setDomains(domains []map[string]string) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.domains = domains |
| +} |
| + |
| +func (fc *deployFakeConsole) setInstances(recs []apiInstanceRecord) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.instances = recs |
| +} |
| + |
| +func (fc *deployFakeConsole) setDomainsListStatus(status int) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.domainsListStatus = status |
| +} |
| + |
| +func (fc *deployFakeConsole) setAttachRefusal(status int, msg string) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.attachStatus, fc.attachErrMsg = status, msg |
| +} |
| + |
| +func (fc *deployFakeConsole) setAttachRouteOverride(route string) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.attachRouteOverride = route |
| +} |
| + |
| +func (fc *deployFakeConsole) record(r *http.Request) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.calls = append(fc.calls, r.Method+" "+r.URL.Path) |
| +} |
| + |
| +// callIndex returns the index of the first recorded call containing sub, or |
| +// -1 — used both to assert a call happened and, via two calls, that one |
| +// happened before another. |
| +func (fc *deployFakeConsole) callIndex(sub string) int { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + for i, c := range fc.calls { |
| + if strings.Contains(c, sub) { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +// TestDeployAutoAttachesUnclaimedStaticHost pins the auto-attach behaviour |
| +// end to end: a -host deploy against a fresh static target (no instance |
| +// record, no domainList entry) attaches the host BEFORE shipping and says |
| +// so; a repeated deploy against the SAME host, once domainList already |
| +// names it, must not re-POST attach at all — the binding note's 409 "host |
| +// unavailable" is what a re-attach gets, so this is what keeps a repeated |
| +// `carlos deploy` idempotent rather than erroring on its own prior success. |
| +func TestDeployAutoAttachesUnclaimedStaticHost(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + const ( |
| + app = "sitely" |
| + host = "sitely.example.test" |
| + channel = "canary/site" |
| + ) |
| + fc := newDeployFakeConsole(t, "sha0001") |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The watch leg's own edge: always answers whatever version this run |
| + // expects, so the test is about the attach step, not about racing a |
| + // slow-to-converge edge. |
| + var edgeVersion atomic.Value |
| + edgeVersion.Store("sha0001") |
| + edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", edgeVersion.Load().(string)) |
| + })) |
| + defer edgeSrv.Close() |
| + origOrigin := watchOrigin |
| + watchOrigin = func(h string) string { return edgeSrv.URL } |
| + defer func() { watchOrigin = origOrigin }() |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out := buf.String() |
| + |
| + attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains") |
| + if attachIdx < 0 { |
| + t.Fatalf("attach was never POSTed: %v", fc.calls) |
| + } |
| + if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx { |
| + t.Fatalf("ship (index %d) happened before attach (index %d): %v", shipIdx, attachIdx, fc.calls) |
| + } |
| + if want := "attached " + host + " (first route: static, channel " + channel + ")"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing %q:\n%s", want, out) |
| + } |
| + |
| + // --- re-run: domainList now names the host, as the console would once |
| + // the first attach landed. The second deploy must NOT re-POST attach. -- |
| + fc.mu.Lock() |
| + fc.domains = []map[string]string{{"host": host, "route": "static", "dns_status": "pointing"}} |
| + fc.calls = nil |
| + fc.shipVersion = "sha0002" |
| + fc.mu.Unlock() |
| + edgeVersion.Store("sha0002") |
| + |
| + buf.Reset() |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out = buf.String() |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { |
| + t.Fatalf("a host already in domainList must not trigger an attach POST: %v", fc.calls) |
| + } |
| + if strings.Contains(out, "attached "+host) { |
| + t.Fatalf("stdout claims an attach that must not have happened:\n%s", out) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { |
| + t.Fatalf("ship never happened on the re-run: %v", fc.calls) |
| + } |
| + |
| + checkNoRegistry() |
| +} |
| + |
| +// TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute is final-review |
| +// finding #1: attachUnclaimedHost must print off the door's OWN answer, not |
| +// assume "first route" just because that's what it asked for. A console |
| +// that resolves this deploy's -host to a DIFFERENT route than the host |
| +// itself — the alias case, e.g. because the app already had a ready route |
| +// by the time the door decided (spec §1's yes/no split lives door-side, |
| +// not in what this call sent) — must get the "aliases <route>" line with |
| +// no channel named; a channel belongs to the primary route, and an alias |
| +// serves whatever channel that primary already follows. |
| +func TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + const ( |
| + app = "sitely" |
| + host = "alias.example.test" |
| + primary = "sitely.canary.oncarlos.test" |
| + channel = "canary/site" |
| + ) |
| + fc := newDeployFakeConsole(t, "sha0001") |
| + fc.setAttachRouteOverride(primary) |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", "sha0001") |
| + })) |
| + defer edgeSrv.Close() |
| + origOrigin := watchOrigin |
| + watchOrigin = func(h string) string { return edgeSrv.URL } |
| + defer func() { watchOrigin = origOrigin }() |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out := buf.String() |
| + |
| + if want := "attached " + host + " (aliases " + primary + ")"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want the alias wording naming the door's resolved route %q", out, primary) |
| + } |
| + if strings.Contains(out, "first route") || strings.Contains(out, "channel "+channel) { |
| + t.Fatalf("stdout wrongly named a channel for an alias attach (a channel belongs to the primary):\n%s", out) |
| + } |
| + |
| + checkNoRegistry() |
| +} |
| + |
| +// TestDeployExitsHonestlyWhenDNSIsNotPointingYet pins the exit contract for |
| +// a connection-class watch timeout: the deploy itself already succeeded |
| +// (shipped, promoted) but the host never answered at all — the shape a |
| +// domain that has not been pointed here yet produces — so the exit text |
| +// must say DNS specifically, carry the console's own guidance, and still |
| +// exit nonzero: "deploy failed" would be a lie (the release IS live), and a |
| +// zero exit would be a worse one (the URL does not serve it). |
| +// |
| +// Run as a subprocess (buildCarlos + exec.Command, carlosTestEnv's own |
| +// pattern): log.Fatal exits the process, which would take the rest of this |
| +// package's tests down with it if this ran in-process. The host is a bare |
| +// loopback address with nothing listening on :443 — an instant, hermetic |
| +// "unreachable" that needs no real DNS or outbound network access, and |
| +// exercises the exact diag class (watchVersion's "unreachable: ...", from |
| +// http.Client.Get itself failing) a not-yet-pointing domain produces. The |
| +// fake console already lists the host as claimed (domainList has an entry |
| +// with guidance), so auto-attach is a no-op here — this is the SECOND deploy |
| +// to an already-attached host shape, not the first. |
| +func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) { |
| + bin := buildCarlos(t) |
| + const ( |
| + app = "sitely" |
| + host = "127.0.0.1" |
| + channel = "canary/site" |
| + version = "sha0001" |
| + guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test" |
| + ) |
| + fc := newDeployFakeConsole(t, version) |
| + fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}}) |
| + |
| + credPath := filepath.Join(t.TempDir(), "credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", credPath) |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static", |
| + "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL, |
| + "-timeout", "100ms", siteDir) |
| + // carlosTestEnv is the repo rule for a subprocess that shells out to the |
| + // carlos binary — it masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE to its |
| + // own scratch space; the explicit CARLOS_CREDENTIALS below wins over |
| + // that default because os/exec keeps only the LAST value for a |
| + // duplicate key, landing the subprocess on the exact credential file |
| + // this test just wrote to (the same path this process's own |
| + // CARLOS_CREDENTIALS was just pointed at, via t.Setenv above). |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") |
| + cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule (carlosTestEnv's own reasoning) |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("DNS-not-pointing deploy exited 0:\n%s", out) |
| + } |
| + want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide + |
| + ". Watch with 'carlos domains list -app " + app + " -account ops'; re-run this deploy any time — the shipped release is already promoted." |
| + if !strings.Contains(string(out), want) { |
| + t.Fatalf("exit text = %q, want it to contain %q", out, want) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { |
| + t.Fatalf("host already in domainList must not have triggered an attach POST: %v", fc.calls) |
| + } |
| +} |
| + |
| +// TestDeployDNSGuidanceMessageBothArms pins review fix #1: a host-match alone is |
| +// not enough to say "DNS is not pointing" — DNS == "pointing" with a |
| +// connection-class failure means something else (the certificate, almost |
| +// always) is what's still missing, and printing an empty "Create: ." would |
| +// be a lie by omission. Both arms are driven through a real domainList call |
| +// against a fake console (not hand-built apiDomainRecord values), so the test |
| +// also proves the DNS/Guidance fields survive the wire round trip into the |
| +// message. A third case pins the fall-through: no DNS verdict and no |
| +// guidance yet must defer to the generic timeout report rather than guess. |
| +func TestDeployDNSGuidanceMessageBothArms(t *testing.T) { |
| + fakeDomainsServer := func(t *testing.T, domain map[string]string) []apiDomainRecord { |
| + t.Helper() |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{domain}}) |
| + })) |
| + defer ts.Close() |
| + domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return domains |
| + } |
| + |
| + // Arm 1: DNS is pointing, so a connection-class failure means the |
| + // certificate isn't issued yet — never "DNS is not pointing" (that |
| + // would be false), and never an empty "Create: ." (pointing carries no |
| + // guidance at all). |
| + domains := fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "pointing"}) |
| + msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}) |
| + if !ok { |
| + t.Fatal("pointing arm: want ok") |
| + } |
| + for _, want := range []string{ |
| + "DNS for x.test is pointing here, but the certificate is not issued yet", |
| + "re-run this deploy shortly", |
| + "carlos domains list -app sitely -account ops", |
| + } { |
| + if !strings.Contains(msg, want) { |
| + t.Fatalf("pointing-arm message = %q, missing %q", msg, want) |
| + } |
| + } |
| + if strings.Contains(msg, "Create:") || strings.Contains(msg, "not pointing") { |
| + t.Fatalf("pointing arm must not claim DNS is not pointing or print an empty guidance line: %q", msg) |
| + } |
| + |
| + // Arm 2: DNS is elsewhere, with real guidance — the original report. |
| + domains = fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "elsewhere", "guidance": "create a CNAME"}) |
| + msg, ok = dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}) |
| + if !ok { |
| + t.Fatal("not-pointing arm: want ok") |
| + } |
| + for _, want := range []string{ |
| + "DNS for x.test is not pointing at this deployment yet. Create: create a CNAME", |
| + "re-run this deploy any time", |
| + "carlos domains list -app sitely -account ops", |
| + } { |
| + if !strings.Contains(msg, want) { |
| + t.Fatalf("not-pointing-arm message = %q, missing %q", msg, want) |
| + } |
| + } |
| + |
| + // Fall-through: DNS never checked at all (no dns field, no guidance) — |
| + // neither arm applies, so the caller must fall back to the generic |
| + // watchVersion timeout report rather than print a guessed-at message. |
| + domains = fakeDomainsServer(t, map[string]string{"host": "x.test"}) |
| + if _, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { |
| + t.Fatal("no DNS verdict and no guidance yet must fall through, not print a message") |
| + } |
| + |
| + // A host absent from domains entirely must also fall through. |
| + if _, ok := dnsGuidanceMessage(domains, "not-listed.test", "sitely", apiAcct{Seg: "ops"}); ok { |
| + t.Fatal("a host absent from domainList must fall through, not print a message") |
| + } |
| +} |
| + |
| +// TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor pins review |
| +// fix #2: silence is only right when nothing needed attaching anyway. A |
| +// console that predates the /domains door 404s the list; when the target |
| +// host is genuinely unclaimed (absent from the instance list — always true |
| +// on the no-instance static path), the member is told the door is missing |
| +// and the host needs a console-side attach, and the deploy still PROCEEDS |
| +// (ships and promotes) rather than fataling over a feature this console |
| +// simply cannot offer yet. |
| +func TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + const ( |
| + app = "sitely" |
| + host = "sitely.example.test" |
| + channel = "canary/site" |
| + ) |
| + fc := newDeployFakeConsole(t, "sha0001") |
| + fc.setDomainsListStatus(http.StatusNotFound) // old console: the route itself is unregistered |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", "sha0001") |
| + })) |
| + defer edgeSrv.Close() |
| + origOrigin := watchOrigin |
| + watchOrigin = func(h string) string { return edgeSrv.URL } |
| + defer func() { watchOrigin = origOrigin }() |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out := buf.String() |
| + |
| + if !strings.Contains(out, "no domains door") || !strings.Contains(out, host) { |
| + t.Fatalf("stdout missing the no-domains-door note naming %s:\n%s", host, out) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { |
| + t.Fatalf("no domains door — attach must never have been attempted: %v", fc.calls) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { |
| + t.Fatalf("deploy must still proceed to ship: %v", fc.calls) |
| + } |
| + if !strings.Contains(out, "live https://"+host) { |
| + t.Fatalf("deploy must still complete and confirm the URL:\n%s", out) |
| + } |
| +} |
| + |
| +// TestDeployAutoAttachDegradesOnDomainsListServerError pins review fix #3: |
| +// a transport-level or 5xx domainList failure must warn and continue, not |
| +// abort a deploy that would otherwise succeed — the door exists but is |
| +// unwell, which is not the same problem as an unauthorized credential. |
| +func TestDeployAutoAttachDegradesOnDomainsListServerError(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + const ( |
| + app = "sitely" |
| + host = "sitely.example.test" |
| + channel = "canary/site" |
| + ) |
| + fc := newDeployFakeConsole(t, "sha0001") |
| + fc.setDomainsListStatus(http.StatusInternalServerError) |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", "sha0001") |
| + })) |
| + defer edgeSrv.Close() |
| + origOrigin := watchOrigin |
| + watchOrigin = func(h string) string { return edgeSrv.URL } |
| + defer func() { watchOrigin = origOrigin }() |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out := buf.String() |
| + |
| + if !strings.Contains(out, "could not check custom domains") || !strings.Contains(out, host) { |
| + t.Fatalf("stdout missing the degrade warning naming %s:\n%s", host, out) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 { |
| + t.Fatalf("a domainList 500 must not be treated as an unclaimed host — attach must never be attempted: %v", fc.calls) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { |
| + t.Fatalf("deploy must still proceed to ship despite the domainList 500: %v", fc.calls) |
| + } |
| + if !strings.Contains(out, "live https://"+host) { |
| + t.Fatalf("deploy must still complete and confirm the URL:\n%s", out) |
| + } |
| +} |
| + |
| +// TestDeployAttachRefusalStopsBeforeShip pins the no-partial-ship |
| +// invariant: when the console refuses the attach POST (422 — a policy |
| +// refusal, e.g. an unshaped host), the deploy must exit nonzero WITHOUT |
| +// ever reaching ship — a half-done deploy (bytes shipped, but the route |
| +// never attached) would be worse than stopping before anything is written. |
| +// Run as a subprocess: log.Fatal exits the process. |
| +func TestDeployAttachRefusalStopsBeforeShip(t *testing.T) { |
| + bin := buildCarlos(t) |
| + const ( |
| + app = "sitely" |
| + host = "sitely.example.test" |
| + channel = "canary/site" |
| + version = "sha0001" |
| + ) |
| + fc := newDeployFakeConsole(t, version) |
| + fc.setAttachRefusal(http.StatusUnprocessableEntity, "not a usable hostname") |
| + |
| + credPath := filepath.Join(t.TempDir(), "credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", credPath) |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static", |
| + "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL, |
| + "-timeout", "5s", siteDir) |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("deploy with a refused attach exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(string(out), "not a usable hostname") { |
| + t.Fatalf("exit text missing the console's refusal verbatim:\n%s", out) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx >= 0 { |
| + t.Fatalf("ship must never happen after a refused attach: %v", fc.calls) |
| + } |
| +} |
| + |
| +// TestDeployTargetsStaticKindAware pins the kind-aware static gate (task-8 |
| +// review, important #1): a Kind=="static" provision record — the one a |
| +// first-route domain attach writes — is a static-deploy TARGET, not an |
| +// instance that pulls the deploy off the static path. Before this gate, |
| +// the record the FIRST `deploy -kind static -host <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 obvious redeploy invocation once a domain is |
| + // attached: the records are the watch targets AND their agreed channel |
| + // is the promote target, with no -channel and NO channels round trip. A |
| + // listing that happened to hold one other channel must not win here — |
| + // promoting onto it while watching hosts that follow the record's |
| + // channel would verify the wrong thing (re-review, important #1). |
| + channelsCalled = false |
| + ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "", channels) |
| + if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" { |
| + t.Fatalf("no -host = ch %q hosts %v static %v err %v, want the record's host watched on the record's channel", ch, hosts, static, err) |
| + } |
| + if channelsCalled { |
| + t.Fatal("no -host redeploy consulted the channels listing — the records already agree on a channel") |
| + } |
| + |
| + // Records DISAGREEING on a channel resolve nothing: fall through to the |
| + // channels listing (whose single entry answers, and whose plural case |
| + // refuses naming -channel) rather than guessing between the records. |
| + twoChannels := []apiInstanceRecord{ |
| + {Host: "site.example.test", Channel: "canary/site", Kind: "static"}, |
| + {Host: "blog.example.test", Channel: "canary/blog", Kind: "static"}, |
| + } |
| + ch, hosts, static, err = deployTargets(twoChannels, "static", "", "", channels) |
| + if err != nil || !static || len(hosts) != 2 || ch != "canary/other" { |
| + t.Fatalf("disagreeing records = ch %q hosts %v static %v err %v, want the listing's channel via fallthrough", ch, hosts, static, err) |
| + } |
| + if !channelsCalled { |
| + t.Fatal("disagreeing records never fell through to the channels listing") |
| + } |
| + |
| + // A real instance record still pulls a -kind static deploy onto the |
| + // instance path, exactly as before the gate existed. |
| + instRec := apiInstanceRecord{Host: "app.example.test", Channel: "stable"} |
| + ch, hosts, static, err = deployTargets([]apiInstanceRecord{instRec}, "static", "", "app.example.test", channels) |
| + if err != nil || static || ch != "stable" || len(hosts) != 1 { |
| + t.Fatalf("instance record = ch %q hosts %v static %v err %v, want the instance path", ch, hosts, static, err) |
| + } |
| +} |
| + |
| +// TestDeployStaticRedeployWithRecordGetsDNSGuidance is review important #1's |
| +// regression case (a): the SECOND `deploy -kind static -host <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/plans/2026-08-12-domain-attach.md b/docs/superpowers/plans/2026-08-12-domain-attach.md| new file mode 100644 |
| index 000000000..b2a63df0e |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-domain-attach.md |
| @@ -0,0 +1,757 @@ |
| +# Domain Attach (#82) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Self-serve custom-domain attach: console Settings UI, a shared |
| +attach/detach core, and `carlos deploy -host` auto-attach, per |
| +`docs/superpowers/specs/2026-08-12-domain-attach-design.md`. |
| + |
| +**Architecture:** The door writes a global create-only claim |
| +(`domains/<host>.json`); an app with a ready route gets an alias claim |
| +(#151's box-side machinery serves it), a routeless static app gets a |
| +claim plus a declared `provision.Record` that #137's reconciler |
| +materializes. One console-side core implements both; the browser door and |
| +the CLI-API door are thin wrappers. #151 (branch `worktree-route-hostnames`) |
| +owns the claim writer, the exported validation spot, the `/api/cli/.../domains` |
| +handlers and the `carlos domains` verbs — Tasks 1–2 build interim, |
| +clearly-marked stand-ins so work proceeds tonight; Task 8 swaps them for |
| +#151's exports when their ping arrives. |
| + |
| +**Tech Stack:** Go, stdlib `net/http` mux patterns, `release.Store` |
| +(create-only CAS via `Put(ctx, key, body, "")` → `release.ErrCASConflict`), |
| +html/template console templates. |
| + |
| +## Global Constraints |
| + |
| +- Worktree: `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/domain-attach`, branch `worktree-domain-attach` (stacked on `worktree-route-records`). Never touch the main checkout (a different session's dirty `unit-economics` state lives there). |
| +- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, sandbox disabled ("read-only file system" is the tell). |
| +- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1`, plus `gofmt -l` clean on touched files. |
| +- Wire shapes are additive; anything written before the change must still parse. |
| +- Any test that shells out to the carlos binary builds env via `carlosTestEnv(t, ...)` (`e2e_test.go:81`) AND sets `cmd.Dir` to a scratch dir. |
| +- Claim shape is FROZEN by cross-session agreement: `{schema, host, account, app, route, created_at, created_by}` — do not add fields. |
| +- Commit messages end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. |
| + |
| +--- |
| + |
| +### Task 1: Interim claim package `internal/domainclaim` |
| + |
| +**Files:** |
| +- Create: `internal/domainclaim/domainclaim.go` |
| +- Test: `internal/domainclaim/domainclaim_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `Claim` struct, `Key(host string) string`, |
| + `Create(ctx context.Context, s release.Store, c Claim) error`, |
| + `Get(ctx context.Context, s release.Store, host string) (Claim, string, error)`, |
| + `Delete(ctx context.Context, s release.Store, host string) error`, |
| + `ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error)`. |
| + Task 3 (core), Task 8 (swap) consume these. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/domainclaim/domainclaim_test.go |
| +package domainclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "carlosframework/platform/internal/release" // match the module path used by internal/provision imports |
| +) |
| + |
| +func TestCreateIsCreateOnly(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + c := Claim{Schema: 1, Host: "www.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC(), CreatedBy: "cli:paul@keymail.dev"} |
| + if err := Create(ctx, s, c); err != nil { |
| + t.Fatalf("first create: %v", err) |
| + } |
| + err := Create(ctx, s, Claim{Schema: 1, Host: "www.example.com", Account: "bab", App: "other", CreatedAt: time.Unix(1755000001, 0).UTC()}) |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create: got %v, want ErrCASConflict", err) |
| + } |
| +} |
| + |
| +func TestGetRoundTripsAndKeyShape(t *testing.T) { |
| + if got := Key("www.example.com"); got != "domains/www.example.com.json" { |
| + t.Fatalf("Key = %q", got) |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + in := Claim{Schema: 1, Host: "example.com", Account: "bdj", App: "story", Route: "story.bdj.oncarlos.com", CreatedAt: time.Unix(1755000000, 0).UTC()} |
| + if err := Create(ctx, s, in); err != nil { |
| + t.Fatal(err) |
| + } |
| + out, etag, err := Get(ctx, s, "example.com") |
| + if err != nil || etag == "" { |
| + t.Fatalf("Get: %v etag=%q", err, etag) |
| + } |
| + if out != in { |
| + t.Fatalf("round trip: got %+v want %+v", out, in) |
| + } |
| +} |
| + |
| +func TestListForAppFiltersOtherOwners(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, c := range []Claim{ |
| + {Schema: 1, Host: "a.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()}, |
| + {Schema: 1, Host: "b.example.com", Account: "bdj", App: "blog", CreatedAt: time.Unix(1755000000, 0).UTC()}, |
| + {Schema: 1, Host: "c.example.com", Account: "bab", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()}, |
| + } { |
| + if err := Create(ctx, s, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := ListForApp(ctx, s, "bdj", "story") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 || got[0].Host != "a.example.com" { |
| + t.Fatalf("ListForApp = %+v, want exactly a.example.com", got) |
| + } |
| +} |
| + |
| +func TestDeleteIsIdempotent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := Delete(ctx, s, "never-existed.example.com"); err != nil { |
| + t.Fatalf("delete of absent claim: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +Before writing: open `internal/provision/provision.go:1-30` and copy the |
| +exact module import path it uses for `internal/release`. |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/domainclaim/ -count=1` |
| +Expected: FAIL — package does not exist / functions undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +```go |
| +// internal/domainclaim/domainclaim.go |
| +// Package domainclaim reads and writes the global custom-domain claim at |
| +// domains/<host>.json — the create-only record whose successful create IS |
| +// the fleet-wide uniqueness check for an attached hostname (first writer |
| +// wins; a lost create surfaces release.ErrCASConflict and the door answers |
| +// "hostname unavailable" without naming the holder). |
| +// |
| +// INTERIM (#82/#151 cross-session agreement, 2026-08-12): #151's branch |
| +// (worktree-route-hostnames) owns the canonical claim writer; this package |
| +// is the agreed shape built here so #82's doors can proceed, and it is |
| +// REPLACED by #151's export when the branches merge. The shape below is |
| +// frozen by that agreement — do not add fields here. |
| +package domainclaim |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "carlosframework/platform/internal/release" |
| +) |
| + |
| +// Claim is the wire shape at domains/<host>.json. Route names the primary |
| +// route this host aliases; empty Route means the host is (or will be) its |
| +// own primary route — the first-route static case. |
| +type Claim struct { |
| + Schema int `json:"schema"` |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Route string `json:"route,omitempty"` |
| + CreatedAt time.Time `json:"created_at"` |
| + CreatedBy string `json:"created_by,omitempty"` |
| +} |
| + |
| +const prefix = "domains/" |
| + |
| +// Key is the bucket key for a host's claim. |
| +func Key(host string) string { return prefix + host + ".json" } |
| + |
| +// Create writes the claim create-only: a lost race (any existing claim, |
| +// whoever holds it) is release.ErrCASConflict, never an overwrite. |
| +func Create(ctx context.Context, s release.Store, c Claim) error { |
| + body, err := json.MarshalIndent(c, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, Key(c.Host), body, "") |
| + return err |
| +} |
| + |
| +// Get reads one claim and its etag. |
| +func Get(ctx context.Context, s release.Store, host string) (Claim, string, error) { |
| + body, etag, err := s.Get(ctx, Key(host)) |
| + if err != nil { |
| + return Claim{}, "", err |
| + } |
| + var c Claim |
| + if err := json.Unmarshal(body, &c); err != nil { |
| + return Claim{}, "", fmt.Errorf("claim %s: malformed: %w", Key(host), err) |
| + } |
| + return c, etag, nil |
| +} |
| + |
| +// Delete releases a claim. Store.Delete is idempotent, so releasing an |
| +// already-gone claim is not an error. |
| +func Delete(ctx context.Context, s release.Store, host string) error { |
| + return s.Delete(ctx, Key(host)) |
| +} |
| + |
| +// ListForApp returns the claims held by one account+app pair. Claims that |
| +// fail to parse are skipped: a malformed claim is #151's reconciler's |
| +// problem to surface, not a reason to blank an app's Settings list. |
| +func ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error) { |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []Claim |
| + for _, k := range keys { |
| + if !strings.HasSuffix(k, ".json") { |
| + continue |
| + } |
| + host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + c, _, err := Get(ctx, s, host) |
| + if err != nil { |
| + continue |
| + } |
| + if c.Account == account && c.App == app { |
| + out = append(out, c) |
| + } |
| + } |
| + return out, nil |
| +} |
| +``` |
| + |
| +If `s.Get` on a missing key returns a distinct sentinel (check |
| +`internal/release/store.go` — provision's callers test it), have `Get` |
| +pass it through unwrapped so callers can distinguish absent from |
| +malformed. |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/domainclaim/ -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/domainclaim/ |
| +git add internal/domainclaim/ |
| +git commit -m "domainclaim: interim claim record package (agreed #151 shape) |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Attach policy validation |
| + |
| +**Files:** |
| +- Create: `internal/console/domainpolicy.go` |
| +- Test: `internal/console/domainpolicy_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `provision.ValidHostShape(host string) bool`. |
| +- Produces: `func attachHostRefusal(host, appsDomain string) string` — |
| + empty string means allowed; non-empty is the member-facing refusal |
| + text. Tasks 3–5 consume it. (Task 8 relocates the policy into #151's |
| + exported validation spot; the tests move with it.) |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/console/domainpolicy_test.go |
| +package console |
| + |
| +import "testing" |
| + |
| +func TestAttachHostRefusal(t *testing.T) { |
| + cases := []struct { |
| + name, host, appsDomain string |
| + wantAllowed bool |
| + }{ |
| + {"plain custom fqdn", "www.example.com", "oncarlos.com", true}, |
| + {"apex", "example.com", "oncarlos.com", true}, |
| + {"uppercase rejected", "WWW.example.com", "oncarlos.com", false}, |
| + {"empty host", "", "oncarlos.com", false}, |
| + {"traversal shape", "../control/serial", "oncarlos.com", false}, |
| + {"apps domain itself", "oncarlos.com", "oncarlos.com", false}, |
| + {"under apps domain", "story.bdj.oncarlos.com", "oncarlos.com", false}, |
| + {"apps domain unset allows customs", "www.example.com", "", true}, |
| + {"suffix but not label boundary", "notoncarlos.com", "oncarlos.com", true}, |
| + } |
| + for _, c := range cases { |
| + got := attachHostRefusal(c.host, c.appsDomain) |
| + if (got == "") != c.wantAllowed { |
| + t.Errorf("%s: attachHostRefusal(%q,%q) = %q, want allowed=%v", c.name, c.host, c.appsDomain, got, c.wantAllowed) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/console/ -run TestAttachHostRefusal -count=1` |
| +Expected: FAIL — `attachHostRefusal` undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +```go |
| +// internal/console/domainpolicy.go |
| +// Attach-door host policy (#82). #151 exports the shared validation spot |
| +// for the domains door; when the branches merge these checks land there |
| +// (Task 8) — the rules themselves are this file's contract: |
| +// - hostname shape only (ValidHostShape — apexes allowed, lowercase), |
| +// - never platform space: an attached domain is a foreign domain by |
| +// definition, so anything equal to or under the deployment's apps |
| +// domain is refused before any claim is written. |
| +// First-writer-wins is NOT here: the claim's create-only write is the |
| +// entire uniqueness check, and its refusal is the door's neutral |
| +// "hostname unavailable". |
| +package console |
| + |
| +import ( |
| + "strings" |
| + |
| + "carlosframework/platform/internal/provision" |
| +) |
| + |
| +func attachHostRefusal(host, appsDomain string) string { |
| + if !provision.ValidHostShape(host) { |
| + return "not a valid hostname" |
| + } |
| + if appsDomain != "" { |
| + if host == appsDomain || strings.HasSuffix(host, "."+appsDomain) { |
| + return "hosts under the platform's apps domain are assigned by the platform, not attached" |
| + } |
| + } |
| + return "" |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/console/ -run TestAttachHostRefusal -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/domainpolicy.go |
| +git add internal/console/domainpolicy.go internal/console/domainpolicy_test.go |
| +git commit -m "console: attach-door host policy — shape + never platform space |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Attach/detach core |
| + |
| +**Files:** |
| +- Create: `internal/console/domainattach.go` |
| +- Test: `internal/console/domainattach_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `domainclaim.Create/Get/Delete` (Task 1), |
| + `attachHostRefusal` (Task 2), `provision.CreateRecord` / |
| + `provision.DeleteRecord` / `provision.Record` |
| + (`internal/provision/provision.go`), `release.ChannelsPrefix(acct, app)` |
| + (`internal/release/release.go:207`), `registry.Open` + route listing |
| + (`internal/registry`). |
| +- Produces (Tasks 4, 5, 8 consume): |
| + |
| +```go |
| +type attachResult struct { |
| + Claim domainclaim.Claim |
| + FirstRoute bool // true when a declared record was written |
| + Channel string // channel the first-route record follows ("" for alias) |
| +} |
| +// attachDomain: kindFlag/channelFlag may be "" (meaning: infer). |
| +func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal) |
| +func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal |
| +type doorRefusal struct { |
| + Status int // http status the doors translate to |
| + Msg string // member-facing text; NEVER names another account |
| +} |
| +``` |
| + |
| +Behavior contract (from spec §3–§4), which the tests below pin: |
| + |
| +1. `attachHostRefusal` first → 422 refusal. |
| +2. Ready-route lookup: open `s.cfg.Registry` (skip lookup when the path |
| + is empty — tests), list routes, find the app+account's first |
| + non-Platform ready route by host order (the `firstReadyUpstream` |
| + selection rule, `internal/domains/reconcile.go:617`, minus the |
| + upstream-copy part — only the HOST is needed). Found → alias claim |
| + `{Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: <that host>, CreatedAt: now, CreatedBy: principal}`; |
| + no record written; `FirstRoute: false`. |
| +3. No ready route → first-route path: `kindFlag` must be `""` or |
| + `"static"` (anything else → 422 "an app with no running route can only |
| + attach a static domain — deploy first, then attach"). Channel = |
| + `channelFlag`, or the app's sole channel from |
| + `s.cfg.Releases.List(ctx, release.ChannelsPrefix(acct.Sqid, app))` |
| + (strip prefix + `.json` for names); zero channels → 422 "nothing |
| + shipped yet — carlos ship first"; more than one and no flag → 422 |
| + naming them all. Then claim (Route empty) + `provision.CreateRecord` |
| + with `Record{Schema: 1, Host: host, App: app, Account: acct.Sqid, Channel: channel, Kind: "static", CreatedAt: now, CreatedBy: principal}`. |
| + If CreateRecord fails after the claim landed, best-effort |
| + `domainclaim.Delete` then return 500. |
| +4. `domainclaim.Create` losing the race (`errors.Is(err, release.ErrCASConflict)`) |
| + → 409 with exactly `"hostname unavailable"` — including when the caller's |
| + own account holds it (no owner leak; GET is where you see your own). |
| +5. Success: `s.audit(acct, app, principal, "domain-attach", ...)` + |
| + `s.bumpSerial(ctx)` inside the core (both doors get it for free); |
| + detach mirrors with `"domain-detach"`. |
| +6. `detachDomain`: `domainclaim.Get`; absent, or `Account != acct.Sqid`, |
| + or `App != app` → 404 refusal (byte-identical treatment comes from |
| + the doors calling `http.NotFound`). Claim's `Route == ""` (first-route) |
| + → `provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)` |
| + first, then `domainclaim.Delete`, audit, serial. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Write `internal/console/domainattach_test.go` in package `console`, |
| +following the existing server-test construction pattern — before writing, |
| +open `internal/console/apiinstances_test.go` (or the nearest test that |
| +builds a `*Server` with `cfg.Releases = release.NewDirStore(t.TempDir())`) |
| +and copy its server/fixture setup helper. Cover, minimum: |
| + |
| +```go |
| +// Names are the contract; bodies follow the fixture pattern you copied. |
| +func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) // registry fixture with a ready route → Claim.Route = that host, no record under RecordsPrefix, FirstRoute false |
| +func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) // no routes, one shipped channel → claim with empty Route AND a parseable provision.Record at RecordKey(sqid, app, host) with Kind static + that channel |
| +func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) // two channels, no channelFlag → 422, msg contains both names, NO claim written |
| +func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) // kindFlag "instance", no routes → 422, no claim |
| +func TestAttachHeldHostIsNeutral409(t *testing.T) // pre-created claim by another account → 409, msg exactly "hostname unavailable" |
| +func TestAttachPlatformSpaceRefused(t *testing.T) // host under cfg.AppsDomain → 422, no claim |
| +func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) |
| +func TestDetachForeignClaimIs404(t *testing.T) // claim held by another account+app → 404 refusal |
| +``` |
| + |
| +For the registry fixture: `reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))` |
| +then `reg.Upsert(registry.Route{Host: "story.bdj.oncarlos.com", Kind: "static", App: "story", Account: "bdj", Channel: "canary/live", Status: "ready"})` |
| +and point `cfg.Registry` at that path (match field names against the |
| +`Config` struct at `internal/console/server.go:165`). |
| + |
| +- [ ] **Step 2: Run tests to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run 'TestAttach|TestDetach' -count=1` |
| +Expected: FAIL — `attachDomain`/`detachDomain` undefined. |
| + |
| +- [ ] **Step 3: Implement `domainattach.go` to the behavior contract above** |
| + |
| +File header comment: name the spec |
| +(`docs/superpowers/specs/2026-08-12-domain-attach-design.md`) and the |
| +claim-then-record write order as the invariant (uniqueness is decided |
| +before anything else exists; a half-failed attach leaves at worst an |
| +unclaimed record-less claim that detach or retry clears). |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -run 'TestAttach|TestDetach' -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/domainattach.go |
| +git add internal/console/domainattach.go internal/console/domainattach_test.go |
| +git commit -m "console: attach/detach core — alias claim or claim+declared record |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Browser door — Settings attach/detach handlers |
| + |
| +**Files:** |
| +- Create: `internal/console/appdomains.go` |
| +- Modify: `internal/console/server.go` (route registration block, near the |
| + `POST /a/{sqid}/apps/{app}/rename` registration at `server.go:378-403`) |
| +- Test: `internal/console/appdomains_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `s.accountApp(w, r)` (`server.go:1533` — session + membership |
| + + app 404), the CSRF constant-time compare pattern and gate ORDER from |
| + `handleAppDelete` (`internal/console/appsettings.go:339`), |
| + `attachDomain`/`detachDomain` (Task 3). |
| +- Produces: `POST /a/{sqid}/apps/{app}/domains/attach` (form fields |
| + `csrf`, `host`, optional `channel`) and |
| + `POST /a/{sqid}/apps/{app}/domains/detach` (form fields `csrf`, `host`, |
| + `confirm` — must equal the host). Redirect back to |
| + `/a/{sqid}/apps/{app}/settings` on success; `doorRefusal` renders via |
| + `s.renderMessage` with the refusal's status. |
| + |
| +Gate order (copy `handleAppDelete`'s shape exactly, WITHOUT the |
| +`requireFreshAuth` step — spec §6: attach/detach are member-reversible, |
| +no passkey step-up; detach's protection is the typed-host confirm): |
| +session (`accountApp`) → CSRF (`subtle.ConstantTimeCompare`) → role must |
| +be `RoleOwner` else byte-identical `http.NotFound` → refusals → act. |
| + |
| +- [ ] **Step 1: Write failing handler tests** — follow the request/response |
| +test pattern of the nearest existing browser-door test (grep |
| +`internal/console/*_test.go` for `handleAppDelete` or `csrf` to find it; |
| +copy its session-fixture helper). Cover: happy attach (alias fixture from |
| +Task 3's test), CSRF mismatch → 403, non-owner role → 404 with a body |
| +byte-identical to an unknown app's 404, detach without matching `confirm` |
| +→ 422 and claim survives, detach happy path → redirect + claim gone. |
| + |
| +- [ ] **Step 2: Run to verify they fail** — `go test ./internal/console/ -run TestAppDomains -count=1` → FAIL, handlers undefined. |
| + |
| +- [ ] **Step 3: Implement handlers + register routes** |
| + |
| +```go |
| +// server.go, in the block with the other app POST routes: |
| +s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach) |
| +s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach) |
| +``` |
| + |
| +Handlers live in `appdomains.go`, thin over the Task 3 core; translate |
| +`doorRefusal{Status, Msg}` via `s.renderMessage(w, r, refusal.Status, "Refused", refusal.Msg)`. |
| + |
| +- [ ] **Step 4: Run to verify they pass** — same command, PASS. |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/appdomains.go internal/console/server.go |
| +git add internal/console/appdomains.go internal/console/appdomains_test.go internal/console/server.go |
| +git commit -m "console: browser attach/detach door on app Settings |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Settings page UI — replace the honesty text |
| + |
| +**Files:** |
| +- Modify: `internal/console/templates/app_settings.html:49-57` (the |
| + "Add a custom domain … carlos add … on the platform's roadmap" block) |
| +- Modify: `internal/console/appsettings.go` (`assembleSettings` — add |
| + attached-domain rows) and/or `internal/console/view.go` if the data |
| + fits `DomainView` (`view.go:71`) better; implementer picks whichever |
| + keeps the template loop single. |
| +- Modify: `internal/console/appsettings.go:15-18` package comment — the |
| + "domains attach on the box today" sentence is now false; rewrite to |
| + name this door. |
| +- Test: extend `internal/console/appsettings_test.go` (or the file |
| + Task 4's fixture lives in). |
| + |
| +**Interfaces:** |
| +- Consumes: `domainclaim.ListForApp` (Task 1), `DomainView` + |
| + `domainView(rt, edgeIPs)` (`view.go:71,79`), `s.cfg.EdgeIPs`, |
| + the Task 4 POST endpoints. |
| + |
| +Behavior: |
| +- The existing Domains & TLS table (`app_settings.html:30-48`) keeps |
| + showing ROUTED hosts. Add claim-only rows for attached hosts that have |
| + no colocated registry row yet: Type `custom · attaching`, DNS column |
| + `""` (unknown), Guidance = the same A-record text `domainView` builds |
| + from `edgeIPs`. |
| +- Below the table: the attach form (text input `host`, optional |
| + `channel` input shown only when the app has no route, hidden `csrf`, |
| + submit "Attach domain") posting to `{{.Base}}/apps/{{.Detail.App}}/domains/attach`; |
| + per attached host a detach form with typed-host `confirm` input. |
| + Follow the rename form (`app_settings.html:15-21`) for markup/CSRF |
| + idiom; icons are vendored Lucide constants if any are used — no CDN. |
| +- Empty state line: "This app already answers at its platform domain; |
| + attaching adds addresses, it never removes one." |
| + |
| +- [ ] **Step 1: Failing test** — assert the settings page render (a) no |
| +longer contains the string `carlos add -host`, (b) contains |
| +`domains/attach`, and (c) with a claim fixture present, contains that |
| +host. Use the page-render test pattern already covering |
| +`app_settings` (grep for `app_settings` in `internal/console/*_test.go`). |
| +- [ ] **Step 2: Run to verify it fails** — render still shows the old text. |
| +- [ ] **Step 3: Implement template + assembly changes.** |
| +- [ ] **Step 4: Run to verify it passes** — plus eyeball |
| +`go test ./internal/console/ -count=1` for template-parse regressions. |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/console/templates/app_settings.html internal/console/appsettings.go internal/console/view.go internal/console/appsettings_test.go |
| +git commit -m "console: Settings attaches domains itself — honesty text retired |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: CLI client + `carlos deploy -host` auto-attach |
| + |
| +**Files:** |
| +- Modify: `apimode.go` (new apiSession methods), `deploy.go` |
| + (auto-attach step + honest DNS-failure text) |
| +- Test: `deploy_test.go` (or the file that already tests |
| + `deployTargets`/`watchVersion` — find with `grep -l deployTargets *_test.go`) |
| + |
| +**Interfaces:** |
| +- Consumes: `apiSession.getJSON/postJSON` (`apimode.go:266,291`), |
| + `apiPath(a, app, tail)` (`apimode.go:217`), `deployTargets` |
| + (`deploy.go:180`), `staticDeployHosts` (`deploy.go:223`), |
| + `watchVersion` (`deploy.go:417`). |
| +- Produces (Task 8's e2e consumes; #151's verbs will share): |
| + |
| +```go |
| +type apiDomain struct { |
| + Host string `json:"host"` |
| + Route string `json:"route,omitempty"` |
| + DNS string `json:"dns,omitempty"` // "pointing" | "elsewhere" | "" |
| + Guidance string `json:"guidance,omitempty"` |
| +} |
| +func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomain, error) // GET .../domains |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, kind, channel string) (apiDomain, error) // POST .../domains/{host} |
| +``` |
| + |
| +Wire contract (agreed with #151; the server half arrives in Task 8): |
| +`POST /api/cli/apps/{account}/{app}/domains/{host}` body |
| +`{"kind":"static","channel":"canary/live"}` (both optional) → 201 with an |
| +`apiDomain`; 409 body contains `hostname unavailable`; 422 refusal text |
| +passes through to the user verbatim. |
| + |
| +Behavior in `runDeploy`: when `-host` is set and the resolved static |
| +target host is NOT in `sess.instanceList` results and not already |
| +claimed (a `domainList` miss), call `domainAttach` (kind from `-kind` |
| +flag when it is `static`, channel from the resolved deploy channel) |
| +BEFORE shipping; print `attached <host> (first route: static, channel <c>)` |
| +or the refusal and stop. After `watchVersion` timeout, if the last |
| +failure was connection/DNS-class, fetch `domainList` and print the |
| +guidance + stamp with exit text: |
| +`DNS for <host> is not pointing at this deployment yet. Create: <guidance>. Watch with 'carlos domains -app <app>'; re-run this deploy any time — the shipped release is already promoted.` |
| +(exit nonzero; never claim success). |
| + |
| +- [ ] **Step 1: Failing tests** — an `httptest.NewServer` fake console |
| +(follow the existing fake-console pattern; find it with |
| +`grep -rl "httptest.NewServer" *_test.go | head`) asserting: (a) |
| +`domainAttach` hits the right path+body, (b) deploy with `-host` on an |
| +unclaimed static target POSTs attach before ship, (c) the |
| +DNS-not-pointing exit message includes the fake's guidance text and the |
| +process exits nonzero. |
| +- [ ] **Step 2: Run to verify they fail.** |
| +- [ ] **Step 3: Implement client methods + deploy wiring.** |
| +- [ ] **Step 4: Run to verify they pass** — `go test . -run 'Deploy|Domain' -count=1`. |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add apimode.go deploy.go deploy_test.go |
| +git commit -m "cli: deploy -host auto-attaches an unclaimed custom domain |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Backfill golden — the story.carlosframework.com shape adopts |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile_test.go` (one new test beside the |
| + existing adopt-on-match tests — find them with |
| + `grep -n "adopt" internal/provision/reconcile_test.go`) |
| + |
| +**Interfaces:** |
| +- Consumes: `Reconcile` + `ReconcileConfig` (`reconcile.go:29,57`), |
| + `shapeMismatch` semantics (`reconcile.go:143`), `CreateRecord`. |
| + |
| +- [ ] **Step 1: Write the test** (it should PASS already — #137 built |
| +adoption; this golden pins the #82 backfill path specifically): |
| +pre-Upsert the operator-added shape |
| +`registry.Route{Host: "story.carlosframework.com", Kind: "static", App: "story", Account: "bdj", Channel: "canary/live", Status: "ready"}`, |
| +write the matching declared record |
| +(`Record{Schema: 1, Host: "story.carlosframework.com", App: "story", Account: "bdj", Channel: "canary/live", Kind: "static"}`), |
| +run `Reconcile`, assert the route is now `Provisioned` and its |
| +Socket/DBPath are still empty. Add a companion negative: same record but |
| +existing route `Kind: "instance"` → NOT adopted, still unprovisioned. |
| +- [ ] **Step 2: Run** — `go test ./internal/provision/ -run Adopt -count=1` |
| +(adjust `-run` to the names used). Expected: PASS. If the static shape |
| +does NOT adopt, that is a real #137 gap — STOP and report rather than |
| +patching around it. |
| +- [ ] **Step 3: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile_test.go |
| +git commit -m "provision: golden — declared static record adopts the story-shaped route |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: Integration with #151 (GATED — wait for their ping) |
| + |
| +**Files:** determined by #151's ping (their package path, exported names, |
| +door files, CLI verb file). Expected shape of the work: |
| +- Merge `origin/worktree-route-hostnames` into `worktree-domain-attach`. |
| +- Delete `internal/domainclaim/`; repoint Tasks 1–6's imports at #151's |
| + exported claim writer/reader (same frozen shape — mechanical rename). |
| +- Move `attachHostRefusal`'s checks + tests into #151's exported |
| + validation spot; delete `domainpolicy.go`. |
| +- Wire `attachDomain`'s first-route branch into #151's |
| + `POST /api/cli/.../domains/{host}` handler (their handler gates via the |
| + `apiInstancePrincipal` pattern; the core call slots where their |
| + alias-claim write happens), and `detachDomain` likewise; confirm their |
| + GET's response fields cover `apiDomain` (Task 6) — if their names |
| + differ, Task 6's client adapts to THEIRS (wire contract is theirs to |
| + pin). |
| +- e2e (new file `domains_e2e_test.go`, `carlosTestEnv` + `cmd.Dir` |
| + scratch): `carlos deploy -kind static -host <h>` against a real test |
| + console — attach happens, record exists, second attach from another |
| + account refuses neutrally; `carlos domains -app` lists it. |
| + |
| +**Steps:** |
| +- [ ] Merge their branch; resolve; full gate. |
| +- [ ] Swap stub → their exports (compile-driven: delete the stub, chase errors). |
| +- [ ] Wire door branches; run THEIR door tests + Task 3–6 tests together. |
| +- [ ] Write + run the e2e. |
| +- [ ] Gate and commit: |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add -A |
| +git commit -m "domains: integrate #151 door — one claim writer, one validation spot |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +If the ping has not arrived when Tasks 1–7 are done: hold this task, |
| +proceed to Task 9's PR as DRAFT noting the gate, and check for the ping |
| +periodically — do not re-implement #151's door. |
| + |
| +--- |
| + |
| +### Task 9: Whole-branch review, push, PR |
| + |
| +- [ ] **Step 1: Whole-branch review** — reread the spec top to bottom |
| +against `git diff worktree-route-records...HEAD`; walk every §2 ruling |
| +and §8 test bullet to a test that exists. Fix inline. |
| +- [ ] **Step 2: Full gate one more time** (all packages, `-count=1`), plus |
| +`GOOS=darwin GOARCH=arm64 go build ./...` (the CI cross-compile). |
| +- [ ] **Step 3: Push and open the PR** |
| + |
| +```bash |
| +git push -u origin worktree-domain-attach |
| +gh pr create --repo carlosframework/platform --base main \ |
| + --title "Domain attach: self-serve custom domains from Settings, CLI and deploy (#82)" \ |
| + --body "<summary; note stacked-on-#137 + #151 integration state; closes #82> |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code)" |
| +``` |
| + |
| +Draft if Task 8 is still gated. Merging is Paul's action — never offer |
| +or take it. |
diff --git a/docs/superpowers/specs/2026-08-12-domain-attach-design.md b/docs/superpowers/specs/2026-08-12-domain-attach-design.md| new file mode 100644 |
| index 000000000..c5d736499 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md |
| @@ -0,0 +1,256 @@ |
| +# Domain attach — self-serve custom domains from console, CLI and deploy |
| + |
| +**Date:** 2026-08-12 · **Status:** draft (design approved in conversation |
| +2026-08-12; alias mechanism re-converged the same evening with the #151 |
| +session — see §1.1) · **Requested by:** Paul |
| + |
| +**Closes:** #82 (console-driven custom domain attach). |
| + |
| +**Builds on:** route records (2026-08-10 — the declared-record vocabulary |
| +and reconcile authority; PR #137), #151 (domain claim records + |
| +`route_aliases` + the `domains` console door + `carlos domains` verbs — the |
| +alias half of this design is #151's mechanism, consumed as-is), custom |
| +domains (2026-08-02 — the DNS stamps, the §8 future-work list this |
| +retires a line from), console app pages (2026-08-07 §6 — the Settings |
| +honesty text this replaces), and `carlos deploy` (2026-08-08 — the |
| +one-command form §5 completes). |
| + |
| +## 1. Why, and the shape in one paragraph |
| + |
| +Attaching a custom domain to an app is an operator act today: `carlos add` |
| +on the box over SSM, which is the exact class of gap CLAUDE.md's no-infra |
| +rule names. Two live incidents prove both halves: lazyatom's |
| +echo.example.com (alias attach to a running app — refused by every member |
| +door) and story.carlosframework.com (a fresh static app whose FIRST route |
| +is the custom domain — took an operator route-add plus manual DNS). The |
| +self-serve path splits cleanly on one question the console can answer at |
| +attach time: **does the app already have a ready route?** |
| + |
| +- **Yes → alias attach** (#151's mechanism): the door writes a global |
| + claim record naming the primary route; a box-side reconcile pass |
| + derives a `route_aliases` entry from it, and the edge resolves the |
| + alias to the primary route's upstream at request time. No second route |
| + row exists, which is what avoids the #129 socket-race class, #148's |
| + bare-alias breakage, and the wake-through-aliasing hole (custom-domains |
| + §8) that a copied-shape row inherits. Alias→static-primary works by |
| + #151's pinned rule — content/backing derivation uses the primary's |
| + host; the request host is TLS/cache-key/metering only — so an alias of |
| + a static route serves the same channel-pointed content and carries |
| + `X-Carlos-Version`. |
| +- **No → first-route attach** (this spec's one new mechanism): the same |
| + global claim, plus a **declared route record** in #137's vocabulary — |
| + `kind: static` + `channel`, validated by `ValidHostShape` — which the |
| + provisioning reconciler materializes as the app's real primary route. |
| + There is no source to alias, so nothing competes with `route_aliases`; |
| + the minted route IS the primary. |
| + |
| +Everything else — Settings UI, `carlos deploy -host` auto-attach, and the |
| +policy rulings — rides those two mechanisms through one console door. |
| + |
| +### 1.1 The convergence note for Paul's morning read |
| + |
| +Two designs were approved in separate sessions on 2026-08-12: this |
| +session's original approach (attach = a route record with an additive |
| +`alias: true` field; reconcile mints a second route row copying the |
| +primary's upstream trio) and the #151 session's (global claim + |
| +`route_aliases`, no second row). They conflicted. The second-row shape |
| +loses on the merits — it reproduces the #129 socket race for hibernating |
| +instances, re-imports #148's alias breakage, and keeps the |
| +502-while-hibernating hole — and its one advantage (no new registry |
| +table) is not worth those. The per-app record's uniqueness check also |
| +loses to the global claim: scanning records across accounts is racy where |
| +a global create-only key is atomic. This spec is the reconciliation: #151 |
| +builds the alias mechanism and the shared door; #82 adds the first-route |
| +mechanism, the UI, and deploy integration on top. The `alias: true` |
| +record field from the earlier in-session approval is **dead** — it |
| +appears nowhere below. |
| + |
| +## 2. Rulings (decided 2026-08-12, in-session) |
| + |
| +- **Route-only.** An attached domain serves the app; it does not widen |
| + instance space. `provision.ValidHost` and the sqid-space pin on |
| + `carlos instances create` / `handleAPIInstanceCreate` are untouched. |
| + An app whose model is instance-per-tenant still mints instances under |
| + `<sqid>.<apps-domain>` only. Widening the manifest's `Domain` to "any |
| + attached domain" is a named follow-up issue, not an oversight. |
| +- **DNS-pointing is the ownership evidence.** A claim whose DNS does not |
| + reach the edge yields an inert alias or an unroutable route; the |
| + console's existing `pointing`/`elsewhere` stamp is the signal, and |
| + first-writer-wins (the global claim) closes the intra-platform race. |
| + Residual risk, stated: a domain whose DNS already points at the edge |
| + but is unclaimed could be claimed by an account that does not control |
| + it. Narrow window, accepted for this slice. A TXT-challenge |
| + (`_carlos-challenge.<host>`) pending state is named future work, gated |
| + on abuse actually appearing once Carloku is commercial — the claim |
| + record grows a field then; nothing structural changes. |
| +- **No DNS record automation.** The door tells the member exactly which |
| + records to create (edge IPs / CNAME target) and stamps progress; it |
| + does not write to any registrar. The story incident's DNSimple half |
| + stays manual by design — token scopes differ per zone and no |
| + self-serve story exists yet. |
| + |
| +## 3. First-route attach — the one new mechanism |
| + |
| +Console-side only; the box half already exists in #137. |
| + |
| +When the app has no ready route and the attach names `kind: static`, the |
| +door writes, in order: |
| + |
| +1. The **global claim** (`domains/<host>.json`, create-only, via #151's |
| + shared claim writer). The claim shape is #151's: |
| + `{schema: 1, host, account, app, route, created_at, created_by}` — |
| + `route` names the primary route host in the alias case, and an |
| + **empty `route` means "this host is its own primary"**, which is |
| + exactly this case. Loss of the create = neutral refusal (§4). |
| +2. A **declared route record** at `apps/<sqid>/<app>/instances/<host>.json` |
| + in #137's vocabulary: `kind: static`, `channel` (explicit, or the |
| + app's only shipped channel — several channels and no flag is refused |
| + as ambiguous, naming them), `pool`/`region` empty (the shared pool, |
| + unconstrained region — the deployment's default boxes), plus |
| + `app`/`account`/`added_at`/actor audit fields the record already |
| + carries. |
| +3. `bumpSerial` — the route exists fleet-side in seconds, #137's normal |
| + materialization path, zero new reconcile code. |
| + |
| +A proxied kind (`instance`, `service`) with no existing route is refused: |
| +there is no process, socket or address to serve, so the attach would mint |
| +a route to nothing. The message says to `carlos deploy` (or enable |
| +instances) first — except through §5's deploy integration, where the ship |
| +happens in the same command and the static case is the only one that |
| +arises. |
| + |
| +Detach = delete the route record and release the claim (same door, |
| +reverse order); #137's `Provisioned`-keyed removal phase drops the route. |
| +If the record was adopted rather than minted (backfill, §7), removal |
| +authority is identical — that is #137's contract. |
| + |
| +## 4. The door, shared with #151 |
| + |
| +One endpoint family (built by #151, extended here): |
| + |
| +``` |
| +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 |
| +entries, serial bump after every write. The browser Settings door (§6) |
| +carries the session → CSRF → owner-404 ordering instead — both doors call |
| +the same attach/detach core. |
| + |
| +For an alias attach the door's only write is the claim (with `route` set) |
| +plus the serial bump — the `route_aliases` row is derived box-side, never |
| +door-written. For a first-route attach the door writes claim then |
| +declared record (§3). Validation lives in one exported console-side spot |
| +(#151 builds it; this spec's rulings land there) so both mechanisms share |
| +it: |
| + |
| +- `ValidHostShape` — lowercase FQDN shape, no path-traversal characters; |
| + apexes allowed. |
| +- **Refuse platform space**: any host equal to or under the deployment's |
| + apps-domain, the console's own host, or an edge host. Attached domains |
| + are foreign domains by definition. |
| +- **First-writer-wins**: the global claim's create-only write is the |
| + entire uniqueness check. A lost create — whatever account holds the |
| + claim — returns the same neutral "hostname unavailable" with no owner |
| + leaked. A host that is routed on some box but unclaimed (operator |
| + routes predating this) is handled box-side, not console-side: reconcile |
| + finds a differing existing route, leaves it alone, and logs — inert, |
| + diagnosable, no takeover (#137 §4.2's rule doing its job at this door). |
| + |
| +The GET carries the DNS stamp per host (`pointing` / `elsewhere`, the |
| +existing Domains-table machinery pointed at attached hosts) plus what to |
| +create: an A-record instruction naming this deployment's edge IPs |
| +(`domainGuidance`, `internal/console/view.go`), the same wording whether the |
| +host is an apex or not. The CNAME-for-non-apex split named in an earlier |
| +draft of this line was never built — the console has one guidance path, not |
| +two, and every surface (Settings, `carlos domains`, `carlos deploy`'s DNS |
| +exit) reads it verbatim, so they can never disagree. A CNAME option for |
| +non-apex hosts is future polish, not a correctness gap: an A record at the |
| +edge IPs works for any host shape. |
| + |
| +## 5. `carlos deploy -host` completes the story |
| + |
| +`carlos deploy -kind static -host story.carlosframework.com` on a fresh |
| +app becomes the whole job: |
| + |
| +1. Resolve the host's state via the door's GET: claimed by this app → |
| + proceed; claimed by another → refuse (neutral); unclaimed → attach |
| + (alias if the app has a ready route, first-route declared record if |
| + static, exactly §3's rules — `-kind static` supplies the kind, the |
| + shipped channel supplies the channel). |
| +2. Ship → promote, unchanged. |
| +3. Verify: wait on `X-Carlos-Version` over 2xx as deploy already does. |
| + If DNS is not pointing yet, say so honestly and print the records to |
| + create plus the stamp state, then exit nonzero with "re-run when DNS |
| + points, or watch with `carlos domains -app X`" — never spin against a |
| + host whose DNS the member has not created, and never claim success. |
| + |
| +`carlos domains attach/detach/list` themselves are #151's verbs; deploy |
| +calls the same client functions. Attach output always ends with the DNS |
| +records to create and the current stamp — the command completes the job |
| +or says exactly what is left, per the no-infra rule. |
| + |
| +## 6. Settings UI |
| + |
| +The app Settings page swaps the "custom domains attach via carlos add on |
| +the box" honesty text for the real thing: an attach form (host; kind |
| +fixed to what the app supports — alias for routed apps, static |
| +first-route otherwise), the attached-domains list with DNS stamps and |
| +detach, and the DNS records to create shown inline per pending host. |
| +Confirm-before-detach; no fresh-auth gate (attach is reversible and |
| +serves the member's own app; detach of a live domain gets the confirm, |
| +not a password). Empty state names the platform domain the app already |
| +has, so attach reads as addition, not rescue. |
| + |
| +## 7. Backfill |
| + |
| +story.carlosframework.com (and any other operator-added custom-domain |
| +route): write the claim + the declared record describing the route that |
| +already exists; #137's adopt-on-match stamps `Provisioned` and takes |
| +ownership; the SSM-added row becomes a record-owned row with no box |
| +command. Applied one host at a time, verified through the serial, per |
| +#137 §4.3's discipline. |
| + |
| +## 8. Rollout and testing |
| + |
| +**Order:** #151 lands first on main (its own rollout). #137 merges |
| +(one-click, catch-up merge pushed 2026-08-12; merging is Paul's action). |
| +This branch stacks on `worktree-route-records` and rebases to main when |
| +#137 lands; console + CLI ship together, and since every box-side |
| +mechanism pre-exists (#137's reconcile, #151's alias pass), the door |
| +opening is the only event — no window where records outrun readers. |
| + |
| +**Testing:** |
| +- Door: gate-ordering tests (byte-identical 404), each refusal (shape, |
| + platform space, claim held, ambiguous channel, proxied-kind-no-route), |
| + claim-then-record write order, serial bump, audit entries. |
| +- First-route: record materializes via existing reconcile tests' harness; |
| + detach removes; adopt-on-match backfill golden for the story shape. |
| +- Deploy: auto-attach happy path, DNS-not-pointing honest failure, claim |
| + -held refusal; e2e through `carlosTestEnv` with `cmd.Dir` scratch. |
| +- Parse goldens: claim and record shapes byte-stable against pre-change |
| + writers (the additive rule). |
| + |
| +## 9. Out of scope, named |
| + |
| +- **Instance-space widening** (attached domain as instance space) — the |
| + route-only ruling's follow-up issue. |
| +- **TXT ownership challenge** — future work gated on abuse (§2). |
| +- **Bring-your-own apps domain** (custom-domains §8 bullet 1) — the |
| + per-account ACME and DNS-delegation price tag stays there, unpaid. |
| +- **DNS record automation** — no registrar writes (§2). |
| +- **Path routing** — one host, one upstream, still. |
| +- **Proxied first-route attach** — an app with no process gets no |
| + proxied route; deploy-first stays the answer. |
diff --git a/domains_cli.go b/domains_cli.go| index b1841e93d..b046ab640 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -102,8 +102,9 @@ func runDomainsAttach(args []string) { |
| }) |
| sess := requireAPISession("attach", *consoleFlag) |
| printUsing(stdout, sess) |
| - created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw), |
| - *app, host, strings.TrimSpace(*route), wildcardArg, catchallArg) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + ctx := context.Background() |
| + created, resolvedRoute, err := sess.domainAttach(ctx, acct, *app, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg) |
| if err != nil { |
| log.Fatalf("domains attach: %v", err) |
| } |
| @@ -112,6 +113,43 @@ func runDomainsAttach(args []string) { |
| } else { |
| fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute) |
| } |
| + printDomainDNSLine(ctx, sess, acct, *app, host) |
| +} |
| + |
| +// printDomainDNSLine completes the job `carlos domains attach` promises |
| +// (spec §5: "Attach output always ends with the DNS records to create and |
| +// the current stamp — the command completes the job or says exactly what is |
| +// left"): it re-fetches the domain list and prints the just-attached host's |
| +// own DNS stamp, so the member never needs a second command (let alone a |
| +// console visit) to learn whether the domain is live. Degrades quietly on a |
| +// list failure or a missing row (an old console, or a claim the sweep has |
| +// not reached yet with no readable answer) — the attach line above already |
| +// reported the thing that matters (whether the write happened), so a DNS |
| +// line this call cannot back up is skipped rather than guessed at. |
| +func printDomainDNSLine(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 { |
| + continue |
| + } |
| + if d.DNSStatus == "pointing" { |
| + fmt.Fprintln(stdout, "DNS: pointing") |
| + return |
| + } |
| + dns := d.DNSStatus |
| + if dns == "" { |
| + dns = "pending" |
| + } |
| + if d.Guidance != "" { |
| + fmt.Fprintf(stdout, "DNS: %s — %s\n", dns, d.Guidance) |
| + } else { |
| + fmt.Fprintf(stdout, "DNS: %s\n", dns) |
| + } |
| + return |
| + } |
| } |
| func runDomainsDetach(args []string) { |
| @@ -160,7 +198,9 @@ func runDomainsList(args []string) { |
| // without shouting it. DELEGATION is a tri-state the door itself never |
| // fabricates: "-" covers both "no delegation zone configured" and |
| // "wildcard is off", the two cases the door omits the field for. |
| - fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION") |
| + // GUIDANCE (last — it is the long free-text column) is the door's own |
| + // point-your-DNS-here instruction, "-" when the domain is pointing. |
| + fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE") |
| for _, r := range recs { |
| dns := r.DNSStatus |
| if dns == "" { |
| @@ -182,8 +222,12 @@ func runDomainsList(args []string) { |
| if delegation == "" { |
| delegation = "-" |
| } |
| - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| - r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation) |
| + guidance := r.Guidance |
| + if guidance == "" { |
| + guidance = "-" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation, guidance) |
| } |
| w.Flush() |
| } |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index bc3a318cc..693e88b3a 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -38,7 +38,7 @@ func TestAPISessionDomainAttach(t *testing.T) { |
| s := newTestSession(ts.URL) |
| created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| - "tickets.example.com", "slug.go.tito.io", nil, nil) |
| + "tickets.example.com", "slug.go.tito.io", "", "", nil, nil) |
| if err != nil { |
| t.Fatalf("domainAttach: %v", err) |
| } |
| @@ -66,7 +66,7 @@ func TestAPISessionDomainAttachNoOp(t *testing.T) { |
| s := newTestSession(ts.URL) |
| created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| - "tickets.example.com", "", nil, nil) |
| + "tickets.example.com", "", "", "", nil, nil) |
| if err != nil { |
| t.Fatalf("domainAttach: %v", err) |
| } |
| @@ -86,7 +86,7 @@ func TestAPISessionDomainAttachConflict(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "", nil, nil) |
| + _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "", "", "", nil, nil) |
| if err == nil || err.Error() != "hostname unavailable" { |
| t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err) |
| } |
| @@ -105,7 +105,7 @@ func TestAPISessionDomainAttachWildcardRefusal(t *testing.T) { |
| s := newTestSession(ts.URL) |
| yes := true |
| - _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", &yes, nil) |
| + _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, nil) |
| if err == nil || err.Error() != msg { |
| t.Fatalf("domainAttach against a 422 = %v, want exactly %q", err, msg) |
| } |
| @@ -127,7 +127,7 @@ func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) { |
| // Neither named: the wire body must carry no "wildcard"/"catchall" keys |
| // at all, not false-valued ones — an absent key is what "leave |
| // unchanged" means to the door. |
| - if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", nil, nil); err != nil { |
| + if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", nil, nil); err != nil { |
| t.Fatalf("domainAttach: %v", err) |
| } |
| if _, ok := gotBody["wildcard"]; ok { |
| @@ -141,7 +141,7 @@ func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) { |
| // their own values — false is a real request ("turn it off"), not an |
| // absence. |
| yes, no := true, false |
| - if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", &yes, &no); err != nil { |
| + if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, &no); err != nil { |
| t.Fatalf("domainAttach: %v", err) |
| } |
| var w, c bool |
| @@ -246,7 +246,7 @@ func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) { |
| s := newTestSession(ts.URL) |
| acct := apiAcct{Seg: "bab"} |
| - if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", "", nil, nil); err == nil { |
| + if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", "", "", "", nil, nil); err == nil { |
| t.Fatal("domainAttach against a 404 must error") |
| } |
| if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil { |
| @@ -299,6 +299,15 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| "wildcard": true, "catchall": true, "delegation_status": "delegated"}, |
| {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending", |
| "wildcard": false, "catchall": false}, |
| + // guide.example.com is not pointing yet and carries guidance — |
| + // the row TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing |
| + // and TestRunDomainsListPrintsTable's GUIDANCE-column assertion |
| + // exercise, alongside tickets.example.com's pointing case and |
| + // new.example.com's no-guidance-yet ("pending", no dns_status |
| + // sent at all) case above. |
| + {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending", |
| + "dns_status": "elsewhere", |
| + "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."}, |
| }, |
| }) |
| default: |
| @@ -326,6 +335,12 @@ func domainsCLIFixture(t *testing.T) *fakeDomainsConsole { |
| return fc |
| } |
| +// TestRunDomainsAttachPrintsAttached also pins the no-infra completion the |
| +// spec asks for (§5): a successful attach re-fetches the domain list and |
| +// prints the just-attached host's own DNS stamp — here "pointing", since |
| +// the fake's fixed GET row for tickets.example.com already carries it — so |
| +// the command answers "is this live?" without a second `carlos domains |
| +// list`. |
| func TestRunDomainsAttachPrintsAttached(t *testing.T) { |
| fc := domainsCLIFixture(t) |
| fc.attachCreated = true |
| @@ -336,8 +351,28 @@ func TestRunDomainsAttachPrintsAttached(t *testing.T) { |
| if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" { |
| t.Fatalf("console saw body %+v", fc.lastAttachBody) |
| } |
| - if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") { |
| - t.Fatalf("stdout = %q, want the attached confirmation", buf.String()) |
| + out := buf.String() |
| + if !strings.Contains(out, "attached tickets.example.com -> slug.go.tito.io") { |
| + t.Fatalf("stdout = %q, want the attached confirmation", out) |
| + } |
| + if !strings.Contains(out, "DNS: pointing") { |
| + t.Fatalf("stdout = %q, want the DNS stamp line completing the job", out) |
| + } |
| +} |
| + |
| +// TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing pins the other arm: |
| +// a host whose DNS is not pointing yet gets the console's own guidance text |
| +// verbatim, ending the command's output with exactly what the member still |
| +// has to do (spec §5's "completes the job or says exactly what is left"). |
| +func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "guide.example.com"}) |
| + |
| + want := "DNS: elsewhere — Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes." |
| + if out := buf.String(); !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| } |
| } |
| @@ -380,8 +415,11 @@ func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) { |
| } |
| } |
| -// TestRunDomainsListPrintsTable: the list table carries every column and |
| -// falls back to "-" for the row with no dns_status/cert_expires_at. |
| +// TestRunDomainsListPrintsTable: the list table carries every column |
| +// (including the GUIDANCE column added for the no-infra rule — the DNS |
| +// stamp alone tells a member DNS is wrong, but only guidance tells them |
| +// what to do about it) and falls back to "-" for a row with no |
| +// dns_status/cert_expires_at/guidance. |
| func TestRunDomainsListPrintsTable(t *testing.T) { |
| domainsCLIFixture(t) |
| buf := captureStdout(t) |
| @@ -390,9 +428,11 @@ func TestRunDomainsListPrintsTable(t *testing.T) { |
| out := buf.String() |
| for _, want := range []string{ |
| - "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", |
| + "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE", |
| "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15", |
| "new.example.com", "pending", |
| + "guide.example.com", "elsewhere", |
| + "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes.", |
| } { |
| if !strings.Contains(out, want) { |
| t.Fatalf("stdout missing %q:\n%s", want, out) |
| @@ -409,21 +449,25 @@ func TestRunDomainsListPrintsTable(t *testing.T) { |
| } |
| } |
| // tickets.example.com carries wildcard+catchall+delegated: "yes" twice |
| - // and "delegated" once, in the last three (tabwriter-padded) columns. |
| + // and "delegated" once, followed only by the trailing GUIDANCE cell — |
| + // "-" here, since the row is pointing and the door sends no guidance. |
| if wildcardLine == "" { |
| t.Fatal("no row for tickets.example.com") |
| } |
| - if fields := strings.Fields(wildcardLine); len(fields) < 3 || |
| - fields[len(fields)-3] != "yes" || fields[len(fields)-2] != "yes" || fields[len(fields)-1] != "delegated" { |
| - t.Fatalf("wildcard row = %q, want yes/yes/delegated in the last three columns", wildcardLine) |
| + if fields := strings.Fields(wildcardLine); len(fields) < 4 || |
| + fields[len(fields)-4] != "yes" || fields[len(fields)-3] != "yes" || |
| + fields[len(fields)-2] != "delegated" || fields[len(fields)-1] != "-" { |
| + t.Fatalf("wildcard row = %q, want yes/yes/delegated/- in the last four columns", wildcardLine) |
| } |
| // The pending row's dns/cert columns read "-", and wildcard/catchall are |
| - // blank (not "yes") with delegation "-" — the door omits the field. |
| + // blank (not "yes") with delegation and guidance both "-" — the door |
| + // omits both fields. |
| if plainLine == "" { |
| t.Fatal("no row for new.example.com") |
| } |
| - if fields := strings.Fields(plainLine); len(fields) < 1 || fields[len(fields)-1] != "-" { |
| - t.Fatalf("pending row = %q, want delegation \"-\"", plainLine) |
| + if fields := strings.Fields(plainLine); len(fields) < 2 || |
| + fields[len(fields)-2] != "-" || fields[len(fields)-1] != "-" { |
| + t.Fatalf("pending row = %q, want delegation and guidance \"-\"", plainLine) |
| } |
| if strings.Contains(plainLine, "yes") { |
| t.Fatalf("pending row = %q, want no wildcard/catchall yes when both are false", plainLine) |
diff --git a/domains_e2e_test.go b/domains_e2e_test.go| new file mode 100644 |
| index 000000000..87488d55f |
| --- /dev/null |
| +++ b/domains_e2e_test.go |
| @@ -0,0 +1,253 @@ |
| +// End-to-end acceptance for the domain-attach half of #82 (Task 8), driven |
| +// through the REAL binary against a REAL console.Server: `carlos deploy |
| +// -kind static -host <h>` attaches an unclaimed custom domain on its way |
| +// through (claim + declared record in the bucket), a second attach from a |
| +// DIFFERENT account is refused with the neutral "hostname unavailable" and |
| +// nothing else, and `carlos domains list -app` shows the member their own |
| +// claim. Subprocesses throughout, because the feature's surface is the |
| +// binary's own composition (deploy → attach POST → ship → promote → watch → |
| +// DNS guidance) and the refusal text on a real stderr is the thing the spec |
| +// pins. |
| +// |
| +// Env discipline: every subprocess builds its env through carlosTestEnv AND |
| +// sets cmd.Dir to a scratch dir (the repo rule — the binary walks cwd |
| +// ancestors for .carlos/config, and a logged-in laptop once minted 40+ real |
| +// accounts on the production console). The explicit CARLOS_CREDENTIALS |
| +// appended after carlosTestEnv's own wins (os/exec keeps the LAST duplicate), |
| +// landing each subprocess on exactly the credential file this test minted. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// e2eWriteCredential mints a credential file at path for a device-flow token |
| +// against consoleURL — the state `carlos auth login` leaves behind, minted |
| +// through the console's real approve/poll handlers (e2eDeviceLogin). |
| +func e2eWriteCredential(t *testing.T, path, consoleURL, addr, token string, expiresAt time.Time) { |
| + t.Helper() |
| + t.Setenv("CARLOS_CREDENTIALS", path) |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: consoleURL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestE2EDomainsAttachThroughDeploy(t *testing.T) { |
| + bin := buildCarlos(t) |
| + const ( |
| + ownerAddr = "paul@keymail.dev" |
| + rivalAddr = "rival@keymail.dev" |
| + app = "sitely" |
| + rivalApp = "rival" |
| + channel = "canary/site" |
| + // .test never resolves, so the watch leg's connection-class failure — |
| + // the shape a domain nobody has pointed yet produces — is hermetic. |
| + host = "e2e-domains.example.test" |
| + ) |
| + |
| + tmp := t.TempDir() |
| + relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { cst.Close() }) |
| + |
| + var srv *console.Server |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + srv.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ts.Close) |
| + srv, err = console.NewServer(console.Config{ |
| + Origin: ts.URL, |
| + Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin |
| + Releases: relStore, |
| + ReleasePub: pub, |
| + Key: priv, |
| + AppScheme: "http", |
| + AppsDomain: "oncarlos.test", |
| + }, cst) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Account 1: the ops owner and the static app the deploy targets. |
| + if err := cst.AddMember(console.OpsAccountID, ownerAddr, console.RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cst.CreateAppIn(console.OpsAccountID, app, ownerAddr, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Account 2: a different tenant entirely — the prober the neutrality |
| + // half of this test speaks as. |
| + const rivalID, rivalSqid = 42, "cdef" |
| + if err := cst.CreateAccount(rivalID, rivalSqid, "Rival", rivalAddr, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cst.CreateAppIn(rivalID, rivalApp, rivalAddr, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + ownerToken, ownerExp := e2eDeviceLogin(t, ts.URL, cst, ownerAddr, "e2e@owner") |
| + rivalToken, rivalExp := e2eDeviceLogin(t, ts.URL, cst, rivalAddr, "e2e@rival") |
| + ownerCreds := filepath.Join(t.TempDir(), "owner-credentials") |
| + rivalCreds := filepath.Join(t.TempDir(), "rival-credentials") |
| + e2eWriteCredential(t, ownerCreds, ts.URL, ownerAddr, ownerToken, ownerExp) |
| + e2eWriteCredential(t, rivalCreds, ts.URL, rivalAddr, rivalToken, rivalExp) |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + run := func(creds string, args ...string) (string, error) { |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds, |
| + "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") |
| + cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // --- 1. deploy to a fresh static host: the attach happens on the way -- |
| + out, err := run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static", |
| + "-version", "sha0001", "-channel", channel, "-host", host, "-console", ts.URL, |
| + "-timeout", "3s", siteDir) |
| + // The deploy itself succeeds (attach + ship + promote) and then exits |
| + // NONZERO on the watch: nobody has pointed DNS for the host, and a zero |
| + // exit would claim a URL that does not serve. The exit text is the DNS |
| + // guidance, not "deploy failed". |
| + if err == nil { |
| + t.Fatalf("deploy to an unpointed domain exited 0:\n%s", out) |
| + } |
| + for _, want := range []string{ |
| + "attached " + host + " (first route: static, channel " + channel + ")", |
| + "shipped " + app + " sha0001", |
| + "promoted " + app + " sha0001 -> " + channel, |
| + "DNS for " + host + " is not pointing at this deployment yet", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("deploy output missing %q:\n%s", want, out) |
| + } |
| + } |
| + // The two bucket writes the attach is made of, read back directly. |
| + claim, err := domains.GetClaim(context.Background(), relStore, host) |
| + if err != nil { |
| + t.Fatalf("claim after deploy: %v", err) |
| + } |
| + if claim.Account != release.OpsAccount || claim.App != app || claim.Route != "" { |
| + t.Fatalf("claim = %+v, want a first-route claim (empty route) held by ops/%s", claim, app) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host) |
| + if err != nil { |
| + t.Fatalf("record after deploy: %v", err) |
| + } |
| + if rec.Kind != "static" || rec.Channel != channel || rec.Slug != "" { |
| + 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) |
| + if err == nil { |
| + t.Fatalf("rival attach of a held host exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "hostname unavailable") { |
| + t.Fatalf("rival attach refusal missing the neutral \"hostname unavailable\":\n%s", out) |
| + } |
| + // Neutral means NEUTRAL: no owner, no account, no app of the holder's in |
| + // the refusal — the door must not be a hostname-ownership oracle. |
| + for _, leak := range []string{release.OpsAccount, app, ownerAddr} { |
| + if strings.Contains(out, leak) { |
| + t.Fatalf("refusal leaks the holder (%q):\n%s", leak, out) |
| + } |
| + } |
| + // And the claim is untouched. |
| + if after, err := domains.GetClaim(context.Background(), relStore, host); err != nil || |
| + after.Account != release.OpsAccount || after.App != app { |
| + t.Fatalf("claim after rival attach = %+v (err %v), want the original untouched", after, err) |
| + } |
| + |
| + // --- 3. the owner lists it ------------------------------------------- |
| + out, err = run(ownerCreds, "domains", "list", "-app", app, "-account", "ops", "-console", ts.URL) |
| + if err != nil { |
| + t.Fatalf("domains list: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, host) || !strings.Contains(out, "pending") { |
| + t.Fatalf("domains list missing %s as pending:\n%s", host, out) |
| + } |
| + |
| + // --- 4. detach releases it, and the record goes with it -------------- |
| + out, err = run(ownerCreds, "domains", "detach", "-app", app, "-account", "ops", "-console", ts.URL, host) |
| + if err != nil { |
| + t.Fatalf("domains detach: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "detached "+host) { |
| + t.Fatalf("detach output:\n%s", out) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), relStore, host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("claim after detach: %v, want gone", err) |
| + } |
| + // The record goes with the claim (detach's record-then-claim order): a |
| + // record outliving its claim would keep a route materializing for a |
| + // host anyone may now claim. |
| + if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("record after detach: %v, want gone", err) |
| + } |
| +} |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 4e82f297e..6b6d3b492 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -140,14 +140,26 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a |
| // route, write. Idempotent by design (the titogo contract): re-attaching a |
| // hostname this app already holds is 200 with created false, converging the |
| // 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" 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 { |
| return |
| } |
| var in struct { |
| - Host string `json:"host"` |
| - Route string `json:"route"` |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Kind string `json:"kind"` |
| + Channel string `json:"channel"` |
| // Wildcard and Catchall are the woodstar-carloku capability flips |
| // (Task 5), decoded as pointers so an ABSENT field means "leave |
| // unchanged" — the same tri-state rule apps place's -target and |
| @@ -175,8 +187,37 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| return |
| } |
| + if in.Kind != "" && in.Route != "" { |
| + jsonError(w, http.StatusBadRequest, |
| + "route and kind are two different instructions — name a route to alias, or a kind to mint, not both") |
| + return |
| + } |
| + if in.Kind != "" && in.Kind != "static" { |
| + jsonError(w, http.StatusBadRequest, "kind must be \"static\"") |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| + if in.Kind == "static" { |
| + // 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 |
| + } |
| + route := res.Claim.Route |
| + if res.FirstRoute { |
| + route = host |
| + } |
| + 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 { |
| return |
| @@ -363,6 +404,22 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| + // The first-route half (#82): a claim with an empty Route minted the |
| + // app's own primary route via a declared provision record, and that |
| + // record must go BEFORE the claim — the mirror of attach's claim-then- |
| + // record order, so a failure here cannot leave a record materializing a |
| + // route for a host anyone may now claim. Only the caller's own |
| + // first-route claim takes this branch; every other shape (absent, |
| + // another owner's, an alias) skips it silently and DetachClaim's own |
| + // neutrality answers as before. |
| + if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && |
| + cur.Account == acct.Sqid && cur.App == app && cur.Route == "" { |
| + if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil { |
| + log.Printf("console: api domains detach %s/%s %s: record delete: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again") |
| + return |
| + } |
| + } |
| removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app) |
| if err != nil { |
| log.Printf("console: api domains detach %s/%s %s: %v", acct.Sqid, app, host, err) |
| @@ -381,13 +438,19 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| // (host, route) joined with what the fleet actually reports for it. Status is |
| // "live" or "pending" and never absent; the two reading columns omit when |
| // unstamped, the same unknown-stays-absent convention status.AliasReading |
| -// itself uses. |
| +// itself uses. Guidance is the point-your-DNS-here instruction, present |
| +// exactly when DNSStatus is anything but "pointing" — the same words and |
| +// the same condition as the console Settings page (domainGuidance, view.go), |
| +// so `carlos deploy`'s DNS-guidance exit and the page a member opens next |
| +// can never tell them two different stories. Additive: a client that |
| +// predates it simply never decodes it. |
| type domainListItem struct { |
| Host string `json:"host"` |
| Route string `json:"route"` |
| Status string `json:"status"` |
| DNSStatus string `json:"dns_status,omitempty"` |
| CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| + Guidance string `json:"guidance,omitempty"` |
| // Wildcard and Catchall (Task 5) copy the claim's own capability bits |
| // verbatim — always present, true or false, the same "known state" |
| // convention Created/Removed use on the attach/detach answers (never |
| @@ -447,6 +510,9 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| item.DNSStatus = reading.DNSStatus |
| item.CertExpiresAt = reading.CertExpiresAt |
| } |
| + if item.DNSStatus != "pointing" { |
| + item.Guidance = domainGuidance(c.Host, s.cfg.EdgeIPs) |
| + } |
| // Each stamp is a live, sequential, uncancellable-per-call DNS round |
| // trip on the request path (lookupCNAME) — capped at |
| // domainListCNAMELookupCap per list call so an app with dozens of |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 29173035a..3127077bc 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -27,6 +27,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/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -490,6 +491,117 @@ func TestAPIDomainSerialBump(t *testing.T) { |
| } |
| } |
| +// 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) |
| + } |
| +} |
| + |
| // ---- wildcard/catchall capability door (Task 5) ---------------------------- |
| // TestAttachWildcardCNAMEGate: the attach body's optional wildcard/catchall |
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go| new file mode 100644 |
| index 000000000..ec23703c9 |
| --- /dev/null |
| +++ b/internal/console/appdomains.go |
| @@ -0,0 +1,106 @@ |
| +// The app Settings tab's domain attach/detach door (#82, Task 4): thin |
| +// browser-form handlers over the attachDomain/detachDomain core in |
| +// domainattach.go. Everything that decides whether an attach or detach is |
| +// ALLOWED — platform space, alias-vs-first-route, the claim/record write |
| +// order, the audit row, the serial bump — lives there and is not repeated |
| +// here. What's here is the browser's own shape: session, CSRF, the |
| +// owner-only gate, and turning the core's answer into a page. |
| +// |
| +// Gate order copies handleAppDelete's (appsettings.go) exactly, minus |
| +// requireFreshAuth: session (accountApp) -> CSRF -> owner (byte-identical |
| +// 404) -> refusals -> act. Spec §6: attach/detach are member-reversible, no |
| +// passkey step-up — detach's protection is the typed-host confirm below, |
| +// not a passkey. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// handleAppDomainAttach claims a host for the app and, when the app has no |
| +// route to alias, declares the static route that becomes its first one. |
| +// The browser door never sends a kind — the core infers alias vs |
| +// first-route from whether the app already has a ready route — and the |
| +// channel field is optional, passed through as typed. |
| +func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + host := strings.ToLower(strings.TrimSpace(r.FormValue("host"))) |
| + channel := strings.TrimSpace(r.FormValue("channel")) |
| + |
| + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| + defer cancel() |
| + res, ref := s.attachDomain(ctx, acct, app, host, "", channel, addr) |
| + if ref != nil { |
| + s.renderDoorRefusal(w, r, ref) |
| + return |
| + } |
| + msg := "Attached " + host + " — it now aliases the app's existing route." |
| + if res.FirstRoute { |
| + msg = "Attached " + host + " as the app's first route, following " + res.Channel + "." |
| + } |
| + s.setFlash(w, "ok", msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| +} |
| + |
| +// handleAppDomainDetach releases a host's claim. The typed-host confirm is |
| +// the browser door's own gate — spec §6's substitute for a passkey step-up |
| +// on a reversible action — and is checked BEFORE the core is called: a |
| +// mismatch must leave the claim exactly as it was. |
| +func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + host := strings.ToLower(strings.TrimSpace(r.FormValue("host"))) |
| + if strings.ToLower(strings.TrimSpace(r.FormValue("confirm"))) != host { |
| + s.renderMessage(w, r, http.StatusUnprocessableEntity, "Refused", "Type the domain exactly to detach it.") |
| + return |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| + defer cancel() |
| + if ref := s.detachDomain(ctx, acct, app, host, addr); ref != nil { |
| + s.renderDoorRefusal(w, r, ref) |
| + return |
| + } |
| + s.setFlash(w, "ok", "Detached "+host+".") |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| +} |
| + |
| +// renderDoorRefusal turns a doorRefusal from the Task 3 core into this |
| +// door's page. 404 MUST be http.NotFound: the stranger's byte-identical |
| +// 404, not a rendered message that would tell a prober "an app exists |
| +// here, but this host does not" versus "nothing exists here at all". |
| +// Every other status renders through renderMessage with the refusal's own |
| +// status and member-facing text. |
| +func (s *Server) renderDoorRefusal(w http.ResponseWriter, r *http.Request, ref *doorRefusal) { |
| + if ref.Status == http.StatusNotFound { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + s.renderMessage(w, r, ref.Status, "Refused", ref.Msg) |
| +} |
diff --git a/internal/console/appdomains_test.go b/internal/console/appdomains_test.go| new file mode 100644 |
| index 000000000..02ab75a0f |
| --- /dev/null |
| +++ b/internal/console/appdomains_test.go |
| @@ -0,0 +1,213 @@ |
| +package console |
| + |
| +// The browser Settings door for domain attach/detach (appdomains.go, #82 |
| +// Task 4): thin handlers over the attachDomain/detachDomain core proven in |
| +// domainattach_test.go. What these tests own is the door's OWN shape — |
| +// session, CSRF, the owner-only gate matching handleAppDelete's, and |
| +// detach's typed-host confirm — not the attach/detach policy itself. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| +) |
| + |
| +// TestAppDomainAttachHappyPathAliases exercises the default fixture's kass |
| +// app, which already carries one ready route (kass.carlosapp.dev, view_test's |
| +// buildFixtures) — the alias branch needs nothing more set up than that. |
| +func TestAppDomainAttachHappyPathAliases(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + const host = "docs.example.com" |
| + |
| + status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {csrf}, "host": {host}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| + } |
| + claim, err := domains.GetClaim(context.Background(), fx.rel, host) |
| + if err != nil { |
| + t.Fatalf("claim: %v", err) |
| + } |
| + if claim.Account != fx.sqid || claim.App != "kass" || claim.Route == "" { |
| + t.Errorf("claim = %+v, want an alias claim naming kass's existing route", claim) |
| + } |
| +} |
| + |
| +// TestAppDomainAttachNeedsCSRF matches TestRenameHandlerNeedsCSRF's shape: a |
| +// cross-site request is refused before the host is ever looked at, and |
| +// nothing is claimed. |
| +func TestAppDomainAttachNeedsCSRF(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + const host = "docs.example.com" |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {"not-the-token"}, "host": {host}}) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("attach with a bad csrf = %d, want 403:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "Cross-site request blocked.") { |
| + t.Fatalf("403 body does not name the reason:\n%s", body) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestAppDomainAttachLowercasesHost: a member typing Docs.Example.com must |
| +// not be refused by ValidHostShape's lowercase-only label regex — the door |
| +// normalizes case before the host ever reaches the core, and the claim |
| +// lands at the normalized key, not the one typed. |
| +func TestAppDomainAttachLowercasesHost(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + const mixed = "Docs.Example.com" |
| + const lower = "docs.example.com" |
| + |
| + status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {csrf}, "host": {mixed}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, lower); err != nil { |
| + t.Errorf("claim at the lowercase key: %v, want it there", err) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, mixed); err == nil { |
| + t.Error("a claim also landed at the mixed-case key — the door did not normalize") |
| + } |
| +} |
| + |
| +// TestAppDomainAttachIsOwnerOnly matches TestAppDeleteIsOwnerOnly's shape: |
| +// a member gets the same byte-identical 404 a total stranger would, and |
| +// nothing is claimed. |
| +func TestAppDomainAttachIsOwnerOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-appdomainattach") |
| + csrf := sessionToken(t, fx, c) |
| + const host = "docs.example.com" |
| + |
| + status, _, memberBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {csrf}, "host": {host}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member attaching a domain = %d, want 404", status) |
| + } |
| + strangerBody := strangerRefusal(t, fx, "code-zoe-appdomainattach", fx.u("/apps/kass/domains/attach"), |
| + url.Values{"host": {host}}) |
| + if memberBody != strangerBody { |
| + t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// domainAttachedFixture attaches host to app via the Task 3 core directly |
| +// (the doors' shared, already-tested path) and returns the fixture and |
| +// signed-in owner client — the setup every detach test needs and none of |
| +// them is testing. |
| +func domainAttachedFixture(t *testing.T, app, host string) (*consoleFixture, *http.Client, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| + } |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, app, host, "", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("attach setup: refused %+v", ref) |
| + } |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + return fx, c, csrf |
| +} |
| + |
| +// TestAppDomainDetachNeedsMatchingConfirm is the binding detail from the |
| +// brief: a confirm that does not equal the host is refused 422 BEFORE the |
| +// core is ever called, so the claim must survive untouched. |
| +func TestAppDomainDetachNeedsMatchingConfirm(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, c, csrf := domainAttachedFixture(t, "kass", host) |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| + url.Values{"csrf": {csrf}, "host": {host}, "confirm": {"not-" + host}}) |
| + if status != http.StatusUnprocessableEntity { |
| + t.Fatalf("detach with a wrong confirm = %d, want 422:\n%s", status, body) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| + t.Errorf("claim after a refused detach: %v, want it untouched", err) |
| + } |
| +} |
| + |
| +// TestAppDomainDetachHappyPath: the typed confirm matches, the claim goes, |
| +// and the member lands back on Settings. |
| +func TestAppDomainDetachHappyPath(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, c, csrf := domainAttachedFixture(t, "kass", host) |
| + |
| + status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| + url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestAppDomainDetachUnattachedHostIs404 exercises renderDoorRefusal's 404 |
| +// branch: the owner's own gates all pass (CSRF, RoleOwner, confirm == host), |
| +// but the core has nothing to release, so its doorRefusal{404,...} must |
| +// render as the stranger's own byte-identical 404 — never a message that |
| +// would tell a prober "an app exists here, but this host does not". |
| +func TestAppDomainDetachUnattachedHostIs404(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + const host = "never-attached.example.com" |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| + url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("detach of an unattached host = %d, want 404:\n%s", status, body) |
| + } |
| + strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach-unattached", fx.u("/apps/kass/domains/detach"), |
| + url.Values{"host": {host}, "confirm": {host}}) |
| + if body != strangerBody { |
| + t.Fatalf("owner's unattached-host 404 is not byte-identical to the stranger's:\n(owner) %q\n(stranger) %q", body, strangerBody) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestAppDomainDetachIsOwnerOnly mirrors the attach case: a member gets the |
| +// same byte-identical 404 a stranger would. |
| +func TestAppDomainDetachIsOwnerOnly(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, _, _ := domainAttachedFixture(t, "kass", host) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + sc := newBrowser(t) |
| + signUp(t, fx, sc, "code-sam-appdomaindetach") |
| + scsrf := sessionToken(t, fx, sc) |
| + |
| + status, _, memberBody := postForm(t, sc, fx.u("/apps/kass/domains/detach"), |
| + url.Values{"csrf": {scsrf}, "host": {host}, "confirm": {host}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member detaching a domain = %d, want 404", status) |
| + } |
| + strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach", fx.u("/apps/kass/domains/detach"), |
| + url.Values{"host": {host}, "confirm": {host}}) |
| + if memberBody != strangerBody { |
| + t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| + t.Errorf("claim after a member's refused detach: %v, want it untouched", err) |
| + } |
| +} |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index a77a78324..35d55cb6b 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -12,10 +12,15 @@ |
| // rename is a coordinated migration, not a settings write. The form |
| // refuses with that reason once anything outside this database knows the |
| // name. |
| -// - Creating routes (custom domains). The console reads the registry and |
| -// writes it nothing; domains attach on the box today, and the console- |
| -// driven path wants the bucket-record + reconciler design instance |
| -// provisioning proved out — a platform feature, not a handler here. |
| +// - Attach/detach ITSELF. Domains attach and detach through their own door |
| +// now (appdomains.go, #82 Task 4) — the claim/record writes, their gate |
| +// order and the audit trail all live there. What's here is read-only: |
| +// settingsDomains below folds an app's still-routeless claims (every |
| +// alias claim, and a first-route claim before the reconciler has |
| +// materialized its route — neither ever gets a registry row of its |
| +// own) into the Domains & TLS table this page already draws, so an |
| +// attach shows up the moment it lands, not only once a route exists to |
| +// join it to. |
| // - The full account move. `carlos accounts migrate` moves bucket objects |
| // and re-stamps routes on the box; what the console owns is its own |
| // record, so what Settings offers is the repair half — making this |
| @@ -32,6 +37,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -58,6 +64,30 @@ type SettingsView struct { |
| // and a zero card when there is genuinely no store, which is what the |
| // "ask for one" line renders from. |
| Store *StoreCard |
| + |
| + // Domains is this page's OWN Domains & TLS rows (settingsDomains): a |
| + // copy of AppView.Domains with a placeholder row appended for any of |
| + // this app's claims that have no registry row yet. Deliberately not |
| + // written back onto AppView.Domains itself — that slice stays |
| + // index-parallel with AppView.Routes for assembleOverview's account |
| + // filter (view.go), and a claim-only row has no route to pair with. |
| + Domains []DomainView |
| + // OnlyPlatformDomain is true when Domains holds rows but none of them |
| + // is a custom one — the state the attach form's empty-state line |
| + // ("this app already answers at its platform domain…") is written for. |
| + OnlyPlatformDomain bool |
| + |
| + // NeedsChannel is whether the attach form must show its channel field: |
| + // true exactly when readyRouteHost (domainattach.go) would find no |
| + // ready, non-Platform route to alias, which is the SAME condition the |
| + // core uses to pick the first-route branch — attachChannel then only |
| + // has a self channel or the app's sole shipped channel to fall back on, |
| + // and refuses with "say which one" the moment there is more than one. |
| + // This is deliberately NOT len(Detail.Routes) == 0: an app whose only |
| + // route is its own platform mint has a non-empty Routes slice, but |
| + // readyRouteHost skips Platform rows, so it still takes the |
| + // first-route branch and still needs somewhere to type a channel. |
| + NeedsChannel bool |
| } |
| // StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue |
| @@ -93,6 +123,38 @@ type StoreCard struct { |
| // emptiness claim must not enable an identity change) and never the page. |
| func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *SettingsView { |
| sv := &SettingsView{} |
| + // Domains, its derived OnlyPlatformDomain, and NeedsChannel are all |
| + // read-only against the registry/claims and owe nothing to the |
| + // deployment-bucket check below — they must be set even on a console |
| + // with no bucket configured (s.cfg.Releases == nil), which used to |
| + // return before reaching them and show "No domains yet" under a live |
| + // route. |
| + sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains) |
| + sv.OnlyPlatformDomain = len(sv.Domains) > 0 |
| + for _, d := range sv.Domains { |
| + // Prefix, not equality: domainView appends " · canary/<name>" AFTER |
| + // the "platform"/"custom" word (view.go), so a platform canary |
| + // follower's Type is "platform · canary/rc" — equality against the |
| + // bare word would wrongly count it as a custom domain here. |
| + if !strings.HasPrefix(d.Type, "platform") { |
| + sv.OnlyPlatformDomain = false |
| + break |
| + } |
| + } |
| + // The same question attachDomain's first line of policy answers |
| + // (domainattach.go's readyRouteHost): is there a ready, non-Platform |
| + // route this app could alias? "" means no — the attach form's channel |
| + // field must show, because the core is about to take the first-route |
| + // branch and attachChannel only has a self channel or the app's sole |
| + // shipped channel to infer from. The attaching host is unknown here |
| + // (nothing has been typed yet), so "" is passed — it can never equal a |
| + // real route host, so every ready non-Platform route is a candidate. |
| + if route, _, err := s.readyRouteHost(acct.Sqid, p.AppName, ""); err != nil { |
| + log.Printf("console: settings needs-channel %s/%s: %v", acct.Sqid, p.AppName, err) |
| + sv.NeedsChannel = true // fail toward showing the field the core might need |
| + } else { |
| + sv.NeedsChannel = route == "" |
| + } |
| if s.cfg.Releases == nil { |
| sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run." |
| return sv |
| @@ -167,6 +229,38 @@ func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *Stor |
| return card |
| } |
| +// settingsDomains is the Settings page's own Domains & TLS rows: routed |
| +// (the AppView's own DomainView slice, untouched) plus a placeholder row |
| +// for any of this app's domain claims that routed has no entry for yet. |
| +// Best-effort like the rest of this file — a claim-list read failure is |
| +// logged and the page falls back to routed alone, never a hard error. |
| +func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, routed []DomainView) []DomainView { |
| + out := append([]DomainView(nil), routed...) |
| + if s.cfg.Releases == nil { |
| + return out |
| + } |
| + have := make(map[string]int, len(out)) |
| + for i, d := range out { |
| + have[strings.ToLower(d.Domain)] = i |
| + } |
| + claims, err := domains.ListClaims(ctx, s.cfg.Releases) |
| + if err != nil { |
| + log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err) |
| + return out |
| + } |
| + for _, c := range claims { |
| + if c.Account != acct.Sqid || c.App != app { |
| + continue |
| + } |
| + if i, ok := have[strings.ToLower(c.Host)]; ok { |
| + out[i].Claimed = true |
| + continue |
| + } |
| + out = append(out, claimDomainView(c.Host, s.cfg.EdgeIPs)) |
| + } |
| + return out |
| +} |
| + |
| // appDeployEmpty reports whether the bucket holds ANYTHING under the app's |
| // prefix — account-qualified or legacy-unqualified. One key is enough to |
| // refuse a rename; the caller never needs the listing itself. |
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go| index 38b10b441..5440b1a24 100644 |
| --- a/internal/console/appsettings_test.go |
| +++ b/internal/console/appsettings_test.go |
| @@ -969,3 +969,109 @@ func TestSettingsPageShowsTheStoreCard(t *testing.T) { |
| t.Fatalf("an app with no store is offered a destroy form:\n%s", body) |
| } |
| } |
| + |
| +// ---- the domain attach/detach section of Settings (#82 Task 5) ------------- |
| +// |
| +// The honesty text ("attached where the app runs — on its box, by an |
| +// operator") is retired now that this account's own door does the attaching |
| +// (appdomains.go, Task 4): the page must offer that door's form instead of a |
| +// box command. And a claim this app holds must show up here the moment it |
| +// exists, not only once a registry route has caught up with it — which, |
| +// for an ALIAS claim, is never: domainattach.go's own comment is that |
| +// minting a route row for an alias is exactly the #129/#148 bare-alias |
| +// class this design avoids. |
| + |
| +// TestSettingsPageOffersAttachFormNotBoxInstructions is the honesty-text |
| +// retirement itself. |
| +func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + freshApp(t, fx, "sprout") |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/sprout/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + if strings.Contains(body, "carlos add -host") { |
| + t.Fatalf("the settings page still tells the owner to attach on the box:\n%s", body) |
| + } |
| + if !strings.Contains(body, `action="/a/bab/apps/sprout/domains/attach"`) { |
| + t.Fatalf("the settings page does not post to its own attach door:\n%s", body) |
| + } |
| +} |
| + |
| +// TestSettingsPageShowsAnUnroutedClaimAsAttaching exercises the gap the |
| +// honesty text used to paper over: kass already has a ready route, so |
| +// attaching docs.example.com to it takes the ALIAS branch, which never gets |
| +// a registry row — the claim must still render, as its own placeholder row, |
| +// not vanish until a route exists to join it to. |
| +func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, c, _ := domainAttachedFixture(t, "kass", host) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, host) { |
| + t.Fatalf("an attached claim with no route yet does not appear on its own Settings page:\n%s", body) |
| + } |
| + if !strings.Contains(body, "custom · attaching") { |
| + t.Fatalf("the claim-only row does not say it's still attaching:\n%s", body) |
| + } |
| +} |
| + |
| +// TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform is the review |
| +// fix: readyRouteHost (domainattach.go) skips Platform rows when deciding |
| +// whether an attach can alias, so an app whose only route is its own |
| +// platform mint takes the first-route branch server-side and needs the |
| +// channel field exactly as a never-shipped app does — `len(Detail.Routes) |
| +// == 0` would wrongly hide it, because the platform route makes that slice |
| +// non-empty. |
| +func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + freshApp(t, fx, "sprout") |
| + fx.addRoute(t, registry.Route{Host: "sprout.bab.oncarlos.com", Kind: "static", Status: "ready", |
| + App: "sprout", Channel: "stable", Platform: true}) |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/sprout/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, `id="attach-channel"`) { |
| + t.Fatalf("an app whose only route is its platform mint is not offered the channel field:\n%s", body) |
| + } |
| +} |
| + |
| +// TestAssembleSettingsListsRoutedDomainsWithoutABucket is the other review |
| +// fix: assembleSettings used to return before sv.Domains was ever set on a |
| +// bucketless console (s.cfg.Releases == nil), so a routed app's own domain |
| +// would vanish from Settings.Domains the moment there was no bucket to |
| +// check the never-shipped rename gate against. |
| +// |
| +// This calls assembleSettings directly rather than through the HTTP page: |
| +// a bucketless console panics inside Assemble itself (assembleChannels, |
| +// view.go, calls release.Store.List on every app-page tab, Settings |
| +// included, with no nil guard) — a real, pre-existing gap, but one that |
| +// reaches every app page and is not Task 5's Settings-only bug. Fixing |
| +// assembleSettings's own early return is; isolating it here proves that fix |
| +// without the unit test tripping over the wider, separate one. |
| +func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.Releases = nil |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| + } |
| + detail := &AppView{ |
| + App: "kass", |
| + Domains: []DomainView{{Domain: "kass.carlosapp.dev", Type: "custom"}}, |
| + } |
| + p := &page{AppName: "kass", Detail: detail} |
| + |
| + sv := fx.srv.assembleSettings(context.Background(), p, acct) |
| + if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" { |
| + t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains) |
| + } |
| +} |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| new file mode 100644 |
| index 000000000..40dce96a9 |
| --- /dev/null |
| +++ b/internal/console/domainattach.go |
| @@ -0,0 +1,424 @@ |
| +// Attach and detach a custom domain — the core both doors share (#82). |
| +// Spec: docs/superpowers/specs/2026-08-12-domain-attach-design.md §3-§4. |
| +// |
| +// The attach splits on one question the console can answer: does the app |
| +// already have a ready route? Yes → an ALIAS claim naming that route, and |
| +// nothing else is written (the `route_aliases` row is derived box-side; |
| +// minting a second route row is the #129 socket-race / #148 bare-alias |
| +// class this design exists to avoid). No → a FIRST-ROUTE attach: the same |
| +// claim with an empty route, plus a declared static route record in #137's |
| +// vocabulary, which the provisioning reconciler materializes as the app's |
| +// real primary route. |
| +// |
| +// The invariant: the write order is CLAIM THEN RECORD. The claim's |
| +// create-only write IS the fleet-wide uniqueness check (#151's |
| +// domains.AttachClaim — the bucket key is the cross-account gate), so it |
| +// must be decided before anything else about the host exists. That ordering |
| +// also picks the failure worth having — a half-failed attach leaves at worst |
| +// a claim with no record (inert: no route is materialized from a |
| +// first-route claim, and detach or a retry clears it), never a live route |
| +// whose host is unclaimed. A record write that fails ambiguously therefore |
| +// unwinds both best-effort and refuses, while a record ALREADY at the key |
| +// (which, being account+app scoped, can only be this app's own) makes the |
| +// attach idempotent rather than a permanent 500. The reverse write order |
| +// would let two accounts each declare a record for one host and the loser |
| +// find out from the fleet. |
| +// |
| +// audit + bumpSerial live in here rather than in the doors, so both doors |
| +// get them, in the right place, for free: strictly after the write they |
| +// announce — and gated on an ACTUAL write (#151's changed-gating rule): a |
| +// re-attach that stored nothing new must not send every box in the fleet |
| +// converging onto a serial that names no change, nor bury the tenant's |
| +// audit log under no-ops. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "net/http" |
| + "os" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// doorRefusal is a refusal the calling door translates into its own shape — |
| +// a rendered message for the browser, a JSON error (or, for 404, the |
| +// family's byte-identical http.NotFound) for the CLI API. Msg is |
| +// member-facing and NEVER names another account: the 409 below is one |
| +// string whoever holds the host, including the caller. |
| +type doorRefusal struct { |
| + Status int |
| + Msg string |
| +} |
| + |
| +// attachResult is what the doors report back. FirstRoute distinguishes the |
| +// two mechanisms — true means a declared record was written (or found |
| +// already standing) and a real route is materializing, which is the |
| +// difference between "your domain serves the app the moment DNS points" and |
| +// "a new route is being created". Created is domains.AttachClaim's own |
| +// answer: this call brought the claim into existence (false on a re-attach, |
| +// which the claim writer treats as a converge or a no-op, never a refusal). |
| +type attachResult struct { |
| + Claim domains.Claim |
| + Created bool |
| + FirstRoute bool // a declared record backs this attach |
| + Channel string // channel the first-route record follows ("" for alias) |
| +} |
| + |
| +// attachDomain claims host for acct's app and, when the app has no route to |
| +// alias, declares the static route that becomes its first one. kindFlag and |
| +// channelFlag may both be "" — the kind is then inferred (alias if there is |
| +// a route, static otherwise) and the channel is the one the host's own |
| +// existing route follows, or failing that the app's sole shipped channel. |
| +func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal) { |
| + if ref := s.bucketRefusal(); ref != nil { |
| + return attachResult{}, ref |
| + } |
| + // Policy first: shape and platform space are decided before any write, |
| + // so a refused host never reaches a bucket key. Both checks are #151's |
| + // exported validation surface (internal/domains + deniedDomains), the |
| + // same pair the CLI API door runs — one validation spot, two doors. |
| + host, ok := domains.NormalizeAliasHost(host) |
| + if !ok { |
| + return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| + "not a valid hostname — attach a full hostname like tickets.example.com"} |
| + } |
| + if domains.HostDenied(host, s.deniedDomains()) { |
| + return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| + "that hostname is platform-managed"} |
| + } |
| + route, selfChannel, err := s.readyRouteHost(acct.Sqid, app, host) |
| + if err != nil { |
| + log.Printf("console: attach %s: registry: %v", host, err) |
| + return attachResult{}, &doorRefusal{http.StatusInternalServerError, |
| + "could not read this deployment's routes — try again in a moment"} |
| + } |
| + claim := domains.Claim{ |
| + Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: route, |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| + } |
| + |
| + // --- alias attach: the app already has a route to serve from --------- |
| + if route != "" { |
| + created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim) |
| + if err != nil { |
| + return attachResult{}, claimRefusal(host, err) |
| + } |
| + if changed { |
| + s.audit(acct, app, principal, "domain-attach", host+" → "+route) |
| + s.bumpSerial(ctx) |
| + } |
| + return attachResult{Claim: claim, Created: created}, nil |
| + } |
| + |
| + // --- first-route attach: this host becomes the app's primary -------- |
| + if kindFlag != "" && kindFlag != "static" { |
| + return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| + "an app with no running route can only attach a static domain — deploy first, then attach"} |
| + } |
| + channel, ref := s.attachChannel(ctx, acct.Sqid, app, channelFlag, selfChannel) |
| + if ref != nil { |
| + return attachResult{}, ref |
| + } |
| + created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim) |
| + if err != nil { |
| + return attachResult{}, claimRefusal(host, err) |
| + } |
| + rec := provision.Record{ |
| + Schema: 1, Host: host, App: app, Account: acct.Sqid, |
| + Channel: channel, Kind: "static", |
| + CreatedAt: time.Now().UTC(), CreatedBy: principal, |
| + } |
| + if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil { |
| + // A record already at this key is NOT a failure to undo. The key is |
| + // account+app scoped, so a create that lost the race lost it to this |
| + // app's own record — a retry after a half-failed attach, or a record |
| + // written for a host whose claim had been released. GetRecord |
| + // re-reads it and refuses a body that disagrees with its own path, |
| + // so a successful read is proof the record describes this app and |
| + // account; the attach is then idempotent, the claim stays (this |
| + // app's, whether this call minted it or an earlier one did), and the |
| + // truth reported back is the record's channel, not the one this call |
| + // would have written. audit + serial follow the write-gating rule: |
| + // only when the CLAIM half actually wrote — a fully-converged |
| + // re-attach (claim untouched, record standing) stores nothing and |
| + // announces nothing. |
| + if errors.Is(err, release.ErrCASConflict) { |
| + existing, gerr := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if gerr == nil && attachWrote(*existing) { |
| + if changed { |
| + s.audit(acct, app, principal, "domain-attach", host+" (first route, "+existing.Channel+", record already present)") |
| + s.bumpSerial(ctx) |
| + } |
| + return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel}, nil |
| + } |
| + // Unreadable (malformed, or a body that lies about its |
| + // identity) or written by a door other than this one. Not ours |
| + // to delete and not safe to write over, so the claim goes back |
| + // (only if this call minted it — a pre-existing claim is not |
| + // this failure's to unwind) and the member is told what stands |
| + // in the way — the fact of a record, and nothing about who |
| + // wrote it. |
| + log.Printf("console: attach %s: existing record is not this door's to adopt (read: %v)", host, gerr) |
| + s.rollbackAttach(ctx, acct, app, host, false, created) |
| + return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| + "a route record already exists for this host — detach it or pick another hostname"} |
| + } |
| + // Anything else is AMBIGUOUS: a timeout or a dropped connection can |
| + // come back as an error for a write that landed. Unwind both, in |
| + // detach's order (record, then claim), so the failure cannot leave a |
| + // record behind a released claim — a route materializing for a host |
| + // anyone may now claim. |
| + log.Printf("console: attach %s: record write: %v", host, err) |
| + s.rollbackAttach(ctx, acct, app, host, true, created) |
| + return attachResult{}, &doorRefusal{http.StatusInternalServerError, |
| + "could not declare the route for this domain — nothing was attached, try again"} |
| + } |
| + // The record wrote (a real change by definition), so this announces |
| + // regardless of whether the claim half was the no-op of a retry. |
| + s.audit(acct, app, principal, "domain-attach", host+" (first route, "+channel+")") |
| + s.bumpSerial(ctx) |
| + return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel}, nil |
| +} |
| + |
| +// detachDomain releases host's claim and, when the claim minted the app's |
| +// own route (empty Route — the first-route case), removes the declared |
| +// record first so the reconciler's removal phase drops the route. |
| +// |
| +// Every non-answer is one refusal: absent, another account's, or this |
| +// account's other app's all return the same 404, which the doors render as |
| +// http.NotFound — a detach must not be the probe that confirms a host is |
| +// claimed, or by whom. |
| +func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal { |
| + if ref := s.bucketRefusal(); ref != nil { |
| + return ref |
| + } |
| + notAttached := &doorRefusal{http.StatusNotFound, "not attached to this app"} |
| + // A host that is not even a hostname cannot be a claim key: refuse it |
| + // here rather than handing it to a store as a key segment. |
| + host, ok := domains.NormalizeAliasHost(host) |
| + if !ok { |
| + return notAttached |
| + } |
| + claim, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return notAttached |
| + } |
| + log.Printf("console: detach %s: claim read: %v", host, err) |
| + return &doorRefusal{http.StatusInternalServerError, |
| + "could not read this domain's claim — try again in a moment"} |
| + } |
| + if claim.Account != acct.Sqid || claim.App != app { |
| + return notAttached |
| + } |
| + if claim.Route == "" { |
| + // First-route: the record goes before the claim, the mirror of |
| + // attach. A record left behind by a released claim would keep a |
| + // route alive for a host anyone may now claim. |
| + if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil { |
| + log.Printf("console: detach %s: record delete: %v", host, err) |
| + return &doorRefusal{http.StatusInternalServerError, |
| + "could not remove this domain's route — it is still attached, try again"} |
| + } |
| + } |
| + // DetachClaim re-checks ownership before deleting (its own neutrality); |
| + // the read above already proved it, so removed=false here can only mean |
| + // a race this call lost — in which case nothing was written and nothing |
| + // announces. |
| + removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: detach %s: claim delete: %v", host, err) |
| + return &doorRefusal{http.StatusInternalServerError, |
| + "could not release this domain's claim — try again in a moment"} |
| + } |
| + if removed { |
| + s.audit(acct, app, principal, "domain-detach", host) |
| + s.bumpSerial(ctx) |
| + } |
| + return nil |
| +} |
| + |
| +// readyRouteHost answers "does this app already have a route to alias?" with |
| +// the host of the first one, or "" for none. The selection is |
| +// domains.firstReadyUpstream's, minus the upstream copy: only the HOST is |
| +// needed here, because nothing about the primary's upstream is copied — |
| +// the alias resolves to it at request time, box-side. |
| +// |
| +// An empty Registry path is "no routes", not an error: a console configured |
| +// without a registry (and every test that wants the first-route branch) has |
| +// nothing to alias, and a missing DB must not be reported as a route. |
| +// |
| +// attaching is the host being attached. It is never an alias candidate — a |
| +// claim whose route names its own host is a self-alias, which is not a thing |
| +// the box side can resolve — and its ready row is instead reported back as |
| +// selfChannel, the channel that row already follows. That case is real: an |
| +// operator-added custom-domain route (story.carlosframework.com, spec §7) |
| +// that no claim covers yet. Skipping it as an alias lands the attach on the |
| +// first-route branch, where the declared record describes the row that |
| +// already exists and #137's adopt-on-match transfers ownership with no box |
| +// command — the backfill, arriving through the member's own door — and |
| +// carrying its channel forward is what keeps that adoption from being a |
| +// silent repoint (provision.convergeOwned writes the record's channel onto |
| +// the row it adopts). |
| +func (s *Server) readyRouteHost(sqid, app, attaching string) (alias, selfChannel string, err error) { |
| + if s.cfg.Registry == "" { |
| + return "", "", nil |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + routes, err := reg.List() // host order |
| + if err != nil { |
| + return "", "", err |
| + } |
| + for _, rt := range routes { |
| + if rt.App != app || release.RouteAccount(rt) != sqid { |
| + continue |
| + } |
| + // A platform mint is not a source to alias — aliasing a mint is |
| + // self-reference, the same rule firstReadyUpstream carries — and a |
| + // route that is not ready has nothing to serve yet. |
| + if rt.Platform || rt.Status != "ready" { |
| + continue |
| + } |
| + if rt.Host == attaching { |
| + selfChannel = rt.Channel |
| + continue |
| + } |
| + if alias == "" { |
| + alias = rt.Host |
| + } |
| + } |
| + return alias, selfChannel, nil |
| +} |
| + |
| +// attachChannel resolves the channel a first-route record follows: the flag |
| +// when given, then the channel the host's own existing route already |
| +// follows, then the app's sole shipped channel. Nothing shipped and several |
| +// channels are both refusals rather than guesses — a record pointed at the |
| +// wrong channel serves the wrong build from a domain the member just |
| +// published. |
| +// |
| +// selfChannel (the backfill case above) takes precedence over the listing |
| +// for two reasons: the record is about to be adopted onto that live row, so |
| +// naming any other channel would REPOINT a running route the member did not |
| +// ask to move, and an app with two channels would otherwise be refused as |
| +// ambiguous for a domain whose channel was never in question. |
| +func (s *Server) attachChannel(ctx context.Context, sqid, app, channelFlag, selfChannel string) (string, *doorRefusal) { |
| + // Shape only, never existence: a channel a member is about to ship is a |
| + // legitimate target, but a malformed one becomes a bucket key segment |
| + // and a route pinned to nothing (release.ValidChannel is the same rail |
| + // the instance-create door puts on its own channel field). |
| + if channelFlag != "" { |
| + if !release.ValidChannel(channelFlag) { |
| + return "", &doorRefusal{http.StatusUnprocessableEntity, "invalid channel name"} |
| + } |
| + return channelFlag, nil |
| + } |
| + if selfChannel != "" && release.ValidChannel(selfChannel) { |
| + return selfChannel, nil |
| + } |
| + prefix := release.ChannelsPrefix(sqid, app) |
| + keys, err := s.cfg.Releases.List(ctx, prefix) |
| + if err != nil { |
| + log.Printf("console: attach: list channels %s/%s: %v", sqid, app, err) |
| + return "", &doorRefusal{http.StatusInternalServerError, |
| + "could not list this app's channels — try again in a moment"} |
| + } |
| + var names []string |
| + for _, k := range keys { |
| + name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + if !ok || name == "" { |
| + continue |
| + } |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + switch len(names) { |
| + case 0: |
| + return "", &doorRefusal{http.StatusUnprocessableEntity, |
| + "nothing shipped yet — carlos ship first"} |
| + case 1: |
| + return names[0], nil |
| + } |
| + return "", &doorRefusal{http.StatusUnprocessableEntity, |
| + "this app follows several channels (" + strings.Join(names, ", ") + |
| + ") — say which one this domain serves"} |
| +} |
| + |
| +// attachWrote reports whether rec is a record THIS door could have written: |
| +// the fixed shape of a first-route attach — kind static, no slug, and the |
| +// shared pool and unconstrained region (§3). It is the guard on the |
| +// idempotent conflict branch, and it is narrow on purpose. The record key |
| +// is host-scoped within an app, and an app with a custom manifest Domain |
| +// can have `carlos instances create` write an INSTANCE record at the very |
| +// same key; adopting one of those as "an attach that already happened" |
| +// would hand detach the authority to retire a tenant instance this door |
| +// never created. Anything that fails this test is left alone and refused. |
| +func attachWrote(rec provision.Record) bool { |
| + return rec.Kind == "static" && rec.Slug == "" && rec.Region == "" && rec.Pool == "" |
| +} |
| + |
| +// bucketRefusal is the "this console cannot answer at all" check both verbs |
| +// open with: a deployment with no bucket has nowhere to put a claim or a |
| +// record. Same 503 and the same sentence the instance API's gate uses |
| +// (apiinstances.go), so one deployment shape produces one answer wherever a |
| +// member meets it. |
| +func (s *Server) bucketRefusal() *doorRefusal { |
| + if s.cfg.Releases == nil { |
| + return &doorRefusal{http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — the console is running without one"} |
| + } |
| + return nil |
| +} |
| + |
| +// rollbackAttach unwinds a half-written first-route attach, best effort: |
| +// the bucket that just refused a write may refuse these too, and a member |
| +// waiting on a refusal must not also wait on a retry loop. dropRecord asks |
| +// for the record to go first — set when the record write's outcome is |
| +// UNKNOWN (a timeout can report failure for an object that landed), and |
| +// clear when the record is known to be someone else's business. dropClaim |
| +// is whether the claim is this failure's to release at all: only when THIS |
| +// call created it — deleting a claim an earlier attach minted would strand |
| +// a host the member still legitimately holds. DetachClaim is idempotent and |
| +// owner-checked, so a no-op costs one read. |
| +// |
| +// A rollback that itself fails leaves a claim with no record: inert (nothing |
| +// materializes from a claim alone), visible to its own owner in the app's |
| +// domain list, and clearable with detach. That is the failure this ordering |
| +// was chosen to have. |
| +func (s *Server) rollbackAttach(ctx context.Context, acct *Account, app, host string, dropRecord, dropClaim bool) { |
| + if dropRecord { |
| + if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil { |
| + log.Printf("console: attach %s: record rollback: %v", host, err) |
| + } |
| + } |
| + if !dropClaim { |
| + return |
| + } |
| + if _, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app); err != nil { |
| + log.Printf("console: attach %s: claim rollback: %v (claim is stranded — detach clears it)", host, err) |
| + } |
| +} |
| + |
| +// claimRefusal maps a lost claim write onto the door's answer. The 409 is |
| +// ONE string whoever holds the host: domains.ErrHostUnavailable already |
| +// refuses to name the holder, and the text here is its message verbatim — |
| +// the same bytes the CLI API door answers, so the two doors cannot be |
| +// diffed against each other to learn anything either one refuses to say. |
| +func claimRefusal(host string, err error) *doorRefusal { |
| + if errors.Is(err, domains.ErrHostUnavailable) { |
| + return &doorRefusal{http.StatusConflict, "hostname unavailable"} |
| + } |
| + log.Printf("console: attach %s: claim write: %v", host, err) |
| + return &doorRefusal{http.StatusInternalServerError, |
| + "could not claim this domain — try again in a moment"} |
| +} |
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go| new file mode 100644 |
| index 000000000..93b90940c |
| --- /dev/null |
| +++ b/internal/console/domainattach_test.go |
| @@ -0,0 +1,711 @@ |
| +package console |
| + |
| +// The attach/detach core (domainattach.go): the one place both doors — the |
| +// browser Settings form and the CLI API — decide what attaching a custom |
| +// domain to an app MEANS. The properties that matter, in the order they'd |
| +// hurt if lost: platform space is refused before anything is written, an |
| +// app with a ready route gets an alias claim and NO route record (a second |
| +// route row is the #129/#148 class this design exists to avoid), a lost |
| +// claim race is neutral even when the caller's own account holds it (a 409 |
| +// that named the holder would be a cross-account leak), the first-route |
| +// path writes the CLAIM BEFORE the record (uniqueness is decided before |
| +// anything else exists), a record write that fails takes the claim back |
| +// with it, and detach unwinds in the reverse order behind a 404 that |
| +// cannot tell "not attached" from "someone else's". |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// attachFixture is server_test.go's console pointed at an EMPTY world for |
| +// attach purposes: no registry path at all (so the ready-route lookup is |
| +// skipped and every test starts on the first-route branch) and the |
| +// deployment's apps domain set, which is what makes platform space |
| +// refusable. Tests that want an alias point cfg.Registry at attachRegistry. |
| +func attachFixture(t *testing.T) (*consoleFixture, *Account) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.Registry = "" |
| + fx.srv.cfg.AppsDomain = "oncarlos.com" |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if acct == nil { |
| + t.Fatalf("fixture account %s not found", fx.sqid) |
| + } |
| + return fx, acct |
| +} |
| + |
| +// attachRegistry builds a registry holding routes and returns its path. |
| +func attachRegistry(t *testing.T, routes ...registry.Route) string { |
| + t.Helper() |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := registry.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return path |
| +} |
| + |
| +// opStore records every store write in order — the only way to observe |
| +// "claim THEN record" (and detach's reverse) rather than merely "both |
| +// landed". failPut names one key whose Put fails, which is how the |
| +// half-failed attach is reproduced without a broken bucket. |
| +type opStore struct { |
| + release.Store |
| + ops []string |
| + // failPut names a key whose Put fails before it lands. landThenFail |
| + // makes the same key LAND and then report failure — the ambiguous |
| + // outcome a timeout produces, and the one a one-sided rollback leaks a |
| + // record through. |
| + failPut string |
| + landThenFail bool |
| +} |
| + |
| +func (o *opStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if o.failPut != "" && key == o.failPut { |
| + if o.landThenFail { |
| + o.ops = append(o.ops, "put-then-fail "+key) |
| + if _, err := o.Store.Put(ctx, key, body, ifMatch); err != nil { |
| + return "", err |
| + } |
| + return "", errors.New("opStore: timed out after the object landed") |
| + } |
| + o.ops = append(o.ops, "put-fail "+key) |
| + return "", errors.New("opStore: bucket unavailable") |
| + } |
| + o.ops = append(o.ops, "put "+key) |
| + return o.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (o *opStore) Delete(ctx context.Context, key string) error { |
| + o.ops = append(o.ops, "delete "+key) |
| + return o.Store.Delete(ctx, key) |
| +} |
| + |
| +// opIndex is where an op appears in the log, or -1. |
| +func (o *opStore) opIndex(op string) int { |
| + for i, got := range o.ops { |
| + if got == op { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +// seedClaim plants a pre-existing claim — the "someone already holds this |
| +// host" fixture. AttachClaim is the one claim writer left (#151), and |
| +// creating against an empty key is its create path. |
| +func seedClaim(t *testing.T, fx *consoleFixture, c domains.Claim) { |
| + t.Helper() |
| + created, _, err := domains.AttachClaim(context.Background(), fx.rel, c) |
| + if err != nil || !created { |
| + t.Fatalf("seed claim %s: created=%v err=%v", c.Host, created, err) |
| + } |
| +} |
| + |
| +// auditedOnce asserts exactly one audit row for the action, naming host — |
| +// the record a member (and an incident) reads the door's history from. |
| +func auditedOnce(t *testing.T, fx *consoleFixture, acct *Account, app, action, host string) { |
| + t.Helper() |
| + events, err := fx.st.Events(acct.ID, app, 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + for _, e := range events { |
| + if e.Action == action && strings.Contains(e.Detail, host) { |
| + n++ |
| + } |
| + } |
| + if n != 1 { |
| + t.Errorf("audit rows for %s %s = %d, want 1 (rows: %+v)", action, host, n, events) |
| + } |
| +} |
| + |
| +func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + // Three candidate rows in host order: a platform mint and a route that |
| + // is not ready come FIRST alphabetically, so picking the third proves |
| + // the filter rather than "picked the only one". |
| + fx.srv.cfg.Registry = attachRegistry(t, |
| + registry.Route{Host: "aaa." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready", Platform: true}, |
| + registry.Route{Host: "bbb." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "pending"}, |
| + registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + registry.Route{Host: "other." + fx.sqid + ".oncarlos.com", Kind: "static", App: "other", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + ) |
| + const host = "story.carlosframework.com" |
| + ops := &opStore{Store: fx.rel} |
| + fx.srv.cfg.Releases = ops |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach: refused %+v", ref) |
| + } |
| + if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want { |
| + t.Errorf("claim route = %q, want the app's first non-platform ready route %q", res.Claim.Route, want) |
| + } |
| + if res.FirstRoute { |
| + t.Error("FirstRoute = true for an app that already has a route to alias") |
| + } |
| + if res.Channel != "" { |
| + t.Errorf("Channel = %q, want empty for an alias attach", res.Channel) |
| + } |
| + got, err := domains.GetClaim(context.Background(), fx.rel, host) |
| + if err != nil { |
| + t.Fatalf("claim: %v", err) |
| + } |
| + if got.Schema != 1 || got.Host != host || got.Account != fx.sqid || got.App != "story" || |
| + got.Route != res.Claim.Route || got.CreatedBy != "paul@keymail.dev" { |
| + t.Errorf("stored claim = %+v, want the returned claim %+v", got, res.Claim) |
| + } |
| + keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(keys) != 0 { |
| + t.Errorf("records after an alias attach = %v, want none (the alias is derived box-side)", keys) |
| + } |
| + // The alias branch owes the same two side effects the first-route |
| + // branch does: both doors get them from here, or neither does. |
| + auditedOnce(t, fx, acct, "story", "domain-attach", host) |
| + claimPut := ops.opIndex("put " + domains.ClaimKey(host)) |
| + serial := ops.opIndex("put " + release.SerialKey) |
| + if claimPut < 0 || serial < claimPut { |
| + t.Errorf("ops %v: want the serial bumped after the claim it announces (claim=%d serial=%d)", ops.ops, claimPut, serial) |
| + } |
| +} |
| + |
| +// TestAttachNeverAliasesAHostToItself is the operator-added custom domain |
| +// (story.carlosframework.com, spec §7) arriving at the member's door: a |
| +// ready route for the very host being attached must not become the claim's |
| +// alias target — a claim naming its own host is a self-alias nothing can |
| +// resolve. The attach lands on the first-route branch instead, where the |
| +// declared record describes the row that already exists and #137's |
| +// adopt-on-match transfers ownership. |
| +func TestAttachNeverAliasesAHostToItself(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + fx.srv.cfg.Registry = attachRegistry(t, |
| + registry.Route{Host: host, Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + ) |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach: refused %+v", ref) |
| + } |
| + if res.Claim.Route != "" || !res.FirstRoute { |
| + t.Errorf("result = %+v, want a first-route attach with an empty claim route", res) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { |
| + t.Errorf("record: %v, want a declared record describing the existing route", err) |
| + } |
| +} |
| + |
| +// TestAttachBackfillTakesChannelFromTheExistingRoute is the spec-owner's |
| +// ruling: on the self-host backfill the record's channel comes from the row |
| +// that already serves the host, not from the app's channel listing. Two |
| +// things turn on it — #137's adoption converges the record's channel onto |
| +// that live row, so any other value SILENTLY REPOINTS a running operator |
| +// route, and an app with several channels would otherwise be refused as |
| +// ambiguous for a domain whose channel was never in question. |
| +func TestAttachBackfillTakesChannelFromTheExistingRoute(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + seedChannel(t, fx, "story", "stable", "v1") // ambiguous, if the listing decided |
| + const host = "story.carlosframework.com" |
| + fx.srv.cfg.Registry = attachRegistry(t, |
| + registry.Route{Host: host, Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + ) |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach: refused %+v, want the backfill to go through on the row's own channel", ref) |
| + } |
| + if res.Claim.Route != "" || !res.FirstRoute || res.Channel != "canary/live" { |
| + t.Errorf("result = %+v, want a first-route attach on canary/live with an empty claim route", res) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) |
| + if err != nil { |
| + t.Fatalf("record: %v", err) |
| + } |
| + if rec.Channel != "canary/live" { |
| + t.Errorf("record channel = %q, want canary/live — the channel the live route already follows", rec.Channel) |
| + } |
| + // An explicit flag still wins: repointing is allowed when it is asked for. |
| + const other = "docs.carlosframework.com" |
| + fx.srv.cfg.Registry = attachRegistry(t, |
| + registry.Route{Host: other, Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + ) |
| + res, ref = fx.srv.attachDomain(context.Background(), acct, "story", other, "", "stable", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach with -channel stable: refused %+v", ref) |
| + } |
| + if res.Channel != "stable" { |
| + t.Errorf("Channel = %q, want the explicitly named stable", res.Channel) |
| + } |
| +} |
| + |
| +func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + ops := &opStore{Store: fx.rel} |
| + fx.srv.cfg.Releases = ops |
| + |
| + const host = "story.carlosframework.com" |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach: refused %+v", ref) |
| + } |
| + if !res.FirstRoute { |
| + t.Error("FirstRoute = false for an app with no ready route") |
| + } |
| + if res.Channel != "canary/live" { |
| + t.Errorf("Channel = %q, want the app's sole shipped channel canary/live", res.Channel) |
| + } |
| + if res.Claim.Route != "" { |
| + t.Errorf("claim route = %q, want empty — this host IS its own primary", res.Claim.Route) |
| + } |
| + claim, err := domains.GetClaim(context.Background(), fx.rel, host) |
| + if err != nil { |
| + t.Fatalf("claim: %v", err) |
| + } |
| + if claim.Route != "" || claim.Account != fx.sqid || claim.App != "story" { |
| + t.Errorf("stored claim = %+v", claim) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) |
| + if err != nil { |
| + t.Fatalf("record: %v", err) |
| + } |
| + if rec.Schema != 1 || rec.Kind != "static" || rec.Channel != "canary/live" || |
| + rec.CreatedBy != "paul@keymail.dev" || rec.Pool != "" || rec.Region != "" { |
| + t.Errorf("record = %+v, want a schema-1 static record on canary/live in the shared pool", *rec) |
| + } |
| + claimPut := ops.opIndex("put " + domains.ClaimKey(host)) |
| + recPut := ops.opIndex("put " + provision.RecordKey(fx.sqid, "story", host)) |
| + if claimPut < 0 || recPut < 0 || claimPut > recPut { |
| + t.Errorf("write order %v: want the claim put before the record put (claim=%d record=%d)", ops.ops, claimPut, recPut) |
| + } |
| + // The two things the doors get for free by this living in the core: the |
| + // audit row, and the serial bump that makes the fleet converge in |
| + // seconds instead of at the hourly floor. |
| + auditedOnce(t, fx, acct, "story", "domain-attach", host) |
| + if serial := ops.opIndex("put " + release.SerialKey); serial < recPut { |
| + t.Errorf("ops %v: want the serial bumped AFTER the writes it announces (serial=%d record=%d)", ops.ops, serial, recPut) |
| + } |
| +} |
| + |
| +// TestAttachIdempotentWhenRecordAlreadyExists is the retry after a partial |
| +// attach (and the claim-released-but-record-kept shape): the record key is |
| +// account+app scoped, so a create that loses the race there lost it to this |
| +// app's own record. Refusing would make the host permanently unattachable — |
| +// the claim would roll back on every retry and detach would 404. |
| +func TestAttachIdempotentWhenRecordAlreadyExists(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + // A record left behind by an earlier attach, on a DIFFERENT channel so |
| + // the answer proves which one is reported. |
| + if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{ |
| + Schema: 1, Host: host, App: "story", Account: fx.sqid, Kind: "static", Channel: "stable", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach: refused %+v, want the idempotent success", ref) |
| + } |
| + if !res.FirstRoute || res.Channel != "stable" { |
| + t.Errorf("result = %+v, want FirstRoute with the EXISTING record's channel", res) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| + t.Errorf("claim: %v — the claim this call won must survive the idempotent path", err) |
| + } |
| + // And the second call is idempotent too (#151's same-owner contract): |
| + // claim and record both stand, nothing is written, Created is false. |
| + res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Errorf("second attach = %+v, want the idempotent success", ref) |
| + } else if res.Created { |
| + t.Error("second attach: Created = true, want false — the claim already existed") |
| + } |
| +} |
| + |
| +// TestAttachUnreadableExistingRecordRefuses422 is the other half: a record |
| +// at the key that cannot be read as this app's (malformed, or a body that |
| +// disagrees with its own path) is not ours to overwrite or delete. |
| +func TestAttachUnreadableExistingRecordRefuses422(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + if _, err := fx.rel.Put(context.Background(), |
| + provision.RecordKey(fx.sqid, "story", host), []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 { |
| + t.Fatalf("refusal = %+v, want 422 for an unreadable record at the key", ref) |
| + } |
| + if strings.Contains(ref.Msg, fx.sqid) { |
| + t.Errorf("msg %q leaks identity — the member learns a record exists, nothing more", ref.Msg) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestAttachInstanceRecordAtTheKeyIsNotAdopted is the narrow half of the |
| +// idempotent branch: an app with a custom manifest Domain can have `carlos |
| +// instances create` write an INSTANCE record at this very key. Treating one |
| +// of those as "an attach that already happened" would hand detach the |
| +// authority to retire a tenant instance this door never created. |
| +func TestAttachInstanceRecordAtTheKeyIsNotAdopted(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + // Kind "" is an instance record — what reconcile minted before Kind |
| + // existed, and what the instance door writes today. |
| + instance := provision.Record{ |
| + Schema: 1, Host: host, App: "story", Account: fx.sqid, |
| + Slug: "story", Channel: "stable", |
| + } |
| + if err := provision.CreateRecord(context.Background(), fx.rel, instance); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 { |
| + t.Fatalf("refusal = %+v, want 422 — an instance record is not an attach to adopt", ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) |
| + if err != nil { |
| + t.Fatalf("record: %v, want the instance record untouched", err) |
| + } |
| + if got.Kind != "" || got.Slug != "story" || got.Channel != "stable" { |
| + t.Errorf("record = %+v, want the instance record exactly as it was", *got) |
| + } |
| +} |
| + |
| +// TestAttachAmbiguousRecordFailureUnwindsBoth: a write that LANDS and then |
| +// reports failure (a timeout) must not leave the record behind the claim it |
| +// rolls back — that record would materialize a route for a host anyone may |
| +// now claim. |
| +func TestAttachAmbiguousRecordFailureUnwindsBoth(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host), landThenFail: true} |
| + fx.srv.cfg.Releases = ops |
| + |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 500 { |
| + t.Fatalf("refusal = %+v, want 500 for an ambiguous record write", ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("record after the rollback: %v, want it gone — a record outliving its claim is the leak", err) |
| + } |
| + recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)) |
| + claimDel := ops.opIndex("delete " + domains.ClaimKey(host)) |
| + if recDel < 0 || claimDel < 0 || recDel > claimDel { |
| + t.Errorf("ops %v: want the record dropped before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel) |
| + } |
| +} |
| + |
| +// TestAttachClaimWriteFailureIs500 pins the non-CAS fallback: only a lost |
| +// race is "hostname unavailable". |
| +func TestAttachClaimWriteFailureIs500(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + fx.srv.cfg.Releases = &opStore{Store: fx.rel, failPut: domains.ClaimKey(host)} |
| + |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 500 { |
| + t.Fatalf("refusal = %+v, want 500 for a claim write that failed for reasons other than a lost race", ref) |
| + } |
| + if ref.Msg == "hostname unavailable" { |
| + t.Error("a broken bucket must not read as a taken host") |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +func TestAttachWithNothingShippedRefused(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + const host = "story.carlosframework.com" |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 || ref.Msg != "nothing shipped yet — carlos ship first" { |
| + t.Fatalf("refusal = %+v, want 422 \"nothing shipped yet — carlos ship first\"", ref) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +func TestAttachInvalidChannelFlagRefused(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + for _, bad := range []string{"../escape", "a/b/c", "canary live"} { |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", bad, "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 { |
| + t.Fatalf("channel %q: refusal = %+v, want 422", bad, ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| +} |
| + |
| +// TestAttachWithoutBucketIs503: a console with no deployment bucket has |
| +// nowhere to put a claim, and says so rather than panicking on a nil store. |
| +func TestAttachWithoutBucketIs503(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + fx.srv.cfg.Releases = nil |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "story.carlosframework.com", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 503 { |
| + t.Errorf("attach refusal = %+v, want 503", ref) |
| + } |
| + if ref := fx.srv.detachDomain(context.Background(), acct, "story", "story.carlosframework.com", "paul@keymail.dev"); ref == nil || ref.Status != 503 { |
| + t.Errorf("detach refusal = %+v, want 503", ref) |
| + } |
| +} |
| + |
| +// TestAttachFirstRouteRollsBackClaimOnRecordFailure is the half-failed |
| +// attach: the claim landed, the record did not, and the host must be left |
| +// attachable again rather than claimed by an app with no route. |
| +func TestAttachFirstRouteRollsBackClaimOnRecordFailure(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host)} |
| + fx.srv.cfg.Releases = ops |
| + |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 500 { |
| + t.Fatalf("refusal = %+v, want a 500 for a record write that failed", ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + if ops.opIndex("delete "+domains.ClaimKey(host)) < 0 { |
| + t.Errorf("ops %v: want the claim deleted after the record write failed", ops.ops) |
| + } |
| +} |
| + |
| +func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + seedChannel(t, fx, "story", "stable", "v1") |
| + |
| + const host = "story.carlosframework.com" |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 { |
| + t.Fatalf("refusal = %+v, want 422 for two channels and no flag", ref) |
| + } |
| + for _, name := range []string{"canary/live", "stable"} { |
| + if !strings.Contains(ref.Msg, name) { |
| + t.Errorf("msg %q does not name channel %q — the member cannot pick one from it", ref.Msg, name) |
| + } |
| + } |
| + claimAbsent(t, fx, host) |
| + |
| + // The same attach with the flag supplied goes through. |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "stable", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach with -channel stable: refused %+v", ref) |
| + } |
| + if res.Channel != "stable" { |
| + t.Errorf("Channel = %q, want the named stable", res.Channel) |
| + } |
| +} |
| + |
| +func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + |
| + const host = "story.carlosframework.com" |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "instance", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 { |
| + t.Fatalf("refusal = %+v, want 422 for a proxied kind with no process to serve", ref) |
| + } |
| + if !strings.Contains(ref.Msg, "static") { |
| + t.Errorf("msg %q does not say what IS attachable", ref.Msg) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +func TestAttachHeldHostIsNeutral409(t *testing.T) { |
| + const host = "story.carlosframework.com" |
| + t.Run("another account", func(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}) |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" { |
| + t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\"", ref) |
| + } |
| + }) |
| + // This account's OTHER app holding it is the same refusal, byte for |
| + // byte: the message may never be the thing that tells a prober WHICH |
| + // owner stands in the way — GET is where you see your own claims. |
| + t.Run("own account, another app", func(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: acct.Sqid, App: "docs"}) |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" { |
| + t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\" for the caller's own other app too", ref) |
| + } |
| + }) |
| + // The SAME app re-attaching is not a refusal at all any more: #151's |
| + // claim writer treats it as a converge (titogo's router re-attaches on |
| + // every reload pass), and the core inherits that idempotency — Created |
| + // false, nothing re-audited when nothing was written. |
| + t.Run("same app is idempotent", func(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("first attach: refused %+v", ref) |
| + } |
| + if !res.Created { |
| + t.Error("first attach: Created = false, want true") |
| + } |
| + res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("re-attach: refused %+v, want the idempotent success", ref) |
| + } |
| + if res.Created { |
| + t.Error("re-attach: Created = true, want false — the claim already existed") |
| + } |
| + auditedOnce(t, fx, acct, "story", "domain-attach", host) |
| + }) |
| +} |
| + |
| +func TestAttachPlatformSpaceRefused(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + |
| + // "../control/serial" is the traversal shape (domainpolicy_test.go's |
| + // case, ported): a host that would walk out of the domains/ prefix must |
| + // die at the shape check, never reach a bucket key — claimAbsent can't |
| + // even ASK about it (the store refuses the key), which is the point. |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "../control/serial", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { |
| + t.Fatalf("attach traversal host: refusal = %+v, want 422", ref) |
| + } |
| + for _, host := range []string{"oncarlos.com", "story." + fx.sqid + ".oncarlos.com", "not a host"} { |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 422 { |
| + t.Fatalf("attach %q: refusal = %+v, want 422", host, ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| +} |
| + |
| +func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.carlosframework.com" |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("attach: refused %+v", ref) |
| + } |
| + ops := &opStore{Store: fx.rel} |
| + fx.srv.cfg.Releases = ops |
| + |
| + if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("detach: refused %+v", ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("record after detach: %v, want gone", err) |
| + } |
| + recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)) |
| + claimDel := ops.opIndex("delete " + domains.ClaimKey(host)) |
| + if recDel < 0 || claimDel < 0 || recDel > claimDel { |
| + t.Errorf("ops %v: want the record deleted before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel) |
| + } |
| + auditedOnce(t, fx, acct, "story", "domain-detach", host) |
| + if serial := ops.opIndex("put " + release.SerialKey); serial < claimDel { |
| + t.Errorf("ops %v: want the serial bumped after the deletes it announces (serial=%d claim=%d)", ops.ops, serial, claimDel) |
| + } |
| +} |
| + |
| +// TestDetachAliasLeavesRecordsAlone: an alias claim (Route set) never had a |
| +// record of its own, and detaching it must not delete at that key — the app |
| +// may hold a record there for a route this claim only pointed at. |
| +func TestDetachAliasLeavesRecordsAlone(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + const host = "echo.example.com" |
| + fx.srv.cfg.Registry = attachRegistry(t, |
| + registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + ) |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil || res.Claim.Route == "" { |
| + t.Fatalf("attach: %+v / %+v, want an alias claim", res, ref) |
| + } |
| + ops := &opStore{Store: fx.rel} |
| + fx.srv.cfg.Releases = ops |
| + if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("detach: refused %+v", ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + if i := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)); i >= 0 { |
| + t.Errorf("ops %v: an alias detach deleted a record key it never wrote", ops.ops) |
| + } |
| +} |
| + |
| +func TestDetachForeignClaimIs404(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + const host = "story.carlosframework.com" |
| + seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}) |
| + ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev") |
| + if ref == nil || ref.Status != 404 { |
| + t.Fatalf("refusal = %+v, want 404 for another account's claim", ref) |
| + } |
| + // The claim survives: a foreign detach must not be a delete door. |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| + t.Fatalf("claim after a foreign detach: %v, want it untouched", err) |
| + } |
| + // A claim held by this account for a DIFFERENT app is the same refusal. |
| + const other = "docs.carlosframework.com" |
| + seedClaim(t, fx, domains.Claim{Schema: 1, Host: other, Account: acct.Sqid, App: "docs"}) |
| + if ref := fx.srv.detachDomain(context.Background(), acct, "story", other, "paul@keymail.dev"); ref == nil || ref.Status != 404 { |
| + t.Fatalf("refusal = %+v, want 404 for this account's OTHER app's claim", ref) |
| + } |
| + // And a host nobody has claimed. |
| + if ref := fx.srv.detachDomain(context.Background(), acct, "story", "unclaimed.example.com", "paul@keymail.dev"); ref == nil || ref.Status != 404 { |
| + t.Fatalf("refusal = %+v, want 404 for an unclaimed host", ref) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 89f2898a3..362bf51f9 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -693,6 +693,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount) |
| + // Custom domain attach/detach (appdomains.go, #82): owner-only, no |
| + // passkey step-up (spec §6) — detach's guard is the typed-host confirm. |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach) |
| // Customer fleets (Task 9): the owner's own remote boxes and what's |
| // placed on them — member-readable, owner-gated for rotate/detach |
| @@ -2251,8 +2255,15 @@ func (s *Server) audit(acct *Account, app, addr, action, detail string) { |
| // it, because rewriting a different id there would re-home every platform |
| // domain the account owns. It also covers the accounts that predate this |
| // feature (the built-in ops account, and any minted while the bucket was |
| -// unreachable). Best-effort: failures are logged, never fail the page. |
| +// unreachable). Best-effort: failures are logged, never fail the page — which |
| +// a bucketless console (s.cfg.Releases == nil) is the extreme case of: there |
| +// is nothing to read OR backfill, so every appPage view on such a console |
| +// panicked here until #82 Task 5's Settings-without-a-bucket coverage caught |
| +// it; nil is now just another "nothing to do" the caller never sees. |
| func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) { |
| + if s.cfg.Releases == nil { |
| + return |
| + } |
| key := AccountManifestKey(acct.Sqid) |
| _, _, err := s.cfg.Releases.Get(ctx, key) |
| if err == nil { |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index a6db42984..adff2cacf 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -28,33 +28,53 @@ bucket keys and stays.</p> |
| {{end}} |
| <h2>Domains & TLS</h2> |
| -{{if .Detail.Domains}} |
| +{{if .Settings.Domains}} |
| <div class="tablewrap"> |
| <table> |
| <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr> |
| - {{range .Detail.Domains}} |
| + {{range $i, $d := .Settings.Domains}} |
| <tr> |
| - <td><code>{{.Domain}}</code></td> |
| - <td>{{.Type}}</td> |
| - <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td> |
| - <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| + <td><code>{{$d.Domain}}</code></td> |
| + <td>{{$d.Type}}</td> |
| + <td>{{if $d.DNS}}{{$d.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td> |
| + <td>{{if $d.TLS}}{{$d.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| </tr> |
| - {{if .Guidance}} |
| - <tr><td colspan="4" class="small">{{.Guidance}}</td></tr> |
| + {{if $d.Guidance}} |
| + <tr><td colspan="4" class="small">{{$d.Guidance}}</td></tr> |
| + {{end}} |
| + {{if and $.IsOwner $d.Claimed}} |
| + <tr><td colspan="4" class="small"> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/domains/detach" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$d.Domain}}"> |
| + <label class="visually-hidden" for="detach-confirm-{{$i}}">Type {{$d.Domain}} to confirm detaching it</label> |
| + <input type="text" id="detach-confirm-{{$i}}" name="confirm" |
| + placeholder="type {{$d.Domain}} to confirm" autocomplete="off" required> |
| + <button type="submit" class="btn-sm btn-danger">Detach</button> |
| + </form> |
| + </td></tr> |
| {{end}} |
| {{end}} |
| </table> |
| </div> |
| {{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}} |
| -<h3>Add a custom domain</h3> |
| -<p class="small">A custom domain is attached where the app runs — on its box, |
| -by an operator:</p> |
| -<pre><code>carlos add -host www.example.com -app {{.Detail.App}} -account {{.Account.Sqid}} \ |
| - -channel stable ...</code></pre> |
| -<p class="small">Then point the domain's A record at this deployment's edge; |
| -the certificate is minted on the first request and renews on its own. |
| -Attaching a domain from this page, without the box, is on the platform's |
| -roadmap.</p> |
| +{{if .IsOwner}} |
| +<h3>Attach a custom domain</h3> |
| +{{if .Settings.OnlyPlatformDomain}}<p class="small">This app already answers |
| +at its platform domain; attaching adds addresses, it never removes one.</p>{{end}} |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/domains/attach" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="attach-host">Domain</label> |
| + <input type="text" id="attach-host" name="host" placeholder="www.example.com" required> |
| + {{if .Settings.NeedsChannel}} |
| + <label class="visually-hidden" for="attach-channel">Channel</label> |
| + <input type="text" id="attach-channel" name="channel" placeholder="channel (e.g. stable)"> |
| + {{end}} |
| + <button type="submit" class="btn-sm">Attach domain</button> |
| +</form> |
| +<p class="small">Point the domain's A record at this deployment's edge; |
| +the certificate is minted on the first request and renews on its own.</p> |
| +{{end}} |
| {{if and .Settings .Settings.MismatchTarget}} |
| <h2>Account</h2> |
diff --git a/internal/console/view.go b/internal/console/view.go| index a5dfbe5f0..1bb2eedb2 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -71,6 +71,32 @@ func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 } |
| type DomainView struct { |
| Domain, Type, DNS, TLS string |
| Guidance string |
| + // Claimed is Settings-only (settingsDomains, appsettings.go): true when |
| + // this row corresponds to a domain claim this app holds — routed or not |
| + // — which is what the Settings page uses to decide whether a detach |
| + // form belongs beside the row. A plain domainView row (no claim looked |
| + // up) always carries the zero value. |
| + Claimed bool |
| +} |
| + |
| +// domainGuidance is the A-record instruction a custom host that is not |
| +// pointing at this deployment yet gets shown — shared by domainView (a |
| +// routed custom host) and claimDomainView (a claim with no route yet), so |
| +// the two rows never drift into different wording for the same instruction. |
| +func domainGuidance(host string, edgeIPs []string) string { |
| + target := "this deployment's edge IP" |
| + if len(edgeIPs) > 0 { |
| + target = strings.Join(edgeIPs, ", ") |
| + } |
| + return "Point an A record for " + host + " at " + target + ". Checks run every few minutes." |
| +} |
| + |
| +// claimDomainView folds a domain claim that has no colocated registry row |
| +// yet into its own Domains placeholder row — an alias claim (which never |
| +// gets a route row; see domainattach.go) or a first-route claim before the |
| +// provisioning reconciler has materialized its route. |
| +func claimDomainView(host string, edgeIPs []string) DomainView { |
| + return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, edgeIPs)} |
| } |
| // domainView folds one registry route into its Domains row. edgeIPs is the |
| @@ -99,11 +125,7 @@ func domainView(rt registry.Route, edgeIPs []string) DomainView { |
| // Guidance only for custom + non-pointing: platform domains are ours to |
| // fix, and a pointing custom domain needs no pointing instructions. |
| if !rt.Platform && rt.DNSStatus != "pointing" { |
| - target := "this deployment's edge IP" |
| - if len(edgeIPs) > 0 { |
| - target = strings.Join(edgeIPs, ", ") |
| - } |
| - dv.Guidance = "Point an A record for " + rt.Host + " at " + target + ". Checks run every few minutes." |
| + dv.Guidance = domainGuidance(rt.Host, edgeIPs) |
| } |
| return dv |
| } |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 5a5ad8404..f9193a80d 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -1718,6 +1718,114 @@ func TestReconcileNeverRemovesUnadoptedRoute(t *testing.T) { |
| } |
| } |
| +// TestReconcileAdoptsStaticStoryRoute tests the backfill golden path: |
| +// a declared static record adopts the story-shaped route that was |
| +// hand-added before records existed. This is issue #82 backfill path. |
| +func TestReconcileAdoptsStaticStoryRoute(t *testing.T) { |
| + s := dirStore(t) |
| + // Create a different account for story (id=3, to avoid collision with the tito account) |
| + storyAcctSqid := writeAccountManifest(t, s, 3, "Story") |
| + // Create an app manifest for story |
| + if err := PutManifest(ctx, s, storyAcctSqid, "story", Manifest{ |
| + Schema: 1, App: "story", Domain: "carlosframework.com", Seed: []string{}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Pre-Upsert the operator-added shape: a static route as it existed |
| + // before records were written to the bucket |
| + handAdded := registry.Route{ |
| + Host: "story.carlosframework.com", Kind: "static", |
| + App: "story", Account: storyAcctSqid, Channel: "canary/live", |
| + Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + |
| + // Write the matching declared record |
| + rec := Record{ |
| + Schema: 1, Host: "story.carlosframework.com", App: "story", |
| + Account: storyAcctSqid, Channel: "canary/live", Kind: "static", |
| + CreatedAt: time.Now(), |
| + } |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Run Reconcile with an unconstrained region/pool (matches the static route) |
| + cfg := ReconcileConfig{DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Assert the route is now Provisioned |
| + got := *mustGet(t, reg, "story.carlosframework.com") |
| + if !got.Provisioned { |
| + t.Fatal("the matching static route was not adopted") |
| + } |
| + |
| + // Static routes should have no Socket or DBPath |
| + if got.Socket != "" || got.DBPath != "" { |
| + t.Errorf("a static route derived filesystem paths: socket %q db %q", got.Socket, got.DBPath) |
| + } |
| + |
| + // Static routes should not be hibernating |
| + if got.Hibernate { |
| + t.Error("a static route was armed for hibernation — it has no process to wake") |
| + } |
| +} |
| + |
| +// Companion negative: a static record does NOT adopt an instance route. |
| +// The kind must match for adoption to happen. |
| +func TestReconcileRefusesAdoptInstanceRouteForStaticRecord(t *testing.T) { |
| + s := dirStore(t) |
| + // Create a different account for story (id=3) |
| + storyAcctSqid := writeAccountManifest(t, s, 3, "Story") |
| + // Create an app manifest for story |
| + if err := PutManifest(ctx, s, storyAcctSqid, "story", Manifest{ |
| + Schema: 1, App: "story", Domain: "carlosframework.com", Seed: []string{}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Pre-Upsert a route with Kind: "instance" (not "static") |
| + handAdded := registry.Route{ |
| + Host: "story.carlosframework.com", Kind: "instance", |
| + Socket: "/run/carlos/story.carlosframework.com.sock", |
| + DBPath: "/data/story.carlosframework.com.db", |
| + App: "story", Account: storyAcctSqid, Channel: "canary/live", |
| + Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "story.carlosframework.com") |
| + |
| + // Write the matching declared record WITH Kind: "static" |
| + rec := Record{ |
| + Schema: 1, Host: "story.carlosframework.com", App: "story", |
| + Account: storyAcctSqid, Channel: "canary/live", Kind: "static", |
| + CreatedAt: time.Now(), |
| + } |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Run Reconcile |
| + cfg := ReconcileConfig{DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Assert the route was NOT adopted (Provisioned still false) |
| + after := *mustGet(t, reg, "story.carlosframework.com") |
| + if after.Provisioned { |
| + t.Error("a kind-mismatched route was adopted — removal authority granted over a route the record does not describe") |
| + } |
| + |
| + // Verify the route was not modified at all |
| + if after != before { |
| + t.Errorf("a mismatched route was modified: before %+v after %+v", before, after) |
| + } |
| +} |
| + |
| // TestRouteForCarriesAddrsAndHealth is Task 3's mint-side proof: a record |
| // with an explicit multi-upstream Addrs set mints a route whose Addrs is the |
| // comma-joined string and whose Health carries a fully-materialized default |