carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// `carlos status --instances` — who is using what right now (discussion #11).
4//
5// The question "which account is using what on this box?" had no terminal
6// answer. Every door holding it gated on a browser session, and the last
7// person who needed it read four boxes' metrics objects and nine accounts'
8// usage objects out of the bucket with `aws s3 cp` — which AGENTS.md's
9// "operate via API and CLI" rule names as a product gap rather than a path.
10//
11// Console-backed, unlike `--live`. `--live` reads the status bucket with the
12// operator's own bucket credentials, which is exactly the move this verb
13// exists to make unnecessary: the console already sweeps the per-unit
14// readings for its own browser page, and the account each unit belongs to is
15// a registry join only the console can do.
16
17import (
18 "context"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "net/http"
24 "net/url"
25 "sort"
26 "strings"
27)
28
29// statusInstanceRow is one awake instance, as the console answers it
30// (internal/console/apistatusinstances.go). Declared here rather than
31// imported, the convention every other CLI-side wire struct follows.
32//
33// CPU and Mem are pointers because a missing reading is a real state — a unit
34// seen for the first time has no CPU delta yet — and printing it as 0 would
35// be printing a number an operator might act on.
36type statusInstanceRow struct {
37 Box string `json:"box"`
38 Unit string `json:"unit"`
39 Host string `json:"host"`
40 Account string `json:"account"`
41 App string `json:"app"`
42 CPU *float64 `json:"cpu"`
43 Mem *int64 `json:"mem_bytes"`
44 Platform bool `json:"platform"`
45}
46
47func (s *apiSession) statusInstanceList(ctx context.Context, account string) ([]statusInstanceRow, error) {
48 path := "/api/cli/status/instances"
49 if account != "" {
50 path += "?account=" + url.QueryEscape(account)
51 }
52 status, body, err := s.getJSON(ctx, path)
53 if err != nil {
54 return nil, err
55 }
56 if status != http.StatusOK {
57 if status == http.StatusNotFound {
58 // The same bare 404 an unknown route gives, and deliberately so:
59 // the answer names other people's accounts, so the refusal says
60 // nothing about them and nothing about who holds the operator
61 // bit. Which leaves this message covering both readings, because
62 // the caller genuinely cannot tell them apart from here.
63 return nil, errors.New("not found — this needs the fleet-operator bit on your account, " +
64 "or this console predates `carlos status --instances` (deploy a console built from current main)")
65 }
66 return nil, apiErrorFromBody(status, body)
67 }
68 var out struct {
69 Instances []statusInstanceRow `json:"instances"`
70 }
71 if err := json.Unmarshal(body, &out); err != nil {
72 return nil, fmt.Errorf("status instances: unreadable response: %w", err)
73 }
74 return out.Instances, nil
75}
76
77func statusInstances(w io.Writer, consoleFlag, account string) error {
78 sess, err := resolveMode(consoleFlag)
79 if err != nil {
80 return err
81 }
82 if sess == nil {
83 return errors.New("carlos status --instances works through a console, and this shell is in direct bucket mode\n" +
84 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run:\n" +
85 "carlos auth login --console <url>")
86 }
87 rows, err := sess.statusInstanceList(context.Background(), account)
88 if err != nil {
89 return fmt.Errorf("status --instances: %w", err)
90 }
91 printStatusInstances(w, rows, account)
92 return nil
93}
94
95func printStatusInstances(w io.Writer, rows []statusInstanceRow, account string) {
96 if len(rows) == 0 {
97 if account != "" {
98 mutedf(w, "no awake instances for %s right now", account)
99 } else {
100 mutedf(w, "no awake instances right now")
101 }
102 return
103 }
104 t := newTable(w)
105 fmt.Fprintln(t, "BOX\tHOST\tACCOUNT\tAPP\tCPU\tMEM")
106 for _, r := range rows {
107 host, app := r.Host, r.App
108 if r.Platform {
109 // A unit that matched no route: the console's own unit, the
110 // agent, system units. Named as what it is rather than left as
111 // two blank cells a reader would take for missing data — an
112 // operator asking what is on the box wants the box's own
113 // overhead in the picture.
114 host, app = r.Unit, "(platform)"
115 }
116 fmt.Fprintf(t, "%s\t%s\t%s\t%s\t%s\t%s\n",
117 r.Box, dashIfEmpty(host), dashIfEmpty(r.Account), dashIfEmpty(app),
118 cpuCell(r.CPU), memCell(r.Mem))
119 }
120 t.Flush()
121 // Both said every time, because both are ways a short list misleads.
122 //
123 // A hibernating instance has no process, so it has no cgroup and no
124 // reading — it is not in this list and its absence is not evidence that
125 // it does not exist. The question this verb answers is "right now", and
126 // that limit belongs next to the answer.
127 mutedf(w, "(awake instances only — a hibernating instance has no process to measure)")
128 // The readings are fleet-wide, but the console joins them against its own
129 // box's route registry, so a unit on any other box matches nothing and
130 // lands under (platform) rather than under its tenant. Inert on a
131 // single-box deployment and fixed structurally by PR #137; until then an
132 // operator reading this for "who is using what" has to be told, the same
133 // way `carlos routes` says whose routes it is showing.
134 mutedf(w, "(accounts are joined against the console's own box's route registry; "+
135 "an instance on another box in a fleet can land under (platform))")
136}
137
138// statusLayoutRow is one host's layout, as the console answers it
139// (internal/console/apistatuslayout.go). Declared here rather than
140// imported, the same wire-struct convention statusInstanceRow above
141// follows.
142type statusLayoutRow struct {
143 Host string `json:"host"`
144 Layout string `json:"layout"`
145 Reason string `json:"reason"`
146 Note string `json:"note"`
147}
148
149// statusLayoutBox is one box's layout report.
150type statusLayoutBox struct {
151 Box string `json:"box"`
152 Mode string `json:"mode"`
153 Stale bool `json:"stale"`
154 Rows []statusLayoutRow `json:"rows"`
155 Mixed bool `json:"mixed"`
156 // Default: nested by the deployment's default, not a recorded intent;
157 // DefaultBlocked: why the default is not in force on this box.
158 Default bool `json:"default"`
159 DefaultBlocked string `json:"default_blocked"`
160}
161
162func (s *apiSession) statusLayoutList(ctx context.Context) ([]statusLayoutBox, error) {
163 status, body, err := s.getJSON(ctx, "/api/cli/status/layout")
164 if err != nil {
165 return nil, err
166 }
167 if status != http.StatusOK {
168 if status == http.StatusNotFound {
169 // The same bare 404 an unknown route gives, and for the same
170 // reason statusInstanceList's own comment gives: the answer
171 // names every box's own layout, so the refusal must say
172 // nothing about whether the caller holds the operator bit.
173 return nil, errors.New("not found. this needs the fleet-operator bit on your account, " +
174 "or this console predates `carlos status --layout` (deploy a console built from current main)")
175 }
176 return nil, apiErrorFromBody(status, body)
177 }
178 var out struct {
179 Boxes []statusLayoutBox `json:"boxes"`
180 }
181 if err := json.Unmarshal(body, &out); err != nil {
182 return nil, fmt.Errorf("status layout: unreadable response: %w", err)
183 }
184 return out.Boxes, nil
185}
186
187func statusLayout(w io.Writer, consoleFlag string) error {
188 sess, err := resolveMode(consoleFlag)
189 if err != nil {
190 return err
191 }
192 if sess == nil {
193 return errors.New("carlos status --layout works through a console, and this shell is in direct bucket mode\n" +
194 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run:\n" +
195 "carlos auth login --console <url>")
196 }
197 boxes, err := sess.statusLayoutList(context.Background())
198 if err != nil {
199 return fmt.Errorf("status --layout: %w", err)
200 }
201 printStatusLayout(w, boxes)
202 return nil
203}
204
205// printStatusLayout renders the door's answer as one row per host, sorted
206// by box then host so two runs diff on the fleet rather than on request
207// order — the door itself sorts, but a caller's own render must not depend
208// on that staying true. The heading is the approved copy verbatim; rows are
209// laid out to match it rather than through a tabwriter, whose dynamic
210// column widths would drift the heading away from what was reviewed the
211// moment any cell is wider than its header word.
212func printStatusLayout(w io.Writer, boxes []statusLayoutBox) {
213 type line struct{ box, host, layout, reason string }
214 var lines []line
215 for _, b := range boxes {
216 name := b.Box
217 if b.Stale {
218 name += " (stale)"
219 }
220 // Nested by default is said on the box, not implied by its rows:
221 // an operator reading nested rows must be able to tell a box that
222 // was migrated on purpose from one that moved on its own.
223 if b.Default {
224 name += " (nested by default)"
225 }
226 for _, r := range b.Rows {
227 // A note is an event, not a refusal; it shares the column so
228 // the heading stays the approved one.
229 reason := r.Reason
230 if r.Note != "" {
231 if reason != "" {
232 reason += "; "
233 }
234 reason += r.Note
235 }
236 lines = append(lines, line{name, r.Host, r.Layout, reason})
237 }
238 }
239 if len(lines) == 0 {
240 mutedf(w, "no boxes have reported a layout yet")
241 return
242 }
243 sort.Slice(lines, func(i, j int) bool {
244 if lines[i].box != lines[j].box {
245 return lines[i].box < lines[j].box
246 }
247 return lines[i].host < lines[j].host
248 })
249 fmt.Fprintln(w, "BOX HOST LAYOUT REASON")
250 for _, l := range lines {
251 fmt.Fprintf(w, "%s %s %s %s\n", l.box, l.host, l.layout, dashIfEmpty(l.reason))
252 }
253 // A box with flat, foreign or external rows beside nested ones, or
254 // under a nested intent, is the loud case: each of those still shares
255 // the agent's uid and mount namespace, so the nested rows on that box
256 // are not isolated from it yet.
257 for _, b := range boxes {
258 if b.Mixed {
259 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)
260 }
261 }
262 // A box the default has not reached says why, once per box, so an
263 // operator waiting for a fleet to move on its own can see what each
264 // box is waiting for.
265 for _, b := range boxes {
266 if b.DefaultBlocked != "" && !b.Stale {
267 fmt.Fprintf(w, "\n%s is not nested by default: %s\n", b.Box, b.DefaultBlocked)
268 }
269 }
270 // Boxes that disagree about their mode are the other loud case: a
271 // box-scoped intent covers the boxes it names, and an instance placed
272 // or relocated onto a box outside it is minted flat there (#126, the
273 // Region HA 1b note). Said once, naming both sides.
274 if nested, other := layoutModeSplit(boxes); len(nested) > 0 && len(other) > 0 {
275 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",
276 boxList(nested), boxList(other))
277 }
278}
279
280// layoutModeSplit sorts the boxes by mode: nested, and anything else
281// (flat, none, unreadable). Stale boxes are left out: their mode is what
282// they last said, not what they are.
283func layoutModeSplit(boxes []statusLayoutBox) (nested, other []string) {
284 for _, b := range boxes {
285 if b.Stale {
286 continue
287 }
288 if b.Mode == "nested" {
289 nested = append(nested, b.Box)
290 } else {
291 other = append(other, b.Box)
292 }
293 }
294 sort.Strings(nested)
295 sort.Strings(other)
296 return nested, other
297}
298
299// boxList is "kass-1 is" or "kass-1 and kass-2 are", for a sentence.
300func boxList(labels []string) string {
301 if len(labels) == 1 {
302 return labels[0] + " is"
303 }
304 return strings.Join(labels[:len(labels)-1], ", ") + " and " + labels[len(labels)-1] + " are"
305}
306
307// cpuCell renders a fraction of one core. A nil reading prints as a dash: the
308// unit was sampled but has no CPU delta yet, which is not the same as idle,
309// and 0.000 is a claim about the process rather than about the sample.
310func cpuCell(v *float64) string {
311 if v == nil {
312 return "—"
313 }
314 return fmt.Sprintf("%.3f", *v)
315}
316
317// memCell renders bytes the way an operator scans them. Same nil rule.
318func memCell(v *int64) string {
319 if v == nil {
320 return "—"
321 }
322 b := float64(*v)
323 switch {
324 case b >= 1<<30:
325 return fmt.Sprintf("%.1fGB", b/(1<<30))
326 case b >= 1<<20:
327 return fmt.Sprintf("%.0fMB", b/(1<<20))
328 case b >= 1<<10:
329 return fmt.Sprintf("%.0fKB", b/(1<<10))
330 }
331 return fmt.Sprintf("%.0fB", b)
332}
333