Merge branch 'status-instances' into main
9 files changed,
+648
−5
cli.go+28 −0docs/site/cli.md+22 −0internal/console/apistatusinstances.go+162 −0internal/console/apistatusinstances_test.go+198 −0internal/console/authcli.go+7 −0internal/console/economicspass.go+16 −3internal/console/svcroutes_test.go+11 −2status_instances.go+161 −0status_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) |
| + } |
| + } |
| +} |