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
6 files changed,
+303
−3
apimode.go+38 −0cli.go+13 −3cli_test.go+19 −0main.go+4 −0routes_cli.go+85 −0routes_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()) |
| + } |
| +} |