carlos / platform Public

cli: domains attach -wildcard/-catchall flags, list delegation columns

runDomainsAttach gains -wildcard/-catchall bool flags, sent to the
console only when named on the command line (fs.Visit) so a bare
attach cannot clear a capability the caller never mentioned — the
same tri-state discipline apps place's -target already uses. 422
refusals (missing CNAME record, no delegation zone, catchall without
wildcard) print verbatim via apiErrorFromBody's existing json-first
decode. runDomainsList adds WILDCARD/CATCHALL/DELEGATION columns
("yes"/blank, "-" when the door omits delegation_status).

apiSession.domainAttach's signature grows two *bool parameters;
existing call sites updated to pass nil,nil.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6e06314f6c82788bfd84caa93df13bf795ba5c3e parent 2472b54
3 files changed, +215 −37
  • apimode.go +30 −16
  • domains_cli.go +46 −7
  • domains_cli_test.go +139 −14
diff --git a/apimode.go b/apimode.go
index 53fa244c0..5f4b00840 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1690,28 +1690,42 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
// CertExpiresAt are the box-stamped reading columns and stay their zero
// value ("" / 0) for a claim no live box has reported on yet ("pending"),
// the same absent-stays-absent convention status.AliasReading uses.
+// 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.
type apiDomainRecord 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"`
+ 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"`
+ Wildcard bool `json:"wildcard"`
+ Catchall bool `json:"catchall"`
+ DelegationStatus string `json:"delegation_status,omitempty"`
}
// 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). created is the caller's own answer — false on a re-
-// attach that changed nothing or only repointed the route — and
-// resolvedRoute is always the route the claim actually carries, whatever
-// this call sent: a caller that omitted -route learns what the server
-// defaulted it to. A 409 (another account or app already holds the
-// hostname) surfaces as "hostname unavailable" verbatim, apiErrorFromBody's
-// json-first decode doing the work — the door never says who holds it.
-func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) {
+// instance record). wildcard/catchall are the Task 5 capability flips — nil
+// means "leave unchanged" and is never sent on the wire (omitempty), so a
+// bare attach cannot clear a capability the caller never mentioned; the
+// caller (runDomainsAttach) is responsible for turning a bare, unset CLI
+// flag into nil rather than a pointer to false. created is the caller's own
+// answer — false on a re-attach that changed nothing or only repointed the
+// route — and resolvedRoute is always the route the claim actually carries,
+// whatever this call sent: a caller that omitted -route learns what the
+// server defaulted it to. A 409 (another account or app already holds the
+// 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) {
in := struct {
- Host string `json:"host"`
- Route string `json:"route,omitempty"`
- }{host, route}
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ Wildcard *bool `json:"wildcard,omitempty"`
+ Catchall *bool `json:"catchall,omitempty"`
+ }{host, route, wildcard, catchall}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in)
if err != nil {
return false, "", err
diff --git a/domains_cli.go b/domains_cli.go
index 4e5330923..b1841e93d 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -18,15 +18,17 @@ import (
const domainsUsage = `usage: carlos domains <attach|detach|list> [flags]
- attach -app <a> [-route <r>] [flags] <hostname>
+ attach -app <a> [-route <r>] [-wildcard] [-catchall] [flags] <hostname>
claim a customer's hostname for the app, pointed at
-route (default: the app's single instance record, if it
- has exactly one — otherwise -route is required)
+ has exactly one — otherwise -route is required).
+ -wildcard/-catchall are capability flips: naming either
+ one changes it, an unset flag leaves it as it was.
detach -app <a> [flags] <hostname>
release a hostname claim
list -app <a> [flags]
this app's hostname claims, joined to the fleet's own
- readings sweep (DNS/CERT-EXPIRES)
+ readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION)
-account <a> account (sqid or name; default "ops")
-account-id <id> account sqid, matched as a sqid ONLY
@@ -38,7 +40,7 @@ route, and writes it. The hostname comes live on the box that owns the route
at its next domains pass.
`
-const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
+const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-wildcard] [-catchall] [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
`
const domainsDetachUsage = `usage: carlos domains detach -app <a> [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
@@ -71,6 +73,8 @@ func runDomainsAttach(args []string) {
fs := flag.NewFlagSet("domains attach", flag.ExitOnError)
app := fs.String("app", "", "app the hostname belongs to")
route := fs.String("route", "", "route hostname to attach to")
+ wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)")
+ catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs -wildcard)")
consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)")
acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
@@ -79,10 +83,27 @@ func runDomainsAttach(args []string) {
if *app == "" || host == "" || fs.NArg() > 1 {
log.Fatal(domainsAttachUsage)
}
+ // -wildcard/-catchall are tri-state, the same reason apps place's
+ // -target is: an omitted flag must leave the claim's current capability
+ // untouched, and the bool value alone cannot distinguish "not named"
+ // from "named, false" (an explicit -catchall=false is a real request to
+ // turn it off). fs.Visit reports only flags actually present on the
+ // command line.
+ var wildcardArg, catchallArg *bool
+ fs.Visit(func(f *flag.Flag) {
+ switch f.Name {
+ case "wildcard":
+ v := *wildcard
+ wildcardArg = &v
+ case "catchall":
+ v := *catchall
+ catchallArg = &v
+ }
+ })
sess := requireAPISession("attach", *consoleFlag)
printUsing(stdout, sess)
created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw),
- *app, host, strings.TrimSpace(*route))
+ *app, host, strings.TrimSpace(*route), wildcardArg, catchallArg)
if err != nil {
log.Fatalf("domains attach: %v", err)
}
@@ -134,7 +155,12 @@ func runDomainsList(args []string) {
log.Fatalf("domains list: %v", err)
}
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES")
+ // WILDCARD/CATCHALL print "yes" or "" (the route table's -hibernate/HIB
+ // convention, cli.go), never "no" — a blank cell reads as "not set"
+ // 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")
for _, r := range recs {
dns := r.DNSStatus
if dns == "" {
@@ -144,7 +170,20 @@ func runDomainsList(args []string) {
if r.CertExpiresAt > 0 {
cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02")
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert)
+ wildcard := ""
+ if r.Wildcard {
+ wildcard = "yes"
+ }
+ catchall := ""
+ if r.Catchall {
+ catchall = "yes"
+ }
+ delegation := r.DelegationStatus
+ 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)
}
w.Flush()
}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index 485b06046..bc3a318cc 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")
+ "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", "")
+ "tickets.example.com", "", nil, nil)
if err != nil {
t.Fatalf("domainAttach: %v", err)
}
@@ -86,12 +86,73 @@ func TestAPISessionDomainAttachConflict(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "")
+ _, _, 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)
}
}
+// TestAPISessionDomainAttachWildcardRefusal: a 422 (the wildcard/catchall
+// gate) surfaces its own message verbatim too — the CNAME record the caller
+// needs to create, exactly as the door wrote it, not summarized.
+func TestAPISessionDomainAttachWildcardRefusal(t *testing.T) {
+ const msg = "create CNAME _acme-challenge.tickets.example.com -> _acme-challenge.tickets.example.com.acme.oncarlos.com, then retry"
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ json.NewEncoder(w).Encode(map[string]any{"error": msg})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ yes := true
+ _, _, 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)
+ }
+}
+
+// TestAPISessionDomainAttachWildcardCatchallWireShape: wildcard/catchall
+// pointers marshal only when non-nil — nil never reaches the wire at all
+// (omitempty on a nil *bool), so a caller that names neither sends a body
+// indistinguishable from the pre-Task-5 shape.
+func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) {
+ var gotBody map[string]json.RawMessage
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "route": "go.tito.io", "created": true})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ // 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 {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if _, ok := gotBody["wildcard"]; ok {
+ t.Fatalf("body = %v, want no wildcard key when nil", gotBody)
+ }
+ if _, ok := gotBody["catchall"]; ok {
+ t.Fatalf("body = %v, want no catchall key when nil", gotBody)
+ }
+
+ // wildcard named true, catchall named false: both keys present, with
+ // 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 {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ var w, c bool
+ if err := json.Unmarshal(gotBody["wildcard"], &w); err != nil || !w {
+ t.Fatalf("wildcard = %v (err %v), want true", gotBody["wildcard"], err)
+ }
+ if err := json.Unmarshal(gotBody["catchall"], &c); err != nil || c {
+ t.Fatalf("catchall = %v (err %v), want false", gotBody["catchall"], err)
+ }
+}
+
// TestAPISessionDomainDetach: domainDetach DELETEs with the host
// path-escaped and decodes removed.
func TestAPISessionDomainDetach(t *testing.T) {
@@ -185,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", ""); 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 {
@@ -203,7 +264,10 @@ func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) {
type fakeDomainsConsole struct {
ts *httptest.Server
lastAttachPath string
- lastAttachBody struct{ Host, Route string }
+ lastAttachBody struct {
+ Host, Route string
+ Wildcard, Catchall *bool
+ }
attachCreated bool
lastDetachPath string
detachRemoved bool
@@ -231,8 +295,10 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
json.NewEncoder(w).Encode(map[string]any{
"domains": []map[string]any{
{"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
- "dns_status": "pointing", "cert_expires_at": 1799999999},
- {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ "dns_status": "pointing", "cert_expires_at": 1799999999,
+ "wildcard": true, "catchall": true, "delegation_status": "delegated"},
+ {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending",
+ "wildcard": false, "catchall": false},
},
})
default:
@@ -324,7 +390,7 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
out := buf.String()
for _, want := range []string{
- "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES",
+ "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION",
"tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
"new.example.com", "pending",
} {
@@ -332,17 +398,76 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
t.Fatalf("stdout missing %q:\n%s", want, out)
}
}
- // The pending row's dns/cert columns must read "-", not a stray zero or
- // blank cell that would misalign the table.
lines := strings.Split(out, "\n")
- var pendingLine string
+ var wildcardLine, plainLine string
for _, l := range lines {
+ if strings.Contains(l, "tickets.example.com") {
+ wildcardLine = l
+ }
if strings.Contains(l, "new.example.com") {
- pendingLine = l
+ plainLine = l
}
}
- if pendingLine == "" || !strings.Contains(pendingLine, "-") {
- t.Fatalf("pending row = %q, want a \"-\" placeholder for the absent columns", pendingLine)
+ // tickets.example.com carries wildcard+catchall+delegated: "yes" twice
+ // and "delegated" once, in the last three (tabwriter-padded) columns.
+ 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)
+ }
+ // The pending row's dns/cert columns read "-", and wildcard/catchall are
+ // blank (not "yes") with delegation "-" — the door omits the field.
+ 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 strings.Contains(plainLine, "yes") {
+ t.Fatalf("pending row = %q, want no wildcard/catchall yes when both are false", plainLine)
+ }
+}
+
+// TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed: -wildcard/-catchall are
+// tri-state on the wire, the way -target already is on `apps place` —
+// fs.Visit is what makes "not named" and "named false" distinguishable, and
+// this is the regression that would silently break if runDomainsAttach ever
+// switched to reading *wildcard/*catchall unconditionally.
+func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) {
+ fc := domainsCLIFixture(t)
+
+ // Bare attach: neither flag named, neither pointer sent.
+ runDomains([]string{"attach", "-app", "tito", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard != nil || fc.lastAttachBody.Catchall != nil {
+ t.Fatalf("bare attach body = %+v, want both wildcard and catchall unset", fc.lastAttachBody)
+ }
+
+ // -wildcard named (implicitly true): wildcard sent true, catchall still
+ // unset — naming one flag must not touch the other.
+ runDomains([]string{"attach", "-app", "tito", "-wildcard", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard {
+ t.Fatalf("wildcard body = %+v, want wildcard=true", fc.lastAttachBody)
+ }
+ if fc.lastAttachBody.Catchall != nil {
+ t.Fatalf("wildcard body = %+v, want catchall still unset", fc.lastAttachBody)
+ }
+
+ // -wildcard=false named explicitly: a real request to turn it off, sent
+ // as a present false, not treated as "not named".
+ runDomains([]string{"attach", "-app", "tito", "-wildcard=false", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard == nil || *fc.lastAttachBody.Wildcard {
+ t.Fatalf("wildcard=false body = %+v, want wildcard present and false", fc.lastAttachBody)
+ }
+
+ // Both named: both sent.
+ runDomains([]string{"attach", "-app", "tito", "-wildcard", "-catchall", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard {
+ t.Fatalf("both body = %+v, want wildcard=true", fc.lastAttachBody)
+ }
+ if fc.lastAttachBody.Catchall == nil || !*fc.lastAttachBody.Catchall {
+ t.Fatalf("both body = %+v, want catchall=true", fc.lastAttachBody)
}
}