| 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. |
| 8 | package main |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "flag" |
| 13 | "fmt" |
| 14 | "log" |
| 15 | "os" |
| 16 | "sort" |
| 17 | "strings" |
| 18 | "time" |
| 19 | ) |
| 20 | |
| 21 | const 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 | |
| 59 | Signed-in only (carlos auth login): a claim is a console-authorized write — |
| 60 | the console checks the hostname against the deploy's deny list, resolves the |
| 61 | route, and writes it. The hostname comes live on the box that owns the route |
| 62 | at its next domains pass. |
| 63 | ` |
| 64 | |
| 65 | const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| 66 | ` |
| 67 | |
| 68 | const domainsVerifyUsage = `usage: carlos domains verify --app <a> [--channel <c>] [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| 69 | ` |
| 70 | |
| 71 | const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| 72 | ` |
| 73 | |
| 74 | const domainsListUsage = `usage: carlos domains list --app <a> [--account <a>] [--account-id <id>] [--console <origin>] |
| 75 | ` |
| 76 | |
| 77 | const 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. |
| 83 | func 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 | |
| 106 | func 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. |
| 188 | func 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. |
| 231 | func 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. |
| 244 | func 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 | |
| 297 | func 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 | |
| 324 | func 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. |
| 422 | func 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. |
| 472 | func 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 | |