carlos / platform Public

Merge branch 'status-instances' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 5e6f75e70523f02916e6ba3e2a9f55ed4855e0b5 parent 6b21c6bparent e9790f9
9 files changed, +648 −5
  • cli.go +28 −0
  • docs/site/cli.md +22 −0
  • internal/console/apistatusinstances.go +162 −0
  • internal/console/apistatusinstances_test.go +198 −0
  • internal/console/authcli.go +7 −0
  • internal/console/economicspass.go +16 −3
  • internal/console/svcroutes_test.go +11 −2
  • status_instances.go +161 −0
  • status_instances_test.go +43 −0

CI passed — run details

diff --git a/cli.go b/cli.go
index 8b3d7eb88..fe12cda75 100644
--- a/cli.go
+++ b/cli.go
@@ -3189,6 +3189,7 @@ func runKeygen(args []string) {
// "carlos status publish" here; --live only reads what those ticks wrote.
const statusUsage = `usage: carlos status --dry-run <dir>
carlos status --live
+ carlos status --instances [--account <sqid>]
--dry-run <dir> build a throwaway fleet stuffed with fixture secrets
(hostnames, a machine id, mid-band fractions), run it
@@ -3207,6 +3208,18 @@ const statusUsage = `usage: carlos status --dry-run <dir>
last readings. Needs bucket credentials and
CARLOS_STATUS_BUCKET (or CARLOS_DEPLOYMENT_BUCKET, the same
defaulting the agent's own status loop applies).
+
+ --instances who is using what right now: one row per AWAKE instance
+ across the fleet, with the account and app it belongs to
+ and what it is consuming. Signed in through the console
+ (carlos auth login), operator only — a non-operator token
+ gets the same bare 404 an unknown route gets. --account
+ <sqid> narrows it to one account. A sleeping instance has
+ no process and therefore no reading, so it does not appear;
+ and accounts are joined against the console's own box's
+ route registry, so an instance on another box in a fleet
+ can land under (platform). Both limits print with the
+ table.
`
// RunStatus is the "carlos status" verb: a fixture dry-run and a live read of
@@ -3217,9 +3230,24 @@ func RunStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
dryRunDir := fs.String("dry-run", "", "build a fixture fleet, publish it into this directory, and verify no leaks")
liveFlag := fs.Bool("live", false, "read status/boxes/*.json from the status bucket and print per-host metrics")
+ instancesFlag := fs.Bool("instances", false, "one row per awake instance across the fleet, with the account and app it belongs to (operator only, through the console)")
+ acctFilter := fs.String("account", "", "narrow --instances to one account (sqid)")
+ consoleFlag := fs.String("console", "", "console to ask (default: the only logged-in one)")
fs.Usage = flagUsage(fs, statusUsage)
fs.Parse(args)
+ if *instancesFlag {
+ // Console-backed, unlike --live: the per-unit readings live in the
+ // deployment's metrics objects, which the console already sweeps, and
+ // the account each one belongs to is a registry join only the console
+ // can do. --live reads the status bucket directly with the operator's
+ // own bucket credentials; this deliberately needs none, which is the
+ // whole point (discussion #11: the alternative was `aws s3 cp`).
+ if err := statusInstances(os.Stdout, *consoleFlag, *acctFilter); err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
if *liveFlag {
if err := statusLive(os.Stdout); err != nil {
log.Fatal(err)
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 22a137a20..2fcf1f22e 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -851,6 +851,28 @@ its stale readings.
carlos status --live
```
+`--instances` answers the other question an operator has about a fleet: who is
+using what, right now. One row per awake instance across every box, with the
+account and app it belongs to and what it is consuming.
+
+```sh
+carlos status --instances --account bac
+```
+
+It goes through the console rather than the status bucket, so it needs no AWS
+credentials — which is the point of it existing. Operator only: a token
+without the fleet-operator bit gets the same bare 404 an unknown route gets,
+so it learns neither the rows nor whether it holds the bit. The path itself is
+not a secret — a wrong-method request gets a 405 from the router, like every
+other route here — but the answer is, because it names other people's
+accounts.
+
+Two things it cannot see, both printed under the table. A sleeping instance
+has no process and so no reading, so it does not appear. And the readings are
+fleet-wide while the account each one is attributed to comes from the
+console's own box's route registry, so on a multi-box deployment an instance
+running elsewhere can show up under `(platform)` rather than under its tenant.
+
`--dry-run <dir>` is the self-hoster's leak check. It builds a throwaway fleet
stuffed with fixture secrets, runs it through the real publish path into a
scratch directory, reads every published object back, and fails if any
diff --git a/internal/console/apistatusinstances.go b/internal/console/apistatusinstances.go
new file mode 100644
index 000000000..27b71e455
--- /dev/null
+++ b/internal/console/apistatusinstances.go
@@ -0,0 +1,162 @@
+// 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/<box>/ 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
+ }
+ defer reg.Close()
+ 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})
+}
diff --git a/internal/console/apistatusinstances_test.go b/internal/console/apistatusinstances_test.go
new file mode 100644
index 000000000..9d9081b1a
--- /dev/null
+++ b/internal/console/apistatusinstances_test.go
@@ -0,0 +1,198 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestAPIStatusInstancesGate: operator only, with the same posture every
+// operator door here has — anyone else sees the byte-identical 404 an unknown
+// route gets. It matters more than usual on this door: the answer names other
+// people's accounts and what they are running, so the refusal must give away
+// neither the rows nor whether the caller holds the operator bit. It does not
+// hide the PATH — a wrong-method request gets the mux's 405 before any of
+// this — and the handler's comment says so; what is asserted here is what the
+// 404 actually buys.
+func TestAPIStatusInstancesGate(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusinstances")
+ ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token)
+
+ if status, _ := getBearer(t, fx.ts.URL+"/api/cli/status/instances", ""); status != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated = %d; want the CLI door's 401", status)
+ }
+ if status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token); status != ghostStatus || string(body) != string(ghostBody) {
+ t.Fatalf("non-operator = %d %q; want the ghost 404 (%d %q)", status, body, ghostStatus, ghostBody)
+ }
+
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token)
+ if status != http.StatusOK {
+ t.Fatalf("operator = %d %q", status, body)
+ }
+ var out struct {
+ Instances []apiStatusInstance `json:"instances"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ t.Fatalf("unreadable: %v: %s", err, body)
+ }
+ // Never null: a client that decodes null gets a nil slice and prints
+ // nothing, which reads the same as "no instances" for the wrong reason.
+ if out.Instances == nil {
+ t.Error("instances decoded as null; want an empty array")
+ }
+}
+
+// tenancyFixture puts real readings behind the door: one box, three sampled
+// units, two of them owned by routes belonging to DIFFERENT accounts and one
+// owned by no route at all.
+//
+// It exists because without it the tests below assert nothing. No other test
+// in this package writes anything under metrics/boxes/, so LoadMetrics finds
+// no boxes, the handler's loop over box units never runs, every answer is an
+// empty array, and every loop a test writes over that answer runs zero times.
+// The --account filter test in particular stayed green with the server-side
+// filter deleted. A fixture is the difference between a test that reads like
+// a pin and one that is one.
+//
+// The unit names are built the way the handler matches them — through
+// activator.UnitName, which is what routeForUnit compares against — so what
+// is under test is the real matching rule rather than a string this file
+// agreed with itself about.
+func tenancyFixture(t *testing.T, fx *consoleFixture) {
+ t.Helper()
+ // fx.sqid is the built-in ops account; "cdef" is somebody else. Two
+ // tenants on one box is the whole point: a filter can only be shown to
+ // narrow if there is something for it to leave behind.
+ fx.addRoute(t, registry.Route{Host: "ours.example.com", Kind: "instance", Status: "ready",
+ App: "kass", Account: fx.sqid, Channel: "stable"})
+ fx.addRoute(t, registry.Route{Host: "theirs.example.com", Kind: "instance", Status: "ready",
+ App: "widgets", Account: "cdef", Channel: "stable"})
+ at := time.Now().UTC().Add(-time.Minute)
+ cpu, mem := 0.25, int64(1<<20)
+ if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{
+ V: 1, Box: "box-tenancy", Hour: metrics.HourOf(at),
+ Samples: []metrics.Sample{{At: at, Units: map[string]metrics.UnitSample{
+ // A full reading, a partial one (CPU but no memory — the reason
+ // those fields are pointers), and a unit matching no route at
+ // all: the console's own, which is platform overhead.
+ activator.UnitName("ours.example.com"): {CPUFrac: &cpu, MemBytes: &mem},
+ activator.UnitName("theirs.example.com"): {CPUFrac: &cpu},
+ "console.service": {},
+ }}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// statusInstanceRows asks the door and decodes it, failing the test on any
+// answer that is not a 200 carrying an array.
+func statusInstanceRows(t *testing.T, fx *consoleFixture, token, query string) []apiStatusInstance {
+ t.Helper()
+ status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances"+query, token)
+ if status != http.StatusOK {
+ t.Fatalf("GET /api/cli/status/instances%s = %d %q", query, status, body)
+ }
+ var out struct {
+ Instances []apiStatusInstance `json:"instances"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ t.Fatalf("unreadable: %v: %s", err, body)
+ }
+ return out.Instances
+}
+
+// TestAPIStatusInstancesAttributesUnitsToAccounts pins the join itself: a
+// sampled unit is carried to the account that owns the route it belongs to,
+// and a unit belonging to no route stays in the answer marked as platform
+// overhead rather than being dropped. Dropping it would be the quieter bug of
+// the two — an operator asking what is on a box would get a list that omits
+// the box's own load and never says it did.
+func TestAPIStatusInstancesAttributesUnitsToAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusjoin")
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ tenancyFixture(t, fx)
+
+ rows := statusInstanceRows(t, fx, token, "")
+ byUnit := map[string]apiStatusInstance{}
+ for _, r := range rows {
+ byUnit[r.Unit] = r
+ }
+ if len(rows) != 3 || len(byUnit) != 3 {
+ t.Fatalf("unfiltered = %d rows %+v, want one per sampled unit", len(rows), rows)
+ }
+
+ ours := byUnit[activator.UnitName("ours.example.com")]
+ if ours.Account != fx.sqid || ours.App != "kass" || ours.Host != "ours.example.com" || ours.Platform {
+ t.Errorf("ours = %+v, want the ops account's kass route rather than platform", ours)
+ }
+ if ours.Box != "box-tenancy" || ours.CPU == nil || *ours.CPU != 0.25 || ours.Mem == nil || *ours.Mem != 1<<20 {
+ t.Errorf("ours = %+v, want box-tenancy carrying its own readings", ours)
+ }
+
+ theirs := byUnit[activator.UnitName("theirs.example.com")]
+ if theirs.Account != "cdef" || theirs.App != "widgets" || theirs.Host != "theirs.example.com" || theirs.Platform {
+ t.Errorf("theirs = %+v, want cdef's widgets route", theirs)
+ }
+ // A sampled unit with no memory reading must arrive as nil rather than 0:
+ // a zero is a number an operator would act on.
+ if theirs.Mem != nil {
+ t.Errorf("theirs mem = %d, want nil — the sample carried no reading", *theirs.Mem)
+ }
+
+ plat := byUnit["console.service"]
+ if !plat.Platform || plat.Account != release.OpsAccount || plat.App != "" || plat.Host != "" {
+ t.Errorf("console.service = %+v, want platform overhead charged to ops", plat)
+ }
+}
+
+// TestAPIStatusInstancesAccountFilterNarrows: --account is the "is THIS member
+// hogging the box?" form of the question, which is the one that prompted the
+// door (discussion #11). The filter is applied on the SERVER, and it has to
+// be: a filter that silently ignores itself is how an operator reads another
+// account's rows as the one they asked about, and a client-side one would put
+// every account's rows on the wire to get there.
+//
+// Asserted against a populated box, so "narrows" means fewer rows than the
+// unfiltered answer rather than an empty list being trivially consistent with
+// every claim anyone could make about it.
+func TestAPIStatusInstancesAccountFilterNarrows(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusfilter")
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ tenancyFixture(t, fx)
+
+ if all := statusInstanceRows(t, fx, token, ""); len(all) != 3 {
+ t.Fatalf("unfiltered = %d rows %+v, want three — the fixture is what makes the rest of this test mean anything", len(all), all)
+ }
+
+ // A known account: exactly its own row, and nobody else's.
+ only := statusInstanceRows(t, fx, token, "?account=cdef")
+ if len(only) != 1 {
+ t.Fatalf("?account=cdef = %d rows %+v, want just the one", len(only), only)
+ }
+ if got, want := only[0].Unit, activator.UnitName("theirs.example.com"); got != want {
+ t.Errorf("?account=cdef returned %q, want %q", got, want)
+ }
+
+ // An unknown account narrows to nothing rather than falling back to
+ // everything.
+ if rows := statusInstanceRows(t, fx, token, "?account=nosuchacct"); len(rows) != 0 {
+ t.Errorf("?account=nosuchacct = %+v, want no rows at all", rows)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 2d1243302..9875f2b64 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -469,6 +469,13 @@ func (s *Server) registerCLIRoutes() {
// (/api/cli/) from the identity routes above (/auth/cli/), which must not
// move now that CLIs depend on them.
s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Instance, svcrole.Operate), s.handleAPIApps)
+ // The fleet-wide live tenancy read (discussion #11). An EMPTY role set:
+ // no service credential of any role reaches it, because the answer names
+ // every account's instances on every box, and that is an operator's view
+ // rather than any workload's. The operator check itself is inside the
+ // handler, since cliRoute's role set can only narrow SERVICE principals
+ // and a person always passes through it.
+ s.cliRoute("GET /api/cli/status/instances", svcrole.Set{}, s.handleAPIStatusInstances)
// The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no
// bare POST .../{app} route exists to collide with, and the mux prefers
// the literal if one ever does.
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index 08df9644b..e42932060 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -209,15 +209,28 @@ func (s *Server) storagePass(ctx context.Context, date string) error {
// own unit, system units — attributes to release.OpsAccount, app "": platform
// overhead.
func unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool) {
+ rt, ok := routeForUnit(routes, unit)
+ if !ok {
+ return release.OpsAccount, "", false
+ }
+ return release.RouteAccount(rt), rt.App, true
+}
+
+// routeForUnit is the matching rule itself, split out of unitOwner so that a
+// second reader of the same sampled units — `carlos status --instances`,
+// which needs the route's HOST and not only its owner — cannot end up with a
+// private second notion of which route a cgroup belongs to. The comment above
+// unitOwner is the rationale for the rule; this is the rule.
+func routeForUnit(routes []registry.Route, unit string) (registry.Route, bool) {
for _, rt := range routes {
if rt.Unit != "" && rt.Unit == unit {
- return release.RouteAccount(rt), rt.App, true
+ return rt, true
}
if rt.Host != "" && (unit == activator.UnitName(rt.Host) || unit == activator.ExecName(rt.Host)) {
- return release.RouteAccount(rt), rt.App, true
+ return rt, true
}
}
- return release.OpsAccount, "", false
+ return registry.Route{}, false
}
// usageSweep is sweepUsage's result: the box- and account-shaped meters
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 0f3d30e82..d738e5573 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -92,6 +92,15 @@ var closedDoors = []string{
// the registration says so where the role table can be read.
"GET /api/cli/apps/{account}/{app}/routes",
"PUT /api/cli/apps/{account}/{app}/instances-manifest",
+ // The fleet-wide live tenancy read (discussion #11). Shut to every
+ // credential for a reason that is about the door's SUBJECT rather than
+ // any role: the answer names every account's instances on every box.
+ // There is no app-scoped reading of it — narrowing it to the caller's
+ // own account would be a different door answering a different question —
+ // so no service role can be the right holder, whatever roles exist
+ // later. The operator check itself is in the handler, because cliRoute's
+ // Set only narrows service principals and a person always passes it.
+ "GET /api/cli/status/instances",
// Enrolling a BOX into a fleet, and rotating or detaching one, is a
// human act with a machine's credentials as its output. Same cut.
"POST /api/cli/fleets/{account}/new",
@@ -693,8 +702,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 18 {
- t.Errorf("walked %d closed doors, want all %d (and 18 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe)",
+ if seen != len(closedDoors) || seen != 19 {
+ t.Errorf("walked %d closed doors, want all %d (and 19 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read)",
seen, len(closedDoors))
}
}
diff --git a/status_instances.go b/status_instances.go
new file mode 100644
index 000000000..cfacc1511
--- /dev/null
+++ b/status_instances.go
@@ -0,0 +1,161 @@
+package main
+
+// `carlos status --instances` — who is using what right now (discussion #11).
+//
+// The question "which account is using what on this box?" had no terminal
+// answer. Every door holding it gated on a browser session, and the last
+// person who needed it read four boxes' metrics objects and nine accounts'
+// usage objects out of the bucket with `aws s3 cp` — which AGENTS.md's
+// "operate via API and CLI" rule names as a product gap rather than a path.
+//
+// Console-backed, unlike `--live`. `--live` reads the status bucket with the
+// operator's own bucket credentials, which is exactly the move this verb
+// exists to make unnecessary: the console already sweeps the per-unit
+// readings for its own browser page, and the account each unit belongs to is
+// a registry join only the console can do.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+)
+
+// statusInstanceRow is one awake instance, as the console answers it
+// (internal/console/apistatusinstances.go). Declared here rather than
+// imported, the convention every other CLI-side wire struct follows.
+//
+// CPU and Mem are pointers because a missing reading is a real state — a unit
+// seen for the first time has no CPU delta yet — and printing it as 0 would
+// be printing a number an operator might act on.
+type statusInstanceRow struct {
+ Box string `json:"box"`
+ Unit string `json:"unit"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ CPU *float64 `json:"cpu"`
+ Mem *int64 `json:"mem_bytes"`
+ Platform bool `json:"platform"`
+}
+
+func (s *apiSession) statusInstanceList(ctx context.Context, account string) ([]statusInstanceRow, error) {
+ path := "/api/cli/status/instances"
+ if account != "" {
+ path += "?account=" + url.QueryEscape(account)
+ }
+ status, body, err := s.getJSON(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ // The same bare 404 an unknown route gives, and deliberately so:
+ // the answer names other people's accounts, so the refusal says
+ // nothing about them and nothing about who holds the operator
+ // bit. Which leaves this message covering both readings, because
+ // the caller genuinely cannot tell them apart from here.
+ return nil, errors.New("not found — this needs the fleet-operator bit on your account, " +
+ "or this console predates `carlos status --instances` (deploy a console built from current main)")
+ }
+ return nil, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Instances []statusInstanceRow `json:"instances"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("status instances: unreadable response: %w", err)
+ }
+ return out.Instances, nil
+}
+
+func statusInstances(w io.Writer, consoleFlag, account string) error {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ return err
+ }
+ if sess == nil {
+ return errors.New("carlos status --instances works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run:\n" +
+ "carlos auth login --console <url>")
+ }
+ rows, err := sess.statusInstanceList(context.Background(), account)
+ if err != nil {
+ return fmt.Errorf("status --instances: %w", err)
+ }
+ printStatusInstances(w, rows, account)
+ return nil
+}
+
+func printStatusInstances(w io.Writer, rows []statusInstanceRow, account string) {
+ if len(rows) == 0 {
+ if account != "" {
+ mutedf(w, "no awake instances for %s right now", account)
+ } else {
+ mutedf(w, "no awake instances right now")
+ }
+ return
+ }
+ t := newTable(w)
+ fmt.Fprintln(t, "BOX\tHOST\tACCOUNT\tAPP\tCPU\tMEM")
+ for _, r := range rows {
+ host, app := r.Host, r.App
+ if r.Platform {
+ // A unit that matched no route: the console's own unit, the
+ // agent, system units. Named as what it is rather than left as
+ // two blank cells a reader would take for missing data — an
+ // operator asking what is on the box wants the box's own
+ // overhead in the picture.
+ host, app = r.Unit, "(platform)"
+ }
+ fmt.Fprintf(t, "%s\t%s\t%s\t%s\t%s\t%s\n",
+ r.Box, dashIfEmpty(host), dashIfEmpty(r.Account), dashIfEmpty(app),
+ cpuCell(r.CPU), memCell(r.Mem))
+ }
+ t.Flush()
+ // Both said every time, because both are ways a short list misleads.
+ //
+ // A hibernating instance has no process, so it has no cgroup and no
+ // reading — it is not in this list and its absence is not evidence that
+ // it does not exist. The question this verb answers is "right now", and
+ // that limit belongs next to the answer.
+ mutedf(w, "(awake instances only — a hibernating instance has no process to measure)")
+ // The readings are fleet-wide, but the console joins them against its own
+ // box's route registry, so a unit on any other box matches nothing and
+ // lands under (platform) rather than under its tenant. Inert on a
+ // single-box deployment and fixed structurally by PR #137; until then an
+ // operator reading this for "who is using what" has to be told, the same
+ // way `carlos routes` says whose routes it is showing.
+ mutedf(w, "(accounts are joined against the console's own box's route registry; "+
+ "an instance on another box in a fleet can land under (platform))")
+}
+
+// cpuCell renders a fraction of one core. A nil reading prints as a dash: the
+// unit was sampled but has no CPU delta yet, which is not the same as idle,
+// and 0.000 is a claim about the process rather than about the sample.
+func cpuCell(v *float64) string {
+ if v == nil {
+ return "—"
+ }
+ return fmt.Sprintf("%.3f", *v)
+}
+
+// memCell renders bytes the way an operator scans them. Same nil rule.
+func memCell(v *int64) string {
+ if v == nil {
+ return "—"
+ }
+ b := float64(*v)
+ switch {
+ case b >= 1<<30:
+ return fmt.Sprintf("%.1fGB", b/(1<<30))
+ case b >= 1<<20:
+ return fmt.Sprintf("%.0fMB", b/(1<<20))
+ case b >= 1<<10:
+ return fmt.Sprintf("%.0fKB", b/(1<<10))
+ }
+ return fmt.Sprintf("%.0fB", b)
+}
diff --git a/status_instances_test.go b/status_instances_test.go
new file mode 100644
index 000000000..b94695541
--- /dev/null
+++ b/status_instances_test.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestPrintStatusInstancesLabelsPlatformAndStatesItsLimits is the CLI half of
+// what the console door promises. Two things have to survive rendering.
+//
+// A row that matched no route is the box's own overhead — the console, the
+// agent, a system unit — and it prints under its unit name and the word
+// (platform) rather than as two blank cells a reader would take for missing
+// data.
+//
+// And the table is followed, every time, by the two ways it misleads: a
+// hibernating instance has no process and so no reading, and the readings are
+// fleet-wide while the account each is attributed to comes from the console's
+// own box's registry. Neither caveat is conditional, because both are ways a
+// SHORT list reads as a complete one, and short is exactly when nobody thinks
+// to ask.
+func TestPrintStatusInstancesLabelsPlatformAndStatesItsLimits(t *testing.T) {
+ rows := []statusInstanceRow{
+ {Box: "box-a", Host: "app.example.com", Account: "cdef", App: "widgets",
+ CPU: f64(0.25), Mem: i64(1 << 20)},
+ {Box: "box-a", Unit: "console.service", Account: "bab", Platform: true},
+ }
+ var buf bytes.Buffer
+ printStatusInstances(&buf, rows, "")
+ out := buf.String()
+
+ for _, want := range []string{
+ "app.example.com", "cdef", "widgets", "0.250", "1MB",
+ "console.service", "(platform)",
+ "awake instances only",
+ "console's own box's route registry",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("missing %q in:\n%s", want, out)
+ }
+ }
+}