| 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. |
| 57 | package console |
| 58 | |
| 59 | import ( |
| 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. |
| 70 | type 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 | |
| 91 | func (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 | |