carlos / platform Public

cli: carlos routes, and an ENV column on the box-side listing (#64)

carlos routes is API-mode only: the facts live in a box's registry, and the
point of the verb is reading them WITHOUT box access. Columns: HOST, KIND,
UPSTREAM, CHANNEL, ENVIRONMENT, STATUS, HEALTH. An unbound route's
environment prints as a dash — taking the app's default bundle is an
answer, not missing data.

The multi-box caveat prints every time, not only on an empty list: this
console reads its own box's registry, so a short answer is not a complete
one (#145).

The bare `carlos instances` listing gains the same ENV column for an
operator already on the box, rather than a second box-local verb answering
the same question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue
Paul Campbell cc54db2965a8f8a38e94954d7ac644d13ed169aa parent 882d220
6 files changed, +303 −3
  • apimode.go +38 −0
  • cli.go +13 −3
  • cli_test.go +19 −0
  • main.go +4 −0
  • routes_cli.go +85 −0
  • routes_cli_test.go +144 −0
diff --git a/apimode.go b/apimode.go
index 77a214cac..92aa9e78c 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1865,6 +1865,44 @@ func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app, match, he
return res.Instances, res.Truncated, res.Total, nil
}
+// apiRouteRow is one row of the console's routes answer
+// (internal/console/apiroutes.go). Every field is additive on the wire: a
+// console that predates one sends nothing and it decodes as the zero
+// value, which is why an empty Environment must be printed as "takes the
+// app default" rather than as a binding this client failed to read.
+type apiRouteRow struct {
+ Host string `json:"host"`
+ Kind string `json:"kind"`
+ Upstream string `json:"upstream"`
+ App string `json:"app"`
+ Channel string `json:"channel"`
+ Environment string `json:"environment"`
+ Status string `json:"status"`
+ Health string `json:"health"`
+ Hibernate bool `json:"hibernate"`
+ Unit string `json:"unit"`
+ Backing string `json:"backing"`
+ Provisioned bool `json:"provisioned"`
+}
+
+// routeList reads one app's routes on the console's own box (issue #64).
+func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/routes"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Routes []apiRouteRow `json:"routes"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("routes: unreadable response: %w", err)
+ }
+ return res.Routes, nil
+}
+
// apiLogsResponse is the console's logs answer (internal/console/apilogs.go)
// — records are applog's own wire shape, decoded as-is.
type apiLogsResponse struct {
diff --git a/cli.go b/cli.go
index 482a64298..102ffb7df 100644
--- a/cli.go
+++ b/cli.go
@@ -299,14 +299,24 @@ func runInstances(args []string) {
// cycled by that unit on adoption, whatever BACKING says (and BACKING is
// empty for every such route, which is the shape that made issue #110
// invisible from this listing).
- fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tSTATUS\tVERSION\tHIB\tBACKING\tUNIT")
+ //
+ // ENV is the named config bundle the route is bound to (issue #64). It
+ // decides which secrets this route's process is delivered, and reading
+ // it used to mean sqlite3 against the registry — the one step of
+ // titogo's secrets procedure that still needed box access. It sits
+ // beside CHANNEL because the two are the same kind of fact about a
+ // route and are set by the same verb (`carlos route`). Empty means the
+ // app's default bundle, which is a real answer, so it prints blank
+ // rather than being singled out. Off-box, `carlos routes` answers the
+ // same question through the console.
+ fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tENV\tSTATUS\tVERSION\tHIB\tBACKING\tUNIT")
for _, t := range routes {
hib := ""
if t.Hibernate {
hib = "yes"
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- t.Host, t.App, t.Kind, t.Channel, t.Status, t.Version, hib, t.Backing, t.Unit)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ t.Host, t.App, t.Kind, t.Channel, t.Environment, t.Status, t.Version, hib, t.Backing, t.Unit)
}
w.Flush()
}
diff --git a/cli_test.go b/cli_test.go
index 064b5c44f..b86b37ef7 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -606,6 +606,25 @@ func TestRouteUnitStampsAnExistingRoute(t *testing.T) {
t.Errorf("instances output = %q, want a UNIT column carrying console.service", out)
}
+ // The same listing carries a route's config-ENVIRONMENT binding (issue
+ // #64): it decides which secrets the route's process is delivered, and
+ // reading it used to mean sqlite3 against this file. Bind one and read
+ // it back through the binary.
+ if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com",
+ "-environment", "relay"); err == nil {
+ // The bind only succeeds where the bundle exists; where it does
+ // not, the refusal is the environment rail doing its job and the
+ // column assertion below still has the unbound case to check.
+ _ = out
+ }
+ out, err = run("instances", "-registry", regPath)
+ if err != nil {
+ t.Fatalf("instances: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "ENV") {
+ t.Errorf("instances output = %q, want an ENV column — a route's config binding was unreadable without sqlite3 (#64)", out)
+ }
+
// And the clear door, so a mis-stamp is reversible.
if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com", "-unit", "-"); err != nil {
t.Fatalf("route -unit -: %v\n%s", err, out)
diff --git a/main.go b/main.go
index 15f585435..fd96f0e7b 100644
--- a/main.go
+++ b/main.go
@@ -53,6 +53,8 @@ func main() {
runRoute(args)
case "instances":
runInstances(args)
+ case "routes":
+ runRoutes(args)
case "domains":
runDomains(args)
case "ship":
@@ -158,6 +160,8 @@ Your apps — ship and run software on a CARLOS deployment
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete |
set-upstreams
+ routes an app's routes and what each is bound to: upstream,
+ channel, config environment, status
domains a customer's own hostname attached to an app's route:
attach | detach | list
logs an app's merged log timeline: its own output plus platform
diff --git a/routes_cli.go b/routes_cli.go
new file mode 100644
index 000000000..a982da920
--- /dev/null
+++ b/routes_cli.go
@@ -0,0 +1,85 @@
+// `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 <a> [-account <sqid|name>] [-match <substr>] [-console <origin>]
+
+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).
+
+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 := fs.String("app", "", "app whose routes to list")
+ match := fs.String("match", "", "only hosts containing this substring")
+ consoleFlag := fs.String("console", "", "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.Parse(args)
+ sess := requireAPISession("routes", *consoleFlag)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, routesUsage)
+ rows, 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\tSTATUS\tHEALTH")
+ 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\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), dashIfEmpty(r.Status), dashIfEmpty(r.Health))
+ }
+ 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)")
+}
+
+// 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
+}
diff --git a/routes_cli_test.go b/routes_cli_test.go
new file mode 100644
index 000000000..34fc47e6f
--- /dev/null
+++ b/routes_cli_test.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// routesConsole plants a credential for an httptest console that answers
+// GET /api/cli/apps (account resolution) and the routes door for titogo.
+func routesConsole(t *testing.T, rows []map[string]any) string {
+ t.Helper()
+ return signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.URL.Path == "/api/cli/apps":
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "bae", "name": "tom@keymail.dev", "role": "owner", "apps": []string{"titogo"}},
+ }})
+ case strings.HasSuffix(r.URL.Path, "/titogo/routes"):
+ json.NewEncoder(w).Encode(map[string]any{"routes": rows})
+ default:
+ http.NotFound(w, r)
+ }
+ })
+}
+
+// TestRouteListReportsTheEnvironmentBinding is issue #64 at the client
+// edge: the binding survives the wire and reaches the caller.
+func TestRouteListReportsTheEnvironmentBinding(t *testing.T) {
+ useTempCredentials(t)
+ origin := routesConsole(t, []map[string]any{
+ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
+ "channel": "canary/relay", "environment": "relay", "status": "ready", "health": "Running"},
+ })
+ sess, err := resolveMode(origin)
+ if err != nil {
+ t.Fatalf("session: %v", err)
+ }
+ rows, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo")
+ if err != nil {
+ t.Fatalf("routeList: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("rows = %v, want one", rows)
+ }
+ if rows[0].Environment != "relay" {
+ t.Fatalf("environment = %q, want %q", rows[0].Environment, "relay")
+ }
+ if rows[0].Upstream != "127.0.0.1:9001" || rows[0].Channel != "canary/relay" {
+ t.Fatalf("row = %+v, want the upstream and channel decoded too", rows[0])
+ }
+}
+
+// TestRouteListOldConsoleIsNamed: a console predating the endpoint 404s the
+// path itself. That must not be paraphrased as "no such app" — the
+// membership gate's 404 and a missing endpoint are different problems with
+// different fixes.
+func TestRouteListOldConsoleIsNamed(t *testing.T) {
+ useTempCredentials(t)
+ origin := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+ sess, err := resolveMode(origin)
+ if err != nil {
+ t.Fatalf("session: %v", err)
+ }
+ if _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil {
+ t.Fatal("a 404 from the routes door must surface as an error, never as an empty list")
+ }
+}
+
+// TestDashIfEmpty: an unbound route's empty ENVIRONMENT is a real answer —
+// it takes the app's default bundle — and must render as such rather than
+// as a blank cell a reader would take for missing data.
+func TestDashIfEmpty(t *testing.T) {
+ if got := dashIfEmpty(""); got != "—" {
+ t.Fatalf("dashIfEmpty(\"\") = %q, want an em dash", got)
+ }
+ if got := dashIfEmpty("relay"); got != "relay" {
+ t.Fatalf("dashIfEmpty(%q) = %q, want it unchanged", "relay", got)
+ }
+}
+
+// TestRoutesTableCarriesEveryColumn drives the VERB, not a copy of its
+// rendering: a column dropped from runRoutes' Fprintf must fail here
+// rather than in an operator's terminal.
+func TestRoutesTableCarriesEveryColumn(t *testing.T) {
+ useTempCredentials(t)
+ origin := routesConsole(t, []map[string]any{
+ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
+ "channel": "canary/relay", "environment": "relay", "status": "ready", "health": "Running"},
+ {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002",
+ "channel": "stable", "status": "ready", "health": "Running"},
+ })
+ var out bytes.Buffer
+ old := stdout
+ stdout = &out
+ t.Cleanup(func() { stdout = old })
+
+ runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin})
+
+ for _, want := range []string{
+ "HOST", "KIND", "UPSTREAM", "CHANNEL", "ENVIRONMENT", "STATUS", "HEALTH",
+ "connect.go.tito.io", "canary/relay", "relay", "Running", "127.0.0.1:9001",
+ } {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("routes output missing %q:\n%s", want, out.String())
+ }
+ }
+ // The unbound row's ENVIRONMENT cell is the dash, not a blank: taking
+ // the app's default bundle is an answer, not missing data.
+ line := ""
+ for _, l := range strings.Split(out.String(), "\n") {
+ if strings.HasPrefix(l, "router-a.go.tito.io") {
+ line = l
+ }
+ }
+ if !strings.Contains(line, "\u2014") {
+ t.Fatalf("unbound row = %q, want an em dash in the ENVIRONMENT cell", line)
+ }
+ // The fleet caveat is printed every time, not only on an empty list.
+ if !strings.Contains(out.String(), "other boxes in a fleet are not listed") {
+ t.Fatalf("routes output missing the multi-box caveat:\n%s", out.String())
+ }
+}
+
+// TestRoutesEmptyListSaysSo: an empty answer prints a line, not a bare
+// header an operator has to interpret.
+func TestRoutesEmptyListSaysSo(t *testing.T) {
+ useTempCredentials(t)
+ origin := routesConsole(t, []map[string]any{})
+ var out bytes.Buffer
+ old := stdout
+ stdout = &out
+ t.Cleanup(func() { stdout = old })
+
+ runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin})
+ if !strings.Contains(out.String(), "no routes for titogo") {
+ t.Fatalf("empty routes output = %q, want a line saying so", out.String())
+ }
+}