// `carlos routes` — what routes this app has, and what each one is bound // to (issue #64). // // API MODE ONLY, and that is the point of the verb existing at all. The // facts it prints live in a box's registry, and reading them used to mean // sqlite3 on the box: `carlos route` is a setter, `carlos apps` answers // from the console's app list, and `carlos instances list` answers for // instance RECORDS — so a hand-added route had no reader anywhere. Since a // route's ENVIRONMENT binding decides which sealed secrets it is // delivered, "which routes is this credential going to" was a question // only answerable with box access: the one step of titogo's whole secrets // procedure that needed it. // // The box-side listing is `carlos instances` (cli.go), which carries the // same columns for an operator already standing on the box. A second // box-local verb answering the same question would be two commands to keep // in step for no gain. package main import ( "context" "flag" "fmt" "log" "strings" ) const routesUsage = `usage: carlos routes --app [--account ] [--match ] [--console ] Lists this app's routes on the console's deployment: where each one sends traffic, which release channel it follows, and which named config environment it is bound to — the binding that decides which secrets the route's process is delivered (see carlos env, carlos secrets). A hostname whose traffic is split by leading path segment lists those prefixes indented beneath it, naming the route each one is served by. Read-only: prefix maps are published wholesale by the platform that owns them, so there is no carlos verb that writes one. Signed-in only (carlos auth login). On a box, the local listing is "carlos instances".` func runRoutes(args []string) { fs := flag.NewFlagSet("routes", flag.ExitOnError) app := appFlag(fs, "app whose routes to list") match := fs.String("match", "", "only hosts containing this substring") consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") fs.Usage = flagUsage(fs, routesUsage) fs.Parse(args) sess := requireAPISession("routes", *consoleFlag) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, routesUsage) rows, paths, err := sess.routeList(context.Background(), acct, app2) if err != nil { log.Fatalf("routes: %v", err) } shown := 0 w := newTable(stdout) fmt.Fprintln(w, "HOST\tKIND\tUPSTREAM\tCHANNEL\tENVIRONMENT\tREPLICATION\tSTATUS\tHEALTH\tDB") for _, r := range rows { if *match != "" && !strings.Contains(r.Host, *match) { continue } shown++ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, dashIfEmpty(r.Kind), dashIfEmpty(r.Upstream), dashIfEmpty(r.Channel), // An empty ENVIRONMENT is a real answer — the route takes the // app's default bundle — so it prints as a dash rather than a // blank cell a reader would take for missing data. dashIfEmpty(r.Environment), // REPLICATION reads the same way: empty is the pre-ladder // default (change-driven ~1s sync), which is what every route // carried until `carlos route --replication` gave the column a // door (discussion #10). It prints "default" rather than a // dash, because unlike ENVIRONMENT the empty value has a name // and a behaviour worth saying out loud — this is the column // where "no answer" cost one tenant 97% of a day's replica // traffic. replicationCell(r.Replication), dashIfEmpty(r.Status), dashIfEmpty(r.Health), dbCell(r.DBBytes)) // Path routes indented under the hostname whose traffic they split. // A flipped prefix set has to be visible from the command operators // already use, or a migration is invisible until something breaks. // // KIND says "path" and UPSTREAM names the TARGET ROUTE rather than // an address, because that is all a path row is: a pointer, whose // channel, environment, replication and health are the target's own // row's, listed wherever that route appears. Dashes rather than // blanks in the remaining columns, for the reason ENVIRONMENT prints // one above — a blank cell reads as data this client failed to fetch. // // Read-only, here and everywhere in the CLI: the tenant's own // platform publishes these wholesale and is their source of truth, // and a second writer would drift from it the first time it was used. for _, p := range paths { if p.Host != r.Host { continue } fmt.Fprintf(w, " /%s\tpath\t%s\t—\t—\t—\t—\t—\t—\n", p.Prefix, dashIfEmpty(p.Target)) } } w.Flush() if shown == 0 { mutedf(stdout, "no routes for %s on this deployment's box", app2) } // Said every time, not only when the list is empty: this console reads // its OWN box's registry, so on a multi-box fleet a short answer is not // a complete one and must not be read as one (internal/fleetapi, #145). mutedf(stdout, "(routes on this deployment's own box; other boxes in a fleet are not listed)") } // replicationCell renders the replication rung. The empty column is named // rather than dashed: it is the default every route was on before the ladder // had a door, it behaves exactly like "stream", and a reader deciding // whether a large low-write database is costing more than it should needs to // see that stated, not inferred from a blank. func replicationCell(class string) string { if class == "" { return "default" } return class } // dashIfEmpty renders an empty cell as an em dash, so a column that is // legitimately blank reads as an answer rather than as missing data. func dashIfEmpty(s string) string { if s == "" { return "—" } return s } // dbCell renders a route's database size, a dash (this table's own em dash) // when no box has measured it. func dbCell(n *int64) string { if n == nil { return "—" } return humanBytes(n) }