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" "sort" "strings" ) // 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 ") } 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))") } // statusLayoutRow is one host's layout, as the console answers it // (internal/console/apistatuslayout.go). Declared here rather than // imported, the same wire-struct convention statusInstanceRow above // follows. type statusLayoutRow struct { Host string `json:"host"` Layout string `json:"layout"` Reason string `json:"reason"` Note string `json:"note"` } // statusLayoutBox is one box's layout report. type statusLayoutBox struct { Box string `json:"box"` Mode string `json:"mode"` Stale bool `json:"stale"` Rows []statusLayoutRow `json:"rows"` Mixed bool `json:"mixed"` // Default: nested by the deployment's default, not a recorded intent; // DefaultBlocked: why the default is not in force on this box. Default bool `json:"default"` DefaultBlocked string `json:"default_blocked"` } func (s *apiSession) statusLayoutList(ctx context.Context) ([]statusLayoutBox, error) { status, body, err := s.getJSON(ctx, "/api/cli/status/layout") if err != nil { return nil, err } if status != http.StatusOK { if status == http.StatusNotFound { // The same bare 404 an unknown route gives, and for the same // reason statusInstanceList's own comment gives: the answer // names every box's own layout, so the refusal must say // nothing about whether the caller holds the operator bit. return nil, errors.New("not found. this needs the fleet-operator bit on your account, " + "or this console predates `carlos status --layout` (deploy a console built from current main)") } return nil, apiErrorFromBody(status, body) } var out struct { Boxes []statusLayoutBox `json:"boxes"` } if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("status layout: unreadable response: %w", err) } return out.Boxes, nil } func statusLayout(w io.Writer, consoleFlag string) error { sess, err := resolveMode(consoleFlag) if err != nil { return err } if sess == nil { return errors.New("carlos status --layout 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 ") } boxes, err := sess.statusLayoutList(context.Background()) if err != nil { return fmt.Errorf("status --layout: %w", err) } printStatusLayout(w, boxes) return nil } // printStatusLayout renders the door's answer as one row per host, sorted // by box then host so two runs diff on the fleet rather than on request // order — the door itself sorts, but a caller's own render must not depend // on that staying true. The heading is the approved copy verbatim; rows are // laid out to match it rather than through a tabwriter, whose dynamic // column widths would drift the heading away from what was reviewed the // moment any cell is wider than its header word. func printStatusLayout(w io.Writer, boxes []statusLayoutBox) { type line struct{ box, host, layout, reason string } var lines []line for _, b := range boxes { name := b.Box if b.Stale { name += " (stale)" } // Nested by default is said on the box, not implied by its rows: // an operator reading nested rows must be able to tell a box that // was migrated on purpose from one that moved on its own. if b.Default { name += " (nested by default)" } for _, r := range b.Rows { // A note is an event, not a refusal; it shares the column so // the heading stays the approved one. reason := r.Reason if r.Note != "" { if reason != "" { reason += "; " } reason += r.Note } lines = append(lines, line{name, r.Host, r.Layout, reason}) } } if len(lines) == 0 { mutedf(w, "no boxes have reported a layout yet") return } sort.Slice(lines, func(i, j int) bool { if lines[i].box != lines[j].box { return lines[i].box < lines[j].box } return lines[i].host < lines[j].host }) fmt.Fprintln(w, "BOX HOST LAYOUT REASON") for _, l := range lines { fmt.Fprintf(w, "%s %s %s %s\n", l.box, l.host, l.layout, dashIfEmpty(l.reason)) } // A box with flat, foreign or external rows beside nested ones, or // under a nested intent, is the loud case: each of those still shares // the agent's uid and mount namespace, so the nested rows on that box // are not isolated from it yet. for _, b := range boxes { if b.Mixed { fmt.Fprintf(w, "\nWARNING: %s runs nested instances beside flat, foreign or external ones, which share the agent's user and its view of /data. Nested isolation on this box is not in force until none of those remains.\n", b.Box) } } // A box the default has not reached says why, once per box, so an // operator waiting for a fleet to move on its own can see what each // box is waiting for. for _, b := range boxes { if b.DefaultBlocked != "" && !b.Stale { fmt.Fprintf(w, "\n%s is not nested by default: %s\n", b.Box, b.DefaultBlocked) } } // Boxes that disagree about their mode are the other loud case: a // box-scoped intent covers the boxes it names, and an instance placed // or relocated onto a box outside it is minted flat there (#126, the // Region HA 1b note). Said once, naming both sides. if nested, other := layoutModeSplit(boxes); len(nested) > 0 && len(other) > 0 { fmt.Fprintf(w, "\nWARNING: %s nested while %s not: an instance placed or moved onto a box that is not nested starts flat there. Run carlos system layout migrate without --box once every box is ready.\n", boxList(nested), boxList(other)) } } // layoutModeSplit sorts the boxes by mode: nested, and anything else // (flat, none, unreadable). Stale boxes are left out: their mode is what // they last said, not what they are. func layoutModeSplit(boxes []statusLayoutBox) (nested, other []string) { for _, b := range boxes { if b.Stale { continue } if b.Mode == "nested" { nested = append(nested, b.Box) } else { other = append(other, b.Box) } } sort.Strings(nested) sort.Strings(other) return nested, other } // boxList is "kass-1 is" or "kass-1 and kass-2 are", for a sentence. func boxList(labels []string) string { if len(labels) == 1 { return labels[0] + " is" } return strings.Join(labels[:len(labels)-1], ", ") + " and " + labels[len(labels)-1] + " are" } // 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) }