// The live-tenancy read (`carlos status --instances`, discussion #11): which // account is running what, right now, across the fleet. // // The question had no CLI or API answer at all. `GET /fleet/metrics` and // `GET /fleet/economics/accounts` hold it, and both gate on s.session — a // browser cookie — so a terminal cannot reach them however operator it is. // `carlos economics backhaul` lists only accounts that backhauled something. // `carlos status --live` reports boxes, not tenants. `carlos instances` reads // /data/registry.db on the box and lists routes rather than what they are // consuming. // // So the last time somebody asked "is this member hogging the flagship?", the // answer came out of the bucket with `aws s3 cp` over four boxes' metrics and // nine accounts' usage objects — which AGENTS.md's "operate via API and CLI" // rule names as a product gap rather than a path. This is the door that // closes it. Nothing new is measured or stored: the metrics loop already // writes per-unit cpu and mem every 60s, and LoadMetrics already sweeps them // for the browser page. // // GATE: cliUser + IsOperator, then a byte-identical 404 — the same shape // handleAPIEconomicsBackhaul and handleAPIEmailPause use. What that buys is // worth stating exactly, because it is easy to overclaim: a non-operator // learns nothing about the CONTENT — whose accounts run what, on which box, // consuming what — and nothing about who holds the operator bit, since the // refusal is byte-identical to an unknown route's. It does NOT hide the // path's existence. The route is method-scoped, so a POST to it gets the // mux's own 405 with an Allow header, before any of this handler runs and to // any caller at all. That is true of every method-scoped cliRoute (email // pause included) and is not this door's to fix. Registered through cliRoute // with an EMPTY role set, which closes it to every service credential // outright: this is an operator's view of the whole fleet, and no app-scoped // machine token has any business holding it. // // SCOPE: the two halves of the join do not reach equally far, and the answer // is only as fleet-wide as the shorter one. The READINGS are fleet-wide — // every box writes metrics/boxes// and LoadMetrics sweeps all of it — // but the ROUTES they are attributed against come from openPassRegistry, // which opens this console's OWN box-local registry file. On a multi-box // deployment a unit sampled on any other box therefore matches no route // here, falls through routeForUnit's unmatched case, and lands in the answer // as platform overhead charged to the ops account: a whole box's tenants // read as the platform's own, which is the opposite of what an operator // asking "who is using what" would take from it. sweepUsage // (economicspass.go) carries this same trap and the same reasoning — both // production deployments are single-box today, so it is inert, and the // structural fix is routes-as-bucket-records (PR #137, unmerged), which // would give every pass a registry view spanning boxes instead of one box's // local file. Until that lands the CLI prints the limit beside the answer, // the way `carlos routes` does, rather than letting a short list read as the // whole fleet. // // The other limit is by design rather than a defect: an instance that is // ASLEEP has no cgroup and therefore no sample, so it is not here at all. // That is the right shape for the question ("who is using what RIGHT NOW") // and the CLI says so too, rather than letting a short list read as the // whole tenancy. package console import ( "log" "net/http" "sort" "github.com/carlosframework/platform/internal/release" ) // apiStatusInstance is one awake instance. Account and app are joined on from // the registry through routeForUnit — the same matching rule the economics // pass attributes cost with, deliberately shared rather than reimplemented. type apiStatusInstance struct { Box string `json:"box"` Unit string `json:"unit"` Host string `json:"host,omitempty"` Account string `json:"account"` App string `json:"app,omitempty"` // CPU is a fraction of one core (0.017 = 1.7% of a core), Mem is bytes. // Both are pointers because a sample can legitimately lack a reading — // a unit seen for the first time has no CPU delta yet — and a missing // reading must print as "no answer" rather than as zero, which is a // number an operator would act on. CPU *float64 `json:"cpu,omitempty"` Mem *int64 `json:"mem_bytes,omitempty"` // Platform marks a unit that matched no route: the console's own unit, // system units, the agent itself. They are charged to the ops account by // the economics pass and they are shown here for the same reason — an // operator asking what is on the box wants the box's own overhead in the // picture, clearly labelled rather than silently dropped. Platform bool `json:"platform,omitempty"` } func (s *Server) handleAPIStatusInstances(w http.ResponseWriter, r *http.Request) { addr, ok := s.cliUser(w, r) if !ok { return } isOp, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: api status instances: is-operator: %v", err) http.NotFound(w, r) return } if !isOp { http.NotFound(w, r) return } reg, err := s.openPassRegistry() if err != nil { // Not a 404: the caller IS an operator and the route DOES exist, so // the honest answer is that this console cannot join the readings to // their owners. Answering with unattributed rows would be worse than // refusing — the whole question is whose they are. log.Printf("console: api status instances: registry: %v", err) jsonError(w, http.StatusServiceUnavailable, "this console serves no route registry, so it cannot say which account an instance belongs to") return } routes, err := reg.List() if err != nil { log.Printf("console: api status instances: registry list: %v", err) jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") return } boxes, _, err := s.LoadMetrics(r.Context()) if err != nil { log.Printf("console: api status instances: metrics: %v", err) jsonError(w, http.StatusBadGateway, "could not read the fleet's metrics — try again") return } want := r.URL.Query().Get("account") rows := []apiStatusInstance{} for _, b := range boxes { for _, u := range b.Units { row := apiStatusInstance{Box: b.Box, Unit: u.Unit, CPU: u.CPUFrac, Mem: u.MemBytes} // unitOwner's two cases, taken off the match already in hand // rather than by walking the whole route list a second time — // same rule, same answers, one pass. if rt, ok := routeForUnit(routes, u.Unit); ok { row.Host, row.App, row.Account = rt.Host, rt.App, release.RouteAccount(rt) } else { row.Platform, row.Account = true, release.OpsAccount } if want != "" && row.Account != want { continue } rows = append(rows, row) } } // Sorted by box then host then unit: the sweep's order is not stable // between calls, and a listing that reshuffles is one nobody can diff. sort.Slice(rows, func(i, j int) bool { if rows[i].Box != rows[j].Box { return rows[i].Box < rows[j].Box } if rows[i].Host != rows[j].Host { return rows[i].Host < rows[j].Host } return rows[i].Unit < rows[j].Unit }) writeJSON(w, http.StatusOK, map[string]any{"instances": rows}) }