carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The live-tenancy read (`carlos status --instances`, discussion #11): which
2// account is running what, right now, across the fleet.
3//
4// The question had no CLI or API answer at all. `GET /fleet/metrics` and
5// `GET /fleet/economics/accounts` hold it, and both gate on s.session — a
6// browser cookie — so a terminal cannot reach them however operator it is.
7// `carlos economics backhaul` lists only accounts that backhauled something.
8// `carlos status --live` reports boxes, not tenants. `carlos instances` reads
9// /data/registry.db on the box and lists routes rather than what they are
10// consuming.
11//
12// So the last time somebody asked "is this member hogging the flagship?", the
13// answer came out of the bucket with `aws s3 cp` over four boxes' metrics and
14// nine accounts' usage objects — which AGENTS.md's "operate via API and CLI"
15// rule names as a product gap rather than a path. This is the door that
16// closes it. Nothing new is measured or stored: the metrics loop already
17// writes per-unit cpu and mem every 60s, and LoadMetrics already sweeps them
18// for the browser page.
19//
20// GATE: cliUser + IsOperator, then a byte-identical 404 — the same shape
21// handleAPIEconomicsBackhaul and handleAPIEmailPause use. What that buys is
22// worth stating exactly, because it is easy to overclaim: a non-operator
23// learns nothing about the CONTENT — whose accounts run what, on which box,
24// consuming what — and nothing about who holds the operator bit, since the
25// refusal is byte-identical to an unknown route's. It does NOT hide the
26// path's existence. The route is method-scoped, so a POST to it gets the
27// mux's own 405 with an Allow header, before any of this handler runs and to
28// any caller at all. That is true of every method-scoped cliRoute (email
29// pause included) and is not this door's to fix. Registered through cliRoute
30// with an EMPTY role set, which closes it to every service credential
31// outright: this is an operator's view of the whole fleet, and no app-scoped
32// machine token has any business holding it.
33//
34// SCOPE: the two halves of the join do not reach equally far, and the answer
35// is only as fleet-wide as the shorter one. The READINGS are fleet-wide —
36// every box writes metrics/boxes/<box>/ and LoadMetrics sweeps all of it —
37// but the ROUTES they are attributed against come from openPassRegistry,
38// which opens this console's OWN box-local registry file. On a multi-box
39// deployment a unit sampled on any other box therefore matches no route
40// here, falls through routeForUnit's unmatched case, and lands in the answer
41// as platform overhead charged to the ops account: a whole box's tenants
42// read as the platform's own, which is the opposite of what an operator
43// asking "who is using what" would take from it. sweepUsage
44// (economicspass.go) carries this same trap and the same reasoning — both
45// production deployments are single-box today, so it is inert, and the
46// structural fix is routes-as-bucket-records (PR #137, unmerged), which
47// would give every pass a registry view spanning boxes instead of one box's
48// local file. Until that lands the CLI prints the limit beside the answer,
49// the way `carlos routes` does, rather than letting a short list read as the
50// whole fleet.
51//
52// The other limit is by design rather than a defect: an instance that is
53// ASLEEP has no cgroup and therefore no sample, so it is not here at all.
54// That is the right shape for the question ("who is using what RIGHT NOW")
55// and the CLI says so too, rather than letting a short list read as the
56// whole tenancy.
57package console
58
59import (
60 "log"
61 "net/http"
62 "sort"
63
64 "github.com/carlosframework/platform/internal/release"
65)
66
67// apiStatusInstance is one awake instance. Account and app are joined on from
68// the registry through routeForUnit — the same matching rule the economics
69// pass attributes cost with, deliberately shared rather than reimplemented.
70type apiStatusInstance struct {
71 Box string `json:"box"`
72 Unit string `json:"unit"`
73 Host string `json:"host,omitempty"`
74 Account string `json:"account"`
75 App string `json:"app,omitempty"`
76 // CPU is a fraction of one core (0.017 = 1.7% of a core), Mem is bytes.
77 // Both are pointers because a sample can legitimately lack a reading —
78 // a unit seen for the first time has no CPU delta yet — and a missing
79 // reading must print as "no answer" rather than as zero, which is a
80 // number an operator would act on.
81 CPU *float64 `json:"cpu,omitempty"`
82 Mem *int64 `json:"mem_bytes,omitempty"`
83 // Platform marks a unit that matched no route: the console's own unit,
84 // system units, the agent itself. They are charged to the ops account by
85 // the economics pass and they are shown here for the same reason — an
86 // operator asking what is on the box wants the box's own overhead in the
87 // picture, clearly labelled rather than silently dropped.
88 Platform bool `json:"platform,omitempty"`
89}
90
91func (s *Server) handleAPIStatusInstances(w http.ResponseWriter, r *http.Request) {
92 addr, ok := s.cliUser(w, r)
93 if !ok {
94 return
95 }
96 isOp, err := s.store.IsOperator(addr)
97 if err != nil {
98 log.Printf("console: api status instances: is-operator: %v", err)
99 http.NotFound(w, r)
100 return
101 }
102 if !isOp {
103 http.NotFound(w, r)
104 return
105 }
106 reg, err := s.openPassRegistry()
107 if err != nil {
108 // Not a 404: the caller IS an operator and the route DOES exist, so
109 // the honest answer is that this console cannot join the readings to
110 // their owners. Answering with unattributed rows would be worse than
111 // refusing — the whole question is whose they are.
112 log.Printf("console: api status instances: registry: %v", err)
113 jsonError(w, http.StatusServiceUnavailable,
114 "this console serves no route registry, so it cannot say which account an instance belongs to")
115 return
116 }
117 routes, err := reg.List()
118 if err != nil {
119 log.Printf("console: api status instances: registry list: %v", err)
120 jsonError(w, http.StatusBadGateway, "could not read the route registry — try again")
121 return
122 }
123 boxes, _, err := s.LoadMetrics(r.Context())
124 if err != nil {
125 log.Printf("console: api status instances: metrics: %v", err)
126 jsonError(w, http.StatusBadGateway, "could not read the fleet's metrics — try again")
127 return
128 }
129
130 want := r.URL.Query().Get("account")
131 rows := []apiStatusInstance{}
132 for _, b := range boxes {
133 for _, u := range b.Units {
134 row := apiStatusInstance{Box: b.Box, Unit: u.Unit, CPU: u.CPUFrac, Mem: u.MemBytes}
135 // unitOwner's two cases, taken off the match already in hand
136 // rather than by walking the whole route list a second time —
137 // same rule, same answers, one pass.
138 if rt, ok := routeForUnit(routes, u.Unit); ok {
139 row.Host, row.App, row.Account = rt.Host, rt.App, release.RouteAccount(rt)
140 } else {
141 row.Platform, row.Account = true, release.OpsAccount
142 }
143 if want != "" && row.Account != want {
144 continue
145 }
146 rows = append(rows, row)
147 }
148 }
149 // Sorted by box then host then unit: the sweep's order is not stable
150 // between calls, and a listing that reshuffles is one nobody can diff.
151 sort.Slice(rows, func(i, j int) bool {
152 if rows[i].Box != rows[j].Box {
153 return rows[i].Box < rows[j].Box
154 }
155 if rows[i].Host != rows[j].Host {
156 return rows[i].Host < rows[j].Host
157 }
158 return rows[i].Unit < rows[j].Unit
159 })
160 writeJSON(w, http.StatusOK, map[string]any{"instances": rows})
161}
162