| 1 | // The routes read (`carlos routes`, issue #64): what routes this app |
| 2 | // actually has on this deployment, and — the reason the issue was reopened |
| 3 | // — what named config environment each one is bound to. |
| 4 | // |
| 5 | // Before this, a route's environment binding could be read exactly one |
| 6 | // way: sqlite3 against /data/registry.db on the box. `carlos route` is a |
| 7 | // setter, `carlos apps` answers from the console's app list, and the |
| 8 | // instance API answers for RECORDS — so a hand-added route (titogo's |
| 9 | // relay, the one that needed the binding) had no reader anywhere. Since |
| 10 | // the binding is what decides which sealed secrets a route is delivered, |
| 11 | // "which routes is this credential going to" was a question only |
| 12 | // answerable with box access: the one step of titogo's whole secrets |
| 13 | // procedure that needed it, in a flow whose selling point is that a |
| 14 | // developer needs no AWS anything. It is also why issue #196 took an hour |
| 15 | // to see rather than a minute. |
| 16 | // |
| 17 | // GATE: apiAccountApp, the member-proving lookup — NOT the instance |
| 18 | // family's instanceGate. An app token is a machine credential minted for |
| 19 | // one instance to register itself; this answer describes every route of |
| 20 | // the app, with upstream addresses, owning units and environment |
| 21 | // bindings, which is an operator's view rather than a workload's. The |
| 22 | // narrower gate is the whole difference between the two doors; do not |
| 23 | // "unify" them. |
| 24 | // |
| 25 | // SCOPE: the console reads its OWN box's registry, so in a multi-box fleet |
| 26 | // the other boxes' routes do not appear here. That is the same blind spot |
| 27 | // instanceHealth has today; internal/fleetapi is the eventual fix (#145) |
| 28 | // and is unmerged. The CLI says so in its own output rather than letting a |
| 29 | // short list read as a complete one. |
| 30 | package console |
| 31 | |
| 32 | import ( |
| 33 | "context" |
| 34 | "log" |
| 35 | "net/http" |
| 36 | "sort" |
| 37 | "time" |
| 38 | |
| 39 | "github.com/carlosframework/platform/internal/registry" |
| 40 | "github.com/carlosframework/platform/internal/release" |
| 41 | ) |
| 42 | |
| 43 | // routeListTimeout bounds the metrics join, the only part of this handler |
| 44 | // that can be slow — the registry is a local file this process already |
| 45 | // holds open. |
| 46 | const routeListTimeout = 15 * time.Second |
| 47 | |
| 48 | // apiRoute is one row of the answer. Every field is drawn from the |
| 49 | // registry row EXCEPT Health, which is the same word instanceHealth and |
| 50 | // the Running tab compute (healthByHost). |
| 51 | type apiRoute struct { |
| 52 | Host string `json:"host"` |
| 53 | Kind string `json:"kind"` |
| 54 | Upstream string `json:"upstream"` |
| 55 | App string `json:"app"` |
| 56 | Channel string `json:"channel"` |
| 57 | Environment string `json:"environment"` |
| 58 | Status string `json:"status"` |
| 59 | Health string `json:"health"` |
| 60 | Hibernate bool `json:"hibernate"` |
| 61 | Unit string `json:"unit,omitempty"` |
| 62 | Backing string `json:"backing,omitempty"` |
| 63 | Provisioned bool `json:"provisioned"` |
| 64 | // Replication is the rung of the replication ladder this route's |
| 65 | // database is replicated on — "" (the pre-ladder default), stream, |
| 66 | // batched or daily. Additive, omitempty: an older client decodes the |
| 67 | // absent field as "", which is exactly what every route carried before |
| 68 | // there was a door onto the column (discussion #10). |
| 69 | Replication string `json:"replication,omitempty"` |
| 70 | // DBBytes is the route's SQLite database size as its box last reported |
| 71 | // it — the same box-snapshot join, and the same staleness rule, as the |
| 72 | // instance list's db_bytes. Absent when unknown, never 0. |
| 73 | DBBytes *int64 `json:"db_bytes,omitempty"` |
| 74 | // PlatformCanary marks a route the domains reconciler mints and rebuilds |
| 75 | // whole (registry Platform && Canary), so neither its channel nor its |
| 76 | // environment is anybody's to set, here or with `carlos route` on the |
| 77 | // box. The CLI reads it to avoid recommending that command for one of |
| 78 | // these after an instance write misses (#264). Additive, omitempty. |
| 79 | PlatformCanary bool `json:"platform_canary,omitempty"` |
| 80 | } |
| 81 | |
| 82 | // apiRoutePath is one path route: a leading URL segment on Host that is |
| 83 | // served by the route at Target instead of by Host's own route. It carries |
| 84 | // no upstream, channel or environment of its own — it is a POINTER, and |
| 85 | // everything about how that traffic is served comes from Target's ordinary |
| 86 | // row above. Additive on the wire: a CLI that predates it decodes nothing |
| 87 | // and lists routes exactly as it always did. |
| 88 | type apiRoutePath struct { |
| 89 | Host string `json:"host"` |
| 90 | Prefix string `json:"prefix"` |
| 91 | Target string `json:"target"` |
| 92 | } |
| 93 | |
| 94 | // routeUpstream renders the one cell answering "where does this route send |
| 95 | // traffic": a socket wins over addresses (registry.Route's own |
| 96 | // precedence), a multi-upstream set is shown whole rather than reduced to |
| 97 | // its first element, and a tunnel-backed route says so instead of |
| 98 | // rendering an empty cell a reader would take for a broken row. |
| 99 | func routeUpstream(rt registry.Route) string { |
| 100 | switch { |
| 101 | case rt.Socket != "": |
| 102 | return rt.Socket |
| 103 | case rt.Addrs != "": |
| 104 | return rt.Addrs |
| 105 | case rt.Addr != "": |
| 106 | return rt.Addr |
| 107 | case rt.Tunnel != "": |
| 108 | return "tunnel:" + rt.Tunnel |
| 109 | } |
| 110 | return "" |
| 111 | } |
| 112 | |
| 113 | func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) { |
| 114 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 115 | if !ok { |
| 116 | return |
| 117 | } |
| 118 | if s.cfg.Registry == "" { |
| 119 | // Never an empty list: a console with no registry cannot know any |
| 120 | // route's state, and "[]" would read as "this app has no routes", |
| 121 | // which is a different and wrong answer. (Opening "" would also |
| 122 | // hand sqlite a throwaway temp DB and say the same thing, having |
| 123 | // created a file for it — see instanceHealth's twin guard.) |
| 124 | jsonError(w, http.StatusServiceUnavailable, |
| 125 | "this console serves no route registry, so it cannot say what routes exist") |
| 126 | return |
| 127 | } |
| 128 | reg, err := openRegistry(s.cfg.Registry) |
| 129 | if err != nil { |
| 130 | log.Printf("console: api routes: registry %s: %v", s.cfg.Registry, err) |
| 131 | jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| 132 | return |
| 133 | } |
| 134 | all, err := reg.List() |
| 135 | if err != nil { |
| 136 | log.Printf("console: api routes: registry list: %v", err) |
| 137 | jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| 138 | return |
| 139 | } |
| 140 | // The tenant boundary, and the only one: the read above returned every |
| 141 | // route on this box, other accounts' included. A row enters routeFor |
| 142 | // only if it is stamped to THIS app and resolves to THIS account. |
| 143 | // release.RouteAccount, not a bare column compare, so an unstamped |
| 144 | // account resolves the way every other read of that column resolves — |
| 145 | // a second, private notion of "whose route is this" is exactly how a |
| 146 | // listing ends up disagreeing with the reconciler about ownership. |
| 147 | routeFor := map[string]registry.Route{} |
| 148 | for _, rt := range all { |
| 149 | if rt.App != app || release.RouteAccount(rt) != acct.Sqid { |
| 150 | continue |
| 151 | } |
| 152 | routeFor[rt.Host] = rt |
| 153 | } |
| 154 | ctx, cancel := context.WithTimeout(r.Context(), routeListTimeout) |
| 155 | defer cancel() |
| 156 | health := s.healthByHost(ctx, routeFor) |
| 157 | join := loadBoxJoin(ctx, s.cfg.Releases, time.Now()) |
| 158 | rows := make([]apiRoute, 0, len(routeFor)) |
| 159 | for _, rt := range routeFor { |
| 160 | rows = append(rows, apiRoute{ |
| 161 | Host: rt.Host, Kind: rt.Kind, Upstream: routeUpstream(rt), App: rt.App, |
| 162 | Channel: rt.Channel, Environment: rt.Environment, Status: rt.Status, |
| 163 | Health: health[rt.Host], Hibernate: rt.Hibernate, Unit: rt.Unit, |
| 164 | Backing: rt.Backing, Provisioned: rt.Provisioned, |
| 165 | Replication: rt.Replication, DBBytes: join.dbSize(rt.Host), |
| 166 | PlatformCanary: rt.Platform && rt.Canary, |
| 167 | }) |
| 168 | } |
| 169 | // Sorted by host: registry order is not stable across passes, and a |
| 170 | // listing that reshuffles between calls is one nobody can diff. |
| 171 | sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| 172 | // The path routes hanging off those hosts. A flipped prefix set has to be |
| 173 | // visible from the command operators already use, or a migration is |
| 174 | // invisible until something breaks. |
| 175 | // |
| 176 | // Best-effort, and NOT a reason to fail the listing: the routes are the |
| 177 | // answer, the prefixes are a detail of some of them, and a console whose |
| 178 | // route_paths table cannot be read should still say what routes exist. |
| 179 | // Same tenant boundary as the rows above, read off the row's own stamped |
| 180 | // columns — ListPaths returns every path route on this box. |
| 181 | // make, not a nil slice: an empty answer must serialise as [] like |
| 182 | // `routes` beside it does, never as null. A client that has to handle |
| 183 | // both spellings of "none" for two keys in one object is a client that |
| 184 | // will handle one of them wrong. |
| 185 | paths := make([]apiRoutePath, 0) |
| 186 | if all, err := reg.ListPaths(); err != nil { |
| 187 | log.Printf("console: api routes: registry list paths: %v", err) |
| 188 | } else { |
| 189 | for _, p := range all { |
| 190 | // The host filter is what keeps this from listing a prefix whose |
| 191 | // hostname is not in the answer above: the reconcile pass only |
| 192 | // derives a row when the claim's host is a local route of the |
| 193 | // same app and account, so in practice every row passes — but a |
| 194 | // listing must not depend on another package's invariant. |
| 195 | // release.RouteAccount, matching the routes filter above rather |
| 196 | // than comparing the column raw. The warning there applies here |
| 197 | // word for word: a second, private notion of "whose row is this" |
| 198 | // is exactly how a listing ends up disagreeing with the |
| 199 | // reconciler about ownership. A raw compare fails CLOSED today |
| 200 | // (an unstamped account never equals a sqid, so the row is just |
| 201 | // hidden), but "fails closed" is not the rule this file states, |
| 202 | // and the two filters must resolve ownership identically or a |
| 203 | // route can appear with its own prefixes missing. |
| 204 | if p.App != app || release.RouteAccount(registry.Route{Account: p.Account}) != acct.Sqid { |
| 205 | continue |
| 206 | } |
| 207 | if _, shown := routeFor[p.Host]; !shown { |
| 208 | continue |
| 209 | } |
| 210 | paths = append(paths, apiRoutePath{Host: p.Host, Prefix: p.Prefix, Target: p.Target}) |
| 211 | } |
| 212 | } |
| 213 | // ListPaths already orders by host then prefix, which is the order the |
| 214 | // CLI prints them in under each host. |
| 215 | writeJSON(w, http.StatusOK, map[string]any{"routes": rows, "paths": paths}) |
| 216 | } |
| 217 | |