console: GET /api/cli/apps/{account}/{app}/routes (#64)
Member-only (apiAccountApp, not instanceGate — an app token is a machine credential for one instance, and this answer describes every route of the app). Served from the console's own registry, the read instanceHealth already does, scoped to rows stamped to this app and resolving to this account. Answers for hand-added routes with no instance record, which is what made titogo's relay unreadable off-box. healthByHost is extracted from instanceHealth so the routes door, the instances door and the Running tab cannot disagree about a health word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue
4 files changed,
+388
−7
internal/console/apiinstances.go+31 −7internal/console/apiroutes.go+142 −0internal/console/apiroutes_test.go+209 −0internal/console/authcli.go+6 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 98f5243cb..ab7202f7e 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -265,13 +265,41 @@ func (s *Server) instanceHealth(ctx context.Context, recs []provision.Record) ma |
| unitByHost[rec.Host] = rt.Unit |
| } |
| } |
| + for h, word := range s.healthByHost(ctx, routeFor) { |
| + out[h] = word |
| + } |
| + return out |
| +} |
| + |
| +// healthByHost is the metrics half of a health answer, shared by |
| +// instanceHealth (records joined to routes) and handleAPIRoutes (routes |
| +// directly, issue #64) so the two — and the Running tab, which uses the |
| +// same healthWord — can never disagree about an instance's state. |
| +// |
| +// routeFor arrives ALREADY SCOPED to the caller's own account and app. |
| +// This function performs no tenant check of its own and must never be |
| +// handed an unfiltered registry listing: OwnerAppMetrics' allowlist is |
| +// exactly the hosts and stamped units it is given, so a foreign unit name |
| +// reaching unitByHost below would widen the metrics view past the caller's |
| +// own app. The scoping lives in each caller, beside the reason that |
| +// caller's rows are the caller's to see. |
| +func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry.Route) map[string]string { |
| + out := make(map[string]string, len(routeFor)) |
| + hosts := make([]string, 0, len(routeFor)) |
| + unitByHost := map[string]string{} |
| + for h, rt := range routeFor { |
| + hosts = append(hosts, h) |
| + if rt.Unit != "" { |
| + unitByHost[h] = rt.Unit |
| + } |
| + } |
| am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost) |
| if err != nil { |
| // An unreadable sweep is not knowledge that these instances are |
| // down: every host reads as not-reporting below, which healthWord |
| // turns into "No readings" or "Not responding" per expectsReadings — |
| // the same answer the page gives when the sweep is unavailable. |
| - log.Printf("console: api instances health: metrics: %v", err) |
| + log.Printf("console: health by host: metrics: %v", err) |
| am = nil |
| } |
| rep := make(map[string]bool, len(am)) |
| @@ -280,16 +308,12 @@ func (s *Server) instanceHealth(ctx context.Context, recs []provision.Record) ma |
| rep[m.Host] = true |
| } |
| } |
| - for _, rec := range recs { |
| - rt, ok := routeFor[rec.Host] |
| - if !ok { |
| - continue |
| - } |
| + for h, rt := range routeFor { |
| status := rt.Status |
| if status == "" { |
| status = "ready" // registry contract: empty status means ready |
| } |
| - out[rec.Host] = healthWord(rt.Kind, status, rt.Hibernate, rep[rec.Host], |
| + out[h] = healthWord(rt.Kind, status, rt.Hibernate, rep[h], |
| expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)) |
| } |
| return out |
diff --git a/internal/console/apiroutes.go b/internal/console/apiroutes.go| new file mode 100644 |
| index 000000000..489397f96 |
| --- /dev/null |
| +++ b/internal/console/apiroutes.go |
| @@ -0,0 +1,142 @@ |
| +// The routes read (`carlos routes`, issue #64): what routes this app |
| +// actually has on this deployment, and — the reason the issue was reopened |
| +// — what named config environment each one is bound to. |
| +// |
| +// Before this, a route's environment binding could be read exactly one |
| +// way: sqlite3 against /data/registry.db on the box. `carlos route` is a |
| +// setter, `carlos apps` answers from the console's app list, and the |
| +// instance API answers for RECORDS — so a hand-added route (titogo's |
| +// relay, the one that needed the binding) had no reader anywhere. Since |
| +// the binding is what decides which sealed secrets a route 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, in a flow whose selling point is that a |
| +// developer needs no AWS anything. It is also why issue #196 took an hour |
| +// to see rather than a minute. |
| +// |
| +// GATE: apiAccountApp, the member-proving lookup — NOT the instance |
| +// family's instanceGate. An app token is a machine credential minted for |
| +// one instance to register itself; this answer describes every route of |
| +// the app, with upstream addresses, owning units and environment |
| +// bindings, which is an operator's view rather than a workload's. The |
| +// narrower gate is the whole difference between the two doors; do not |
| +// "unify" them. |
| +// |
| +// SCOPE: the console reads its OWN box's registry, so in a multi-box fleet |
| +// the other boxes' routes do not appear here. That is the same blind spot |
| +// instanceHealth has today; internal/fleetapi is the eventual fix (#145) |
| +// and is unmerged. The CLI says so in its own output rather than letting a |
| +// short list read as a complete one. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// routeListTimeout bounds the metrics join, the only part of this handler |
| +// that can be slow — the registry is a local file this process already |
| +// holds open. |
| +const routeListTimeout = 15 * time.Second |
| + |
| +// apiRoute is one row of the answer. Every field is drawn from the |
| +// registry row EXCEPT Health, which is the same word instanceHealth and |
| +// the Running tab compute (healthByHost). |
| +type apiRoute 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,omitempty"` |
| + Backing string `json:"backing,omitempty"` |
| + Provisioned bool `json:"provisioned"` |
| +} |
| + |
| +// routeUpstream renders the one cell answering "where does this route send |
| +// traffic": a socket wins over addresses (registry.Route's own |
| +// precedence), a multi-upstream set is shown whole rather than reduced to |
| +// its first element, and a tunnel-backed route says so instead of |
| +// rendering an empty cell a reader would take for a broken row. |
| +func routeUpstream(rt registry.Route) string { |
| + switch { |
| + case rt.Socket != "": |
| + return rt.Socket |
| + case rt.Addrs != "": |
| + return rt.Addrs |
| + case rt.Addr != "": |
| + return rt.Addr |
| + case rt.Tunnel != "": |
| + return "tunnel:" + rt.Tunnel |
| + } |
| + return "" |
| +} |
| + |
| +func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if s.cfg.Registry == "" { |
| + // Never an empty list: a console with no registry cannot know any |
| + // route's state, and "[]" would read as "this app has no routes", |
| + // which is a different and wrong answer. (Opening "" would also |
| + // hand sqlite a throwaway temp DB and say the same thing, having |
| + // created a file for it — see instanceHealth's twin guard.) |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "this console serves no route registry, so it cannot say what routes exist") |
| + return |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: api routes: registry %s: %v", s.cfg.Registry, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| + return |
| + } |
| + all, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: api routes: registry list: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| + return |
| + } |
| + // The tenant boundary, and the only one: the read above returned every |
| + // route on this box, other accounts' included. A row enters routeFor |
| + // only if it is stamped to THIS app and resolves to THIS account. |
| + // release.RouteAccount, not a bare column compare, so an unstamped |
| + // account resolves the way every other read of that column resolves — |
| + // a second, private notion of "whose route is this" is exactly how a |
| + // listing ends up disagreeing with the reconciler about ownership. |
| + routeFor := map[string]registry.Route{} |
| + for _, rt := range all { |
| + if rt.App != app || release.RouteAccount(rt) != acct.Sqid { |
| + continue |
| + } |
| + routeFor[rt.Host] = rt |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), routeListTimeout) |
| + defer cancel() |
| + health := s.healthByHost(ctx, routeFor) |
| + rows := make([]apiRoute, 0, len(routeFor)) |
| + for _, rt := range routeFor { |
| + rows = append(rows, apiRoute{ |
| + Host: rt.Host, Kind: rt.Kind, Upstream: routeUpstream(rt), App: rt.App, |
| + Channel: rt.Channel, Environment: rt.Environment, Status: rt.Status, |
| + Health: health[rt.Host], Hibernate: rt.Hibernate, Unit: rt.Unit, |
| + Backing: rt.Backing, Provisioned: rt.Provisioned, |
| + }) |
| + } |
| + // Sorted by host: registry order is not stable across passes, and a |
| + // listing that reshuffles between calls is one nobody can diff. |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| + writeJSON(w, http.StatusOK, map[string]any{"routes": rows}) |
| +} |
diff --git a/internal/console/apiroutes_test.go b/internal/console/apiroutes_test.go| new file mode 100644 |
| index 000000000..576c9e02c |
| --- /dev/null |
| +++ b/internal/console/apiroutes_test.go |
| @@ -0,0 +1,209 @@ |
| +package console |
| + |
| +// The routes read (apiroutes.go, issue #64). The properties that matter, in |
| +// the order they'd hurt if lost: the gate is the family's byte-identical |
| +// 404 and an app token cannot reach this door at all; a caller sees only |
| +// their own app's rows out of a registry holding every tenant's; and the |
| +// environment binding — the whole reason the issue was reopened — comes |
| +// back. |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// routesURL is the routes door for one app in the fixture's account. |
| +func routesURL(fx *consoleFixture, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/bab/" + app + "/routes" |
| +} |
| + |
| +// routesFixture is a console with titogo claimed by the ops account, a |
| +// member token for it, and a registry holding a spread of rows: two of |
| +// titogo's (one bound to an environment, one not), one belonging to |
| +// another app in the SAME account, and one stamped to a different account |
| +// entirely. |
| +func routesFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, token := configFixture(t, code) |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range []registry.Route{ |
| + // The hand-added relay: no instance record anywhere, which is |
| + // exactly why `carlos instances list` could not answer for it. |
| + {Host: "connect.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9001", Channel: "canary/relay", Environment: "relay"}, |
| + {Host: "router-a.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9002", Channel: "stable"}, |
| + {Host: "neighbour.example.com", Kind: "instance", Status: "ready", App: "kass", |
| + Account: "bab", Addr: "127.0.0.1:9003"}, |
| + {Host: "foreign.example.com", Kind: "instance", Status: "ready", App: "titogo", |
| + Account: "zzzz", Addr: "127.0.0.1:9004", Environment: "someone-elses"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return fx, token |
| +} |
| + |
| +// hostsOf indexes a routes response by host. |
| +func hostsOf(t *testing.T, out map[string]any) map[string]map[string]any { |
| + t.Helper() |
| + rows, ok := out["routes"].([]any) |
| + if !ok { |
| + t.Fatalf("response has no routes array: %v", out) |
| + } |
| + byHost := map[string]map[string]any{} |
| + for _, r := range rows { |
| + m, _ := r.(map[string]any) |
| + byHost[str(t, m, "host")] = m |
| + } |
| + return byHost |
| +} |
| + |
| +// ---- the gate --------------------------------------------------------------- |
| + |
| +// TestAPIRoutesGateIsByteIdentical: a token belonging to no account, an |
| +// account segment that doesn't exist, and an app the account never claimed |
| +// are one and the same 404 — byte-identical to http.NotFound's own output, |
| +// so probing this door teaches a token holder nothing. |
| +func TestAPIRoutesGateIsByteIdentical(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-gate") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-routes-gate-eve") |
| + |
| + statusA, bodyA := getRaw(t, routesURL(fx, "titogo"), eve) |
| + statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/routes", token) |
| + statusC, bodyC := getRaw(t, routesURL(fx, "ghost-app"), token) |
| + if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| + t.Fatalf("routes gate = %d / %d / %d, want 404 for all three", statusA, statusB, statusC) |
| + } |
| + if bodyA != bodyB || bodyB != bodyC { |
| + t.Fatalf("refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", bodyA, bodyB, bodyC) |
| + } |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) |
| + } |
| +} |
| + |
| +// TestAPIRoutesNoToken: the same 401 cliUser always gives, before it can |
| +// matter what account or app was named. |
| +func TestAPIRoutesNoToken(t *testing.T) { |
| + fx, _ := routesFixture(t, "code-routes-notoken") |
| + status, out := getJSON(t, routesURL(fx, "titogo"), "") |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("GET with no token = %d %v, want 401", status, out) |
| + } |
| +} |
| + |
| +// ---- the answer ------------------------------------------------------------- |
| + |
| +// TestAPIRoutesReportsTheEnvironmentBinding is the whole point of issue |
| +// #64: after `carlos route -host <h> -environment relay`, a member can |
| +// read the binding back without a box shell. |
| +func TestAPIRoutesReportsTheEnvironmentBinding(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-env") |
| + status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET routes = %d %v", status, out) |
| + } |
| + byHost := hostsOf(t, out) |
| + relay, ok := byHost["connect.go.tito.io"] |
| + if !ok { |
| + t.Fatalf("routes = %v, want the bound route listed", out["routes"]) |
| + } |
| + if got := str(t, relay, "environment"); got != "relay" { |
| + t.Fatalf("environment = %q, want %q", got, "relay") |
| + } |
| + if got := str(t, relay, "channel"); got != "canary/relay" { |
| + t.Fatalf("channel = %q, want %q", got, "canary/relay") |
| + } |
| + if got := str(t, relay, "upstream"); got != "127.0.0.1:9001" { |
| + t.Fatalf("upstream = %q, want the route's addr", got) |
| + } |
| + // An UNBOUND route reports "" — a real answer (it takes the app's |
| + // default bundle), not missing data. |
| + unbound, ok := byHost["router-a.go.tito.io"] |
| + if !ok { |
| + t.Fatalf("routes = %v, want the unbound route listed too", out["routes"]) |
| + } |
| + if got := str(t, unbound, "environment"); got != "" { |
| + t.Fatalf("unbound route environment = %q, want empty", got) |
| + } |
| +} |
| + |
| +// TestAPIRoutesListsOnlyThisAppsRoutes: the registry holds every tenant's |
| +// rows, and the tenant boundary is the only thing standing between a |
| +// caller and a neighbour's upstream addresses and unit names. |
| +func TestAPIRoutesListsOnlyThisAppsRoutes(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-scope") |
| + status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET routes = %d %v", status, out) |
| + } |
| + byHost := hostsOf(t, out) |
| + if _, present := byHost["neighbour.example.com"]; present { |
| + t.Fatal("another app's route in the same account was listed") |
| + } |
| + if _, present := byHost["foreign.example.com"]; present { |
| + t.Fatal("a route stamped to another ACCOUNT was listed — the app name alone is not the boundary") |
| + } |
| + if len(byHost) != 2 { |
| + t.Fatalf("routes = %v, want exactly titogo's two rows", out["routes"]) |
| + } |
| +} |
| + |
| +// TestAPIRoutesSortsByHost: registry order is not stable across passes, so |
| +// the answer must not be either — a listing that reshuffles between calls |
| +// is one nobody can diff. |
| +func TestAPIRoutesSortsByHost(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-sort") |
| + status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET routes = %d %v", status, out) |
| + } |
| + rows, _ := out["routes"].([]any) |
| + if len(rows) != 2 { |
| + t.Fatalf("routes = %v, want two", out["routes"]) |
| + } |
| + first, _ := rows[0].(map[string]any) |
| + if str(t, first, "host") != "connect.go.tito.io" { |
| + t.Fatalf("first row = %v, want the alphabetically first host", first) |
| + } |
| +} |
| + |
| +// TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry: never an empty list. |
| +// "[]" would read as "this app has no routes", which is a different and |
| +// wrong answer. |
| +func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-noregistry") |
| + fx.srv.cfg.Registry = "" |
| + status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("GET routes with no registry = %d %v, want 503", status, out) |
| + } |
| + if _, present := out["routes"]; present { |
| + t.Fatalf("a console with no registry answered with a routes array: %v", out) |
| + } |
| +} |
| + |
| +// TestAPIRoutesAppTokenIsRefused: an app token is a machine credential |
| +// minted for one instance to register itself. This answer describes every |
| +// route of the app — upstreams, units, bindings — so it is a person's |
| +// view, and the narrower gate is the whole difference between this door |
| +// and the instance family's. |
| +func TestAPIRoutesAppTokenIsRefused(t *testing.T) { |
| + fx, _ := routesFixture(t, "code-routes-apptoken") |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _ := getRaw(t, routesURL(fx, "titogo"), appTok) |
| + if status == http.StatusOK { |
| + t.Fatal("an app token reached the routes door — it must not") |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index ee508b3e7..78b7de62c 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -298,6 +298,12 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet) |
| s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync) |
| + // The routes read (apiroutes.go, issue #64) — the member-only door onto |
| + // this box's registry rows for one app, so a route's environment |
| + // binding can be read back without a box shell. Deliberately NOT |
| + // instanceGate: see that file's package comment for why an app token |
| + // must not reach it. |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/routes", s.handleAPIRoutes) |
| // The customer fleets API (fleets.go) — account-owned remote-box groups |
| // and where an app's routes/instances place onto them. `new` is a |
| // literal beside the {fleet} wildcard for the same reason apps/new is: |