// 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"` // Replication is the rung of the replication ladder this route's // database is replicated on — "" (the pre-ladder default), stream, // batched or daily. Additive, omitempty: an older client decodes the // absent field as "", which is exactly what every route carried before // there was a door onto the column (discussion #10). Replication string `json:"replication,omitempty"` // DBBytes is the route's SQLite database size as its box last reported // it — the same box-snapshot join, and the same staleness rule, as the // instance list's db_bytes. Absent when unknown, never 0. DBBytes *int64 `json:"db_bytes,omitempty"` // PlatformCanary marks a route the domains reconciler mints and rebuilds // whole (registry Platform && Canary), so neither its channel nor its // environment is anybody's to set, here or with `carlos route` on the // box. The CLI reads it to avoid recommending that command for one of // these after an instance write misses (#264). Additive, omitempty. PlatformCanary bool `json:"platform_canary,omitempty"` } // apiRoutePath is one path route: a leading URL segment on Host that is // served by the route at Target instead of by Host's own route. It carries // no upstream, channel or environment of its own — it is a POINTER, and // everything about how that traffic is served comes from Target's ordinary // row above. Additive on the wire: a CLI that predates it decodes nothing // and lists routes exactly as it always did. type apiRoutePath struct { Host string `json:"host"` Prefix string `json:"prefix"` Target string `json:"target"` } // 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) join := loadBoxJoin(ctx, s.cfg.Releases, time.Now()) 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, Replication: rt.Replication, DBBytes: join.dbSize(rt.Host), PlatformCanary: rt.Platform && rt.Canary, }) } // 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 }) // The path routes hanging off those hosts. A flipped prefix set has to be // visible from the command operators already use, or a migration is // invisible until something breaks. // // Best-effort, and NOT a reason to fail the listing: the routes are the // answer, the prefixes are a detail of some of them, and a console whose // route_paths table cannot be read should still say what routes exist. // Same tenant boundary as the rows above, read off the row's own stamped // columns — ListPaths returns every path route on this box. // make, not a nil slice: an empty answer must serialise as [] like // `routes` beside it does, never as null. A client that has to handle // both spellings of "none" for two keys in one object is a client that // will handle one of them wrong. paths := make([]apiRoutePath, 0) if all, err := reg.ListPaths(); err != nil { log.Printf("console: api routes: registry list paths: %v", err) } else { for _, p := range all { // The host filter is what keeps this from listing a prefix whose // hostname is not in the answer above: the reconcile pass only // derives a row when the claim's host is a local route of the // same app and account, so in practice every row passes — but a // listing must not depend on another package's invariant. // release.RouteAccount, matching the routes filter above rather // than comparing the column raw. The warning there applies here // word for word: a second, private notion of "whose row is this" // is exactly how a listing ends up disagreeing with the // reconciler about ownership. A raw compare fails CLOSED today // (an unstamped account never equals a sqid, so the row is just // hidden), but "fails closed" is not the rule this file states, // and the two filters must resolve ownership identically or a // route can appear with its own prefixes missing. if p.App != app || release.RouteAccount(registry.Route{Account: p.Account}) != acct.Sqid { continue } if _, shown := routeFor[p.Host]; !shown { continue } paths = append(paths, apiRoutePath{Host: p.Host, Prefix: p.Prefix, Target: p.Target}) } } // ListPaths already orders by host then prefix, which is the order the // CLI prints them in under each host. writeJSON(w, http.StatusOK, map[string]any{"routes": rows, "paths": paths}) }