carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
30package console
31
32import (
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.
46const 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).
51type 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.
88type 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.
99func 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
113func (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