carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The `carlos domains` verb family (Task 10, issue #151): attach a
2// customer's own hostname to an app's route, detach it, or list what an app
3// currently holds — the CLI front over internal/console/apidomains.go, the
4// same instance-gated console door titogo's router speaks with its
5// CARLOS_INSTANCE_TOKEN. API mode only, the same as `carlos instances
6// create/list/delete`: a claim is a console-authorized write, and there is
7// no store-mode equivalent.
8package main
9
10import (
11 "context"
12 "flag"
13 "fmt"
14 "log"
15 "os"
16 "sort"
17 "strings"
18 "time"
19)
20
21const domainsUsage = `usage: carlos domains <attach|verify|detach|list|paths> [flags]
22
23 attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [flags] <hostname>
24 claim a customer's hostname for the app, pointed at
25 --route (default: the app's single instance record, if it
26 has exactly one — otherwise --route is required).
27 --wildcard/--catchall are capability flips: naming either
28 one changes it, an unset flag leaves it as it was.
29 --platform attaches a hostname in this deployment's own
30 domains. Platform operators only.
31 Where the deployment requires proof of control, the
32 hostname is not served until you add the TXT record
33 attach prints.
34 attach --app <a> '*.<parent>'
35 an instance catch-all: each of the app's instances also
36 answers at <label>.<parent>, where <label> is its host's
37 first label. <parent> must already be attached to an app
38 in this account. Signed-in people only; takes no other
39 flag. Detach it with: detach --app <a> '*.<parent>'.
40 verify --app <a> [--channel <c>] [flags] <hostname>
41 check the TXT record and, once it is there, make the
42 hostname live. --channel is for a hostname that will be
43 the app's first route, when the app follows several.
44 detach --app <a> [flags] <hostname>
45 release a hostname claim
46 list --app <a> [flags]
47 this app's hostname claims, joined to the fleet's own
48 readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION)
49 paths --app <a> [flags] <hostname>
50 the leading path segments this hostname's traffic is split
51 by, as PUBLISHED. Read-only: the platform that owns the
52 hostname republishes the whole map, so there is no verb
53 here that writes one.
54
55 --account <a> account (sqid or name; default "ops")
56 --account-id <id> account sqid, matched as a sqid ONLY
57 --console <origin> console to act through (default: the only logged-in one)
58
59Signed-in only (carlos auth login): a claim is a console-authorized write —
60the console checks the hostname against the deploy's deny list, resolves the
61route, and writes it. The hostname comes live on the box that owns the route
62at its next domains pass.
63`
64
65const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
66`
67
68const domainsVerifyUsage = `usage: carlos domains verify --app <a> [--channel <c>] [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
69`
70
71const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
72`
73
74const domainsListUsage = `usage: carlos domains list --app <a> [--account <a>] [--account-id <id>] [--console <origin>]
75`
76
77const domainsPathsUsage = `usage: carlos domains paths --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
78`
79
80// runDomains dispatches the domains sub-verbs. attach/detach/list are
81// positional words, not flags, and must never reach fs.Parse — the same
82// shape runInstances uses for its own sub-verbs.
83func runDomains(args []string) {
84 if len(args) > 0 {
85 switch args[0] {
86 case "attach":
87 runDomainsAttach(args[1:])
88 return
89 case "verify":
90 runDomainsVerify(args[1:])
91 return
92 case "detach":
93 runDomainsDetach(args[1:])
94 return
95 case "list":
96 runDomainsList(args[1:])
97 return
98 case "paths":
99 runDomainsPaths(args[1:])
100 return
101 }
102 }
103 log.Fatal(domainsUsage)
104}
105
106func runDomainsAttach(args []string) {
107 fs := flag.NewFlagSet("domains attach", flag.ExitOnError)
108 app := appFlag(fs, "app the hostname belongs to")
109 route := fs.String("route", "", "route hostname to attach to")
110 wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)")
111 catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs --wildcard)")
112 platform := fs.Bool("platform", false, "attach a hostname in this deployment's own domains (platform operators only)")
113 consoleFlag := consoleOption(fs, "console to attach through (default: the only logged-in one)")
114 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
115 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
116 fs.Usage = flagUsage(fs, domainsAttachUsage)
117 fs.Parse(args)
118 host := strings.TrimSpace(fs.Arg(0))
119 if host == "" || fs.NArg() > 1 {
120 log.Fatal(domainsAttachUsage)
121 }
122 // --wildcard/--catchall are tri-state, the same reason apps place's
123 // --target is: an omitted flag must leave the claim's current capability
124 // untouched, and the bool value alone cannot distinguish "not named"
125 // from "named, false" (an explicit --catchall=false is a real request to
126 // turn it off). fs.Visit reports only flags actually present on the
127 // command line.
128 var wildcardArg, catchallArg *bool
129 fs.Visit(func(f *flag.Flag) {
130 switch f.Name {
131 case "wildcard":
132 v := *wildcard
133 wildcardArg = &v
134 case "catchall":
135 v := *catchall
136 catchallArg = &v
137 }
138 })
139 sess := requireAPISession("attach", *consoleFlag)
140 printUsing(stdout, sess)
141 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
142 app2 := resolvedApp(sess, acct, *app, domainsAttachUsage)
143 ctx := context.Background()
144 res, err := sess.postDomainAttachFull(ctx, acct, app2, domainAttachIn{
145 Host: host, Route: strings.TrimSpace(*route),
146 Wildcard: wildcardArg, Catchall: catchallArg, Platform: *platform,
147 })
148 if err != nil {
149 log.Fatalf("domains attach: %v", err)
150 }
151 created, resolvedRoute := res.Created, res.Route
152 if !res.Verified {
153 // Stored, not served (#215). Said first, before any "attached"
154 // wording a member would read as "it's live".
155 fmt.Fprintf(stdout, "%s %s -> %s, waiting for you to prove you control it\n", colorFor(stdout).warn("reserved"), host, resolvedRoute)
156 if !printDomainDNSLine(ctx, sess, acct, app2, host) {
157 printDomainVerifyHint(res.Verify, app2, host)
158 return
159 }
160 printDomainVerifyHint(nil, app2, host)
161 return
162 }
163 if strings.HasPrefix(host, "*.") {
164 // An instance catch-all: no route of its own to report.
165 resolvedRoute = "<label>." + strings.TrimPrefix(host, "*.") + " for each instance"
166 }
167 if created {
168 fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
169 } else {
170 fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute)
171 }
172 printDomainDNSLine(ctx, sess, acct, app2, host)
173}
174
175// printDomainDNSLine completes the job `carlos domains attach` promises
176// (spec §5: "Attach output always ends with the DNS records to create and
177// the current stamp — the command completes the job or says exactly what is
178// left"): it re-fetches the domain list and prints the just-attached host's
179// own DNS stamp, so the member never needs a second command (let alone a
180// console visit) to learn whether the domain is live. Degrades quietly on a
181// list failure or a missing row (an old console, or a claim the sweep has
182// not reached yet with no readable answer) — the attach line above already
183// reported the thing that matters (whether the write happened), so a DNS
184// line this call cannot back up is skipped rather than guessed at.
185//
186// It reports whether it printed anything, so a caller with something it must
187// not leave unsaid (the proof record) can say it itself when this could not.
188func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) bool {
189 domains, err := sess.domainList(ctx, acct, app)
190 if err != nil {
191 return false
192 }
193 for _, d := range domains {
194 if d.Host != host {
195 continue
196 }
197 // The door's own derived state and sentence, so the attach line and
198 // `carlos domains list` cannot describe the same hostname two ways.
199 // Falls back to the pre-state wording for an older console.
200 if d.State != "" {
201 if d.Detail != "" {
202 fmt.Fprintf(stdout, "STATE: %s — %s\n", d.State, d.Detail)
203 } else {
204 fmt.Fprintf(stdout, "STATE: %s\n", d.State)
205 }
206 printDomainRecords(d.Records)
207 return true
208 }
209 if d.DNSStatus == "pointing" {
210 fmt.Fprintln(stdout, "DNS: pointing")
211 return true
212 }
213 dns := d.DNSStatus
214 if dns == "" {
215 dns = "pending"
216 }
217 if d.Guidance != "" {
218 fmt.Fprintf(stdout, "DNS: %s — %s\n", dns, d.Guidance)
219 } else {
220 fmt.Fprintf(stdout, "DNS: %s\n", dns)
221 }
222 return true
223 }
224 return false
225}
226
227// printDomainVerifyHint says exactly which record proves control of host
228// and what to run once it exists. A console that sent no record (it cannot
229// happen with verified false, but an answer is not a promise) gets the
230// command alone.
231func printDomainVerifyHint(v *apiDomainDNSRecord, app, host string) {
232 if v != nil && v.Name != "" {
233 fmt.Fprintf(stdout, "Add: TXT %s -> %s\n", v.Name, v.Value)
234 }
235 fmt.Fprintf(stdout, "Then: carlos domains verify --app %s %s\n", app, host)
236}
237
238// runDomainsVerify proves control of an attached hostname (#215). It is the
239// attach door again rather than a door of its own: repeating an attach is
240// what verifies a pending claim, because the door looks the TXT record up
241// whenever the claim is still pending. This verb only works out WHICH
242// attach to repeat — the claim's own route, or a first route for a claim
243// that has none — so the member does not have to remember how it was made.
244func runDomainsVerify(args []string) {
245 fs := flag.NewFlagSet("domains verify", flag.ExitOnError)
246 app := appFlag(fs, "app the hostname belongs to")
247 channel := fs.String("channel", "", "channel a first-route hostname serves (only needed when the app follows several)")
248 consoleFlag := consoleOption(fs, "console to verify through (default: the only logged-in one)")
249 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
250 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
251 fs.Usage = flagUsage(fs, domainsVerifyUsage)
252 fs.Parse(args)
253 host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(fs.Arg(0)), "."))
254 if host == "" || fs.NArg() > 1 {
255 log.Fatal(domainsVerifyUsage)
256 }
257 sess := requireAPISession("verify", *consoleFlag)
258 printUsing(stdout, sess)
259 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
260 app2 := resolvedApp(sess, acct, *app, domainsVerifyUsage)
261 ctx := context.Background()
262 recs, err := sess.domainList(ctx, acct, app2)
263 if err != nil {
264 log.Fatalf("domains verify: %v", err)
265 }
266 var cur *apiDomainRecord
267 for i := range recs {
268 if recs[i].Host == host {
269 cur = &recs[i]
270 }
271 }
272 if cur == nil {
273 log.Fatalf("domains verify: %s is not attached to %s — carlos domains attach it first", host, app2)
274 }
275 if domainVerified(*cur) {
276 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("verified"), host)
277 printDomainDNSLine(ctx, sess, acct, app2, host)
278 return
279 }
280 kind, route := "", cur.Route
281 if route == "" || route == host {
282 kind, route = "static", ""
283 }
284 res, err := sess.domainAttachFull(ctx, acct, app2, host, route, kind, strings.TrimSpace(*channel), nil, nil)
285 if err != nil {
286 log.Fatalf("domains verify: %v", err)
287 }
288 if !res.Verified {
289 fmt.Fprintf(stdout, "%s %s: the TXT record is not there yet (DNS changes can take a few minutes to show)\n", colorFor(stdout).warn("not verified"), host)
290 printDomainVerifyHint(res.Verify, app2, host)
291 os.Exit(1)
292 }
293 fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("verified"), host, res.Route)
294 printDomainDNSLine(ctx, sess, acct, app2, host)
295}
296
297func runDomainsDetach(args []string) {
298 fs := flag.NewFlagSet("domains detach", flag.ExitOnError)
299 app := appFlag(fs, "app the hostname belongs to")
300 consoleFlag := consoleOption(fs, "console to detach through (default: the only logged-in one)")
301 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
302 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
303 fs.Usage = flagUsage(fs, domainsDetachUsage)
304 fs.Parse(args)
305 host := strings.TrimSpace(fs.Arg(0))
306 if host == "" || fs.NArg() > 1 {
307 log.Fatal(domainsDetachUsage)
308 }
309 sess := requireAPISession("detach", *consoleFlag)
310 printUsing(stdout, sess)
311 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
312 app2 := resolvedApp(sess, acct, *app, domainsDetachUsage)
313 removed, err := sess.domainDetach(context.Background(), acct, app2, host)
314 if err != nil {
315 log.Fatalf("domains detach: %v", err)
316 }
317 if removed {
318 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("detached"), host)
319 } else {
320 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("was not attached"), host)
321 }
322}
323
324func runDomainsList(args []string) {
325 fs := flag.NewFlagSet("domains list", flag.ExitOnError)
326 app := appFlag(fs, "app whose hostname claims to list")
327 consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)")
328 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
329 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
330 fs.Usage = flagUsage(fs, domainsListUsage)
331 fs.Parse(args)
332 sess := requireAPISession("list", *consoleFlag)
333 printUsing(stdout, sess)
334 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
335 app2 := resolvedApp(sess, acct, *app, domainsListUsage)
336 recs, err := sess.domainList(context.Background(), acct, app2)
337 if err != nil {
338 log.Fatalf("domains list: %v", err)
339 }
340 w := newTable(stdout)
341 // WILDCARD/CATCHALL print "yes" or "" (the route table's --hibernate/HIB
342 // convention, cli.go), never "no" — a blank cell reads as "not set"
343 // without shouting it. DELEGATION is a tri-state the door itself never
344 // fabricates: "-" covers both "no delegation zone configured" and
345 // "wildcard is off", the two cases the door omits the field for.
346 // GUIDANCE (last — it is the long free-text column) is the door's own
347 // point-your-DNS-here instruction, "-" when the domain is pointing.
348 // STATE is the door's derived lifecycle word (#299) and is the column to
349 // read. The older STATUS only ever meant "some box reported something",
350 // so it says "live" for a hostname pointing at the wrong place; it is
351 // kept off the table entirely rather than shown beside a word that
352 // contradicts it. A console that predates state sends "", and the row
353 // falls back to that legacy status so an older deployment still prints
354 // something true.
355 fmt.Fprintln(w, "HOST\tROUTE\tSTATE\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE")
356 for _, r := range recs {
357 state := r.State
358 if state == "" {
359 state = r.Status
360 }
361 dns := r.DNSStatus
362 if dns == "" {
363 dns = "-"
364 }
365 cert := "-"
366 if r.CertExpiresAt > 0 {
367 cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02")
368 }
369 wildcard := ""
370 if r.Wildcard {
371 wildcard = "yes"
372 }
373 catchall := ""
374 if r.Catchall {
375 catchall = "yes"
376 }
377 // An instance catch-all has no single route: each instance serves
378 // at <label>.<parent>. Its derived names follow it, marked with the
379 // catch-all they came from.
380 route := r.Route
381 switch {
382 case r.Instances:
383 // Which labels it holds against other accounts: all of them once
384 // the parent is proved in DNS, else only existing instances'.
385 route, catchall = "per-instance", "instances"
386 if r.Reserves != "" {
387 catchall += "(reserves:" + r.Reserves + ")"
388 }
389 case r.Via != "":
390 catchall = "via " + r.Via
391 }
392 delegation := r.DelegationStatus
393 if delegation == "" {
394 delegation = "-"
395 }
396 guidance := r.Guidance
397 if guidance == "" {
398 guidance = "-"
399 }
400 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
401 r.Host, route, state, dns, cert, wildcard, catchall, delegation, guidance)
402 }
403 w.Flush()
404}
405
406// runDomainsPaths prints one hostname's PUBLISHED prefix map — the leading
407// path segments its traffic is split by, and the route each is served by.
408//
409// This is desired state, and the distinction from `carlos routes` is the whole
410// reason the verb exists. `carlos routes` prints the route_paths rows a box
411// derived, and the reconcile pass silently drops any entry whose target is not
412// a route of the same account on that box. So a prefix that appears HERE and
413// not there is a flip the platform published and the deployment refused —
414// which is the state an operator debugs, and which no other surface shows. The
415// footer says so rather than leaving the reader to know it.
416//
417// READ-ONLY, and there is deliberately no sibling that writes: the platform
418// that owns the hostname regenerates this map from its own database and
419// republishes it wholesale, so a CLI writer would be a second writer of state
420// whose source of truth is elsewhere, and the two would drift the first time
421// anyone used it.
422func runDomainsPaths(args []string) {
423 fs := flag.NewFlagSet("domains paths", flag.ExitOnError)
424 app := appFlag(fs, "app the hostname belongs to")
425 consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)")
426 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
427 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
428 fs.Usage = flagUsage(fs, domainsPathsUsage)
429 fs.Parse(args)
430 host := strings.TrimSpace(fs.Arg(0))
431 if host == "" || fs.NArg() > 1 {
432 log.Fatal(domainsPathsUsage)
433 }
434 sess := requireAPISession("paths", *consoleFlag)
435 printUsing(stdout, sess)
436 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
437 app2 := resolvedApp(sess, acct, *app, domainsPathsUsage)
438 paths, err := sess.domainPaths(context.Background(), acct, app2, host)
439 if err != nil {
440 log.Fatalf("domains paths: %v", err)
441 }
442 if len(paths) == 0 {
443 mutedf(stdout, "%s splits no traffic by path", host)
444 return
445 }
446 // Sorted by prefix: a map has no order, and a listing that reshuffles
447 // between calls is one nobody can diff.
448 prefixes := make([]string, 0, len(paths))
449 for p := range paths {
450 prefixes = append(prefixes, p)
451 }
452 sort.Strings(prefixes)
453 w := newTable(stdout)
454 fmt.Fprintln(w, "PREFIX\tTARGET")
455 for _, p := range prefixes {
456 fmt.Fprintf(w, "/%s\t%s\n", p, paths[p])
457 }
458 w.Flush()
459 mutedf(stdout, "(published desired state; \"carlos routes --app %s\" shows which of these a box is actually serving)", app2)
460}
461
462// printDomainRecords prints the records the member must create, grouped by
463// alternative. Nothing is printed when the door sent none — a pointing
464// hostname has nothing left to do, and an older console sends no records at
465// all.
466//
467// The "or" between groups is the whole point of the set marker: with steering
468// the platform offers a CNAME *or* a set of A records, and a member who
469// created both would publish a CNAME beside A records for one name, which is
470// illegal. Required records print separately because they are needed
471// whichever alternative is chosen.
472func printDomainRecords(recs []apiDomainDNSRecord) {
473 if len(recs) == 0 {
474 return
475 }
476 var order []string
477 groups := map[string][]apiDomainDNSRecord{}
478 var required []apiDomainDNSRecord
479 for _, r := range recs {
480 if r.Required {
481 required = append(required, r)
482 continue
483 }
484 if _, seen := groups[r.Set]; !seen {
485 order = append(order, r.Set)
486 }
487 groups[r.Set] = append(groups[r.Set], r)
488 }
489 for i, set := range order {
490 lead := "Create:"
491 if i > 0 {
492 lead = " or:"
493 }
494 for j, r := range groups[set] {
495 if j > 0 {
496 lead = " "
497 }
498 fmt.Fprintf(stdout, "%s %s %s -> %s (TTL %d)\n", lead, r.Type, r.Name, r.Value, r.TTL)
499 }
500 }
501 for _, r := range required {
502 fmt.Fprintf(stdout, " Also: %s %s -> %s (TTL %d)\n", r.Type, r.Name, r.Value, r.TTL)
503 }
504}
505