| 1 | // `carlos routes` — what routes this app has, and what each one is bound |
| 2 | // to (issue #64). |
| 3 | // |
| 4 | // API MODE ONLY, and that is the point of the verb existing at all. The |
| 5 | // facts it prints live in a box's registry, and reading them used to mean |
| 6 | // sqlite3 on the box: `carlos route` is a setter, `carlos apps` answers |
| 7 | // from the console's app list, and `carlos instances list` answers for |
| 8 | // instance RECORDS — so a hand-added route had no reader anywhere. Since a |
| 9 | // route's ENVIRONMENT binding decides which sealed secrets it is |
| 10 | // delivered, "which routes is this credential going to" was a question |
| 11 | // only answerable with box access: the one step of titogo's whole secrets |
| 12 | // procedure that needed it. |
| 13 | // |
| 14 | // The box-side listing is `carlos instances` (cli.go), which carries the |
| 15 | // same columns for an operator already standing on the box. A second |
| 16 | // box-local verb answering the same question would be two commands to keep |
| 17 | // in step for no gain. |
| 18 | package main |
| 19 | |
| 20 | import ( |
| 21 | "context" |
| 22 | "flag" |
| 23 | "fmt" |
| 24 | "log" |
| 25 | "strings" |
| 26 | ) |
| 27 | |
| 28 | const routesUsage = `usage: carlos routes --app <a> [--account <sqid|name>] [--match <substr>] [--console <origin>] |
| 29 | |
| 30 | Lists this app's routes on the console's deployment: where each one sends |
| 31 | traffic, which release channel it follows, and which named config |
| 32 | environment it is bound to — the binding that decides which secrets the |
| 33 | route's process is delivered (see carlos env, carlos secrets). |
| 34 | |
| 35 | A hostname whose traffic is split by leading path segment lists those |
| 36 | prefixes indented beneath it, naming the route each one is served by. |
| 37 | Read-only: prefix maps are published wholesale by the platform that owns |
| 38 | them, so there is no carlos verb that writes one. |
| 39 | |
| 40 | Signed-in only (carlos auth login). On a box, the local listing is |
| 41 | "carlos instances".` |
| 42 | |
| 43 | func runRoutes(args []string) { |
| 44 | fs := flag.NewFlagSet("routes", flag.ExitOnError) |
| 45 | app := appFlag(fs, "app whose routes to list") |
| 46 | match := fs.String("match", "", "only hosts containing this substring") |
| 47 | consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 48 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 49 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 50 | fs.Usage = flagUsage(fs, routesUsage) |
| 51 | fs.Parse(args) |
| 52 | sess := requireAPISession("routes", *consoleFlag) |
| 53 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 54 | app2 := resolvedApp(sess, acct, *app, routesUsage) |
| 55 | rows, paths, err := sess.routeList(context.Background(), acct, app2) |
| 56 | if err != nil { |
| 57 | log.Fatalf("routes: %v", err) |
| 58 | } |
| 59 | shown := 0 |
| 60 | w := newTable(stdout) |
| 61 | fmt.Fprintln(w, "HOST\tKIND\tUPSTREAM\tCHANNEL\tENVIRONMENT\tREPLICATION\tSTATUS\tHEALTH\tDB") |
| 62 | for _, r := range rows { |
| 63 | if *match != "" && !strings.Contains(r.Host, *match) { |
| 64 | continue |
| 65 | } |
| 66 | shown++ |
| 67 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| 68 | r.Host, dashIfEmpty(r.Kind), dashIfEmpty(r.Upstream), dashIfEmpty(r.Channel), |
| 69 | // An empty ENVIRONMENT is a real answer — the route takes the |
| 70 | // app's default bundle — so it prints as a dash rather than a |
| 71 | // blank cell a reader would take for missing data. |
| 72 | dashIfEmpty(r.Environment), |
| 73 | // REPLICATION reads the same way: empty is the pre-ladder |
| 74 | // default (change-driven ~1s sync), which is what every route |
| 75 | // carried until `carlos route --replication` gave the column a |
| 76 | // door (discussion #10). It prints "default" rather than a |
| 77 | // dash, because unlike ENVIRONMENT the empty value has a name |
| 78 | // and a behaviour worth saying out loud — this is the column |
| 79 | // where "no answer" cost one tenant 97% of a day's replica |
| 80 | // traffic. |
| 81 | replicationCell(r.Replication), |
| 82 | dashIfEmpty(r.Status), dashIfEmpty(r.Health), dbCell(r.DBBytes)) |
| 83 | // Path routes indented under the hostname whose traffic they split. |
| 84 | // A flipped prefix set has to be visible from the command operators |
| 85 | // already use, or a migration is invisible until something breaks. |
| 86 | // |
| 87 | // KIND says "path" and UPSTREAM names the TARGET ROUTE rather than |
| 88 | // an address, because that is all a path row is: a pointer, whose |
| 89 | // channel, environment, replication and health are the target's own |
| 90 | // row's, listed wherever that route appears. Dashes rather than |
| 91 | // blanks in the remaining columns, for the reason ENVIRONMENT prints |
| 92 | // one above — a blank cell reads as data this client failed to fetch. |
| 93 | // |
| 94 | // Read-only, here and everywhere in the CLI: the tenant's own |
| 95 | // platform publishes these wholesale and is their source of truth, |
| 96 | // and a second writer would drift from it the first time it was used. |
| 97 | for _, p := range paths { |
| 98 | if p.Host != r.Host { |
| 99 | continue |
| 100 | } |
| 101 | fmt.Fprintf(w, " /%s\tpath\t%s\t—\t—\t—\t—\t—\t—\n", p.Prefix, dashIfEmpty(p.Target)) |
| 102 | } |
| 103 | } |
| 104 | w.Flush() |
| 105 | if shown == 0 { |
| 106 | mutedf(stdout, "no routes for %s on this deployment's box", app2) |
| 107 | } |
| 108 | // Said every time, not only when the list is empty: this console reads |
| 109 | // its OWN box's registry, so on a multi-box fleet a short answer is not |
| 110 | // a complete one and must not be read as one (internal/fleetapi, #145). |
| 111 | mutedf(stdout, "(routes on this deployment's own box; other boxes in a fleet are not listed)") |
| 112 | } |
| 113 | |
| 114 | // replicationCell renders the replication rung. The empty column is named |
| 115 | // rather than dashed: it is the default every route was on before the ladder |
| 116 | // had a door, it behaves exactly like "stream", and a reader deciding |
| 117 | // whether a large low-write database is costing more than it should needs to |
| 118 | // see that stated, not inferred from a blank. |
| 119 | func replicationCell(class string) string { |
| 120 | if class == "" { |
| 121 | return "default" |
| 122 | } |
| 123 | return class |
| 124 | } |
| 125 | |
| 126 | // dashIfEmpty renders an empty cell as an em dash, so a column that is |
| 127 | // legitimately blank reads as an answer rather than as missing data. |
| 128 | func dashIfEmpty(s string) string { |
| 129 | if s == "" { |
| 130 | return "—" |
| 131 | } |
| 132 | return s |
| 133 | } |
| 134 | |
| 135 | // dbCell renders a route's database size, a dash (this table's own em dash) |
| 136 | // when no box has measured it. |
| 137 | func dbCell(n *int64) string { |
| 138 | if n == nil { |
| 139 | return "—" |
| 140 | } |
| 141 | return humanBytes(n) |
| 142 | } |
| 143 | |