carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The architecture graph behind /fleet/architecture: one pure function that
2// takes a cloud inventory plus everything CARLOS knows about itself and
3// produces the page's whole view model — nodes, edges, box cards and
4// resource groups.
5//
6// Three rules run through all of it (2026-08-19-fleet-architecture-diagram-
7// design.md §6):
8//
9// - UNJOINED IS DRAWN, NEVER HIDDEN. A machine no box reports is drift, a
10// box with no machine is a customer's own hardware, a DNS record pointing
11// outside the estate is an arrow leaving the diagram, and a resource this
12// vocabulary has no word for still reaches the resource groups with its
13// native type. The resource nobody wrote code for is the one worth
14// seeing, so nothing is silently dropped.
15// - COST COMES FROM THE RATE CARD AND NOWHERE ELSE, so this page and
16// /fleet/economics can never disagree about what a box costs. The
17// discovered instance type and disk sit BESIDE the price and are used
18// only to flag drift — never to compute a second one.
19// - EVERY READ IS INDEPENDENT. A nil inventory, a nil fleet, an empty host
20// table and an empty route list each cost their own section and nothing
21// else. BuildArch never panics on any combination, including all-nil.
22//
23// BuildArch is pure — no I/O, no clock, no registry or bucket access.
24// Everything it needs arrives in ArchInput, which is what makes it
25// table-testable and what lets archlayout.go depend on it being
26// deterministic.
27package console
28
29import (
30 "fmt"
31 "html/template"
32 "sort"
33 "strconv"
34 "strings"
35 "time"
36
37 "github.com/carlosframework/platform/internal/economics"
38 "github.com/carlosframework/platform/internal/infraview"
39 "github.com/carlosframework/platform/internal/registry"
40 "github.com/carlosframework/platform/internal/status"
41)
42
43// ArchInput is every read the page makes, handed over as data. Each field is
44// independently optional: whichever reads succeeded are drawn, and the ones
45// that did not simply do not appear.
46type ArchInput struct {
47 Inv *infraview.Inventory
48 Fleet *status.Fleet
49 Hosts []HostRow
50 Routes []registry.Route
51 Rates economics.RateCard
52 // Bucket is the deployment bucket the console itself is configured with
53 // — the ONE bucket relationship the console can state as fact (spec
54 // §6.2). Empty draws no bucket edge at all: the estate may hold six
55 // buckets and guessing which one the fleet writes to would put a false
56 // arrow on a page whose whole value is that its arrows are true.
57 Bucket string
58}
59
60// KV is one detail line on a node.
61type KV struct{ K, V string }
62
63// Node is one drawn thing. X/Y/W/H are filled by layout (archlayout.go), not
64// here: BuildArch owns what the picture MEANS, layout owns where it sits.
65type Node struct {
66 ID, Kind, Title, Subtitle string
67 Detail []KV
68 // Alarms are the alarms watching THIS node's subject. They ride the
69 // node rather than standing as nodes of their own, for two reasons an
70 // earlier draft learned the hard way: a satellite column placed
71 // between the IP and the box sits underneath every IP-to-box edge, and
72 // nodes paint after edges, so the line vanished behind it and the
73 // picture read as a request flowing THROUGH the alarm; and an alarm is
74 // not a hop a request takes, it is a property of the thing it watches.
75 // The Alarms resource table still lists every one of them in full.
76 Alarms []NodeAlarm
77 // Extra are further muted lines under the subtitle, and they are why a
78 // node's height is computed rather than constant. They exist so a fact
79 // that is a PROPERTY of a node can sit on it instead of standing in the
80 // flow as a node of its own: the apps a box runs used to be their own
81 // column, six of nine cards in it reading "no apps", and that column was
82 // a fifth of the width of a picture too wide for the page.
83 Extra []string
84 X, Y, W, H int
85}
86
87// NodeAlarm is one alarm as its subject wears it: enough to say which
88// alarm and whether it is firing, and nothing else — the table below the
89// diagram carries the metric, the region and the ARN.
90type NodeAlarm struct {
91 Name string
92 State string
93 Firing bool
94}
95
96// Edge is one relationship the console can state as fact. Kind is the
97// vocabulary of spec §6.1: resolves, routes, runs, stores, watches.
98type Edge struct{ From, To, Kind string }
99
100// BoxCard is one box below the diagram: what CARLOS reports about it, what
101// the estate says it is, what the rate card says it costs, and every way
102// those three disagree.
103type BoxCard struct {
104 Label, Region, InstanceType, State string
105 Cores, MemGB, EBSGB float64
106 DiscoveredDiskGB float64
107 USDMonth float64
108 Apps []string
109 // Static/Resident/Hibernating come from the fleet aggregate and ONLY
110 // from it, so a box that read never described has no counts at all —
111 // not three zeroes. CountsKnown is that fact, and it gates every
112 // rendering of them: a zero here would be a claim about a read that
113 // never happened, two words from an Awake/Asleep that correctly says
114 // "unknown".
115 Static, Resident, Hibernating int
116 CountsKnown bool
117 Awake, Asleep *int
118 Release, Load string
119 Stale bool
120 Dark string
121 Drift []string
122 CloudID string // "" = no cloud machine behind this box
123}
124
125// ResourceGroup is one table below the diagram.
126type ResourceGroup struct {
127 Title string
128 Rows []ResourceRow
129}
130
131// ResourceRow is one resource in one of those tables.
132type ResourceRow struct{ Name, NativeType, Region, Detail string }
133
134// archGraph is a set of nodes and the edges between them — the shape every
135// pass below builds into, before anything has been dealt into a bank or a
136// band. It is unexported because it is a stage, not a thing the page shows.
137type archGraph struct {
138 Nodes []Node
139 Edges []Edge
140}
141
142// ArchBand is one region's picture: the nodes that reach a machine in that
143// region, the edges between them, and its own size. It is a whole diagram
144// on its own — laid out, sized and rendered without reference to any other
145// band — which is what lets the page stack six of them down the screen
146// instead of drawing one picture wider than the column.
147//
148// Region is the code the cloud uses ("eu-west-1") or "" for the band of
149// things that reach no machine at all; Label is how that reads to a person.
150type ArchBand struct {
151 archGraph
152 Region string
153 Label string
154 Width, Height int
155}
156
157// SVG renders this band. A method rather than a field on the page struct
158// because a band knows how to draw itself and the number of bands is not
159// known until the estate is built — the template ranges over them.
160func (b ArchBand) SVG() template.HTML { return RenderArchSVG(b) }
161
162// ArchEstate is one half of the page: its own bands, and its own tables.
163// Nothing in this type knows which half it is — layout and RenderArchSVG
164// take one BAND and have no opinion about the estate it came from, which is
165// what makes running them per band free and each picture independently
166// deterministic.
167type ArchEstate struct {
168 Bands []ArchBand
169 Groups []ResourceGroup
170}
171
172// Nodes and Edges are the estate whole again, across every band. A node
173// drawn in two bands (the deployment bucket, which every region writes to)
174// appears once per band here — the callers that count resources dedupe by
175// id, exactly as they already do for a node drawn in both banks.
176func (e ArchEstate) Nodes() []Node {
177 var out []Node
178 for _, b := range e.Bands {
179 out = append(out, b.Nodes...)
180 }
181 return out
182}
183
184func (e ArchEstate) Edges() []Edge {
185 var out []Edge
186 for _, b := range e.Bands {
187 out = append(out, b.Edges...)
188 }
189 return out
190}
191
192// ArchView is the whole page: two estates drawn one above the other, and
193// the facts that belong to neither.
194//
195// Rest is deliberately NOT called "legacy" anywhere a person can read it.
196// This console serves every CARLOS deployment, and what shares Tito's
197// account is a Kubernetes estate while what shares somebody else's will be
198// something else entirely. The page says "also in this account", which is
199// the only thing that is true in all of them.
200type ArchView struct {
201 CARLOS ArchEstate
202 Rest ArchEstate
203 Boxes []BoxCard
204 TakenAt time.Time
205 Account string
206 Partial []string
207 Missing string // "" | "never" | "ungranted" | "unreadable"
208 // Firing names every alarm currently in ALARM, with the node it watches,
209 // so the page can say so above the fold.
210 //
211 // The marks themselves ride their nodes, which is right — an alarm is a
212 // property of the thing it watches — but it made a firing alarm invisible
213 // on arrival: the marks are off to the right of a drawing wider than the
214 // window, and the Alarms table is thousands of pixels down (discussion
215 // #9). A page that knows something is on fire has to say so where the
216 // reader already is.
217 //
218 // It spans BOTH estates: the reader wants to know something is firing,
219 // not which half of the page to look in.
220 Firing []string
221}
222
223// firingAlarms collects the ALARM-state marks across the drawn nodes, in the
224// nodes' own order so the summary is as deterministic as the diagram. A node
225// drawn in more than one band (ArchEstate.Nodes) is counted once: the banner
226// names alarms, and an alarm does not fire twice for being drawn twice.
227func firingAlarms(nodes []Node) []string {
228 var out []string
229 seen := map[string]bool{}
230 for _, n := range nodes {
231 if seen[n.ID] {
232 continue
233 }
234 seen[n.ID] = true
235 for _, a := range n.Alarms {
236 if a.Firing {
237 out = append(out, a.Name+" ("+n.Title+")")
238 }
239 }
240 }
241 return out
242}
243
244// BuildArch joins the estate to the platform's own model of itself. It reads
245// as the sequence of joins spec §6.2 names, in the order a request travels:
246// boxes first (everything else needs to know which machine belongs to whom),
247// then DNS in at the left, then the boxes and their apps, then the
248// satellites.
249func BuildArch(in ArchInput) ArchView {
250 var v ArchView
251
252 if in.Inv == nil {
253 // The caller overwrites this with the reason it actually has
254 // ("ungranted", "unreadable"); "never" is the honest default for a
255 // builder handed nothing and told nothing.
256 v.Missing = "never"
257 } else {
258 v.TakenAt = in.Inv.TakenAt
259 v.Account = in.Inv.Account
260 v.Partial = in.Inv.Partial
261 }
262
263 v.Boxes = buildBoxCards(in)
264
265 // machineID -> box label, the join every later step asks about. Built
266 // once here so "does a box report this machine?" is one lookup and
267 // cannot drift between the DNS pass and the alarm pass.
268 claimed := map[string]string{}
269 for _, b := range v.Boxes {
270 if b.CloudID != "" {
271 claimed[b.CloudID] = b.Label
272 }
273 }
274
275 // The whole graph is built once, exactly as it always was, and only then
276 // dealt into two banks. Building it whole is what keeps the joins in one
277 // place: a record still finds its IP, and an alarm still finds its box,
278 // without either pass having to know which half of the account it is
279 // looking at.
280 var whole archGraph
281 addDNSNodes(&whole, in.Inv, claimed)
282 addBoxNodes(&whole, v.Boxes, in)
283 addMachineNodes(&whole, in.Inv, claimed)
284 addAlarmBadges(&whole, in.Inv, claimed)
285
286 carlos := carlosResources(in.Inv, claimed, in.Bucket)
287 bank := splitNodes(whole.Nodes, whole.Edges, carlos)
288 var forBank [2]archGraph
289 for _, n := range whole.Nodes {
290 if bank[n.ID]&estateCARLOS != 0 {
291 forBank[0].Nodes = append(forBank[0].Nodes, n)
292 }
293 if bank[n.ID]&estateRest != 0 {
294 forBank[1].Nodes = append(forBank[1].Nodes, n)
295 }
296 }
297 // Both banks get every edge and each then prunes what it does not draw.
298 // An edge whose ends are in different banks therefore survives in
299 // neither — which is why archestate_test.go asserts no such edge exists,
300 // rather than this loop quietly deciding it does not matter.
301 forBank[0].Edges = append([]Edge(nil), whole.Edges...)
302 forBank[1].Edges = append([]Edge(nil), whole.Edges...)
303
304 v.CARLOS.Groups, v.Rest.Groups = splitResourceGroups(in.Inv, carlos)
305
306 seeds := regionSeeds(in.Inv, v.Boxes)
307 for i, e := range []*ArchEstate{&v.CARLOS, &v.Rest} {
308 e.Bands = bandsFrom(forBank[i], seeds)
309 }
310
311 // After layout, for the same reason the pruning is: layout is what makes
312 // the node set final, and a summary naming an alarm on a node the diagram
313 // does not draw would send a reader looking for a mark that is not there.
314 //
315 // Both estates, CARLOS first, and each estate's bands in page order
316 // (ArchEstate.Nodes walks them so), so the order is the order the page
317 // draws them in — a firing alarm should be findable by reading downwards.
318 v.Firing = append(firingAlarms(v.CARLOS.Nodes()), firingAlarms(v.Rest.Nodes())...)
319 return v
320}
321
322// bandsFrom deals one bank's graph into its region bands and lays each one
323// out. The ORDER of the three steps is the whole of it:
324//
325// 1. drop the satellite kinds, because banding must not propagate a region
326// along an edge to a node that is not going to be drawn — a zone is
327// global and joined to records in every region, and letting it carry
328// bands would put every record in every band;
329// 2. band what is left, which is what makes an edge unable to span two
330// bands;
331// 3. lay each band out and only THEN prune its dangling edges, because
332// layout is what makes a band's node set final.
333func bandsFrom(g archGraph, seeds map[string]string) []ArchBand {
334 g.Nodes = dropSatellites(g.Nodes)
335 in := bandsOf(g.Nodes, g.Edges, seeds)
336
337 present := map[string]bool{}
338 for _, n := range g.Nodes {
339 if len(in[n.ID]) == 0 {
340 present[noRegion] = true
341 continue
342 }
343 for r := range in[n.ID] {
344 present[r] = true
345 }
346 }
347 // An estate with nothing in it still gets one empty band, so the page
348 // has a picture to draw rather than a gap where one should be — the
349 // same reason layout clamps an empty graph to a positive viewBox.
350 if len(present) == 0 {
351 present[noRegion] = true
352 }
353
354 var bands []ArchBand
355 for _, region := range bandOrder(present) {
356 b := ArchBand{Region: region, Label: bandLabel(region)}
357 for _, n := range g.Nodes {
358 set := in[n.ID]
359 if (len(set) == 0 && region == noRegion) || set[region] {
360 b.Nodes = append(b.Nodes, n)
361 }
362 }
363 b.Edges = append([]Edge(nil), g.Edges...)
364 layout(&b)
365 pruneDanglingEdges(&b)
366 bands = append(bands, b)
367 }
368 // One width for every band in an estate, so the columns line up down the
369 // page: the box column is in the same place in eu-west-1 as in
370 // us-east-1, and a reader's eye can travel down it. Layout sizes each
371 // band to its own deepest column, which left a band of two columns half
372 // the width of one with four and the stack looking ragged. Node
373 // positions do not move — X is the column index, not a fraction of the
374 // width — so this only ever widens the viewBox.
375 widest := 0
376 for _, b := range bands {
377 if b.Width > widest {
378 widest = b.Width
379 }
380 }
381 for i := range bands {
382 bands[i].Width = widest
383 }
384 return bands
385}
386
387// dropSatellites removes the kinds the page lists in a table instead of
388// drawing. Identities, firewalls, networks, disks and certificates because
389// a graph that draws every IAM role is a hairball and the resource groups
390// carry them in full; ZONES because a zone is global — Route 53 hands one
391// back with no region and records in six of them — so a drawn zone is
392// either in no band, cutting every record loose from its name, or in all of
393// them, which is six copies of one node whose only edge is to a record
394// already labelled with the zone's own name. The DNS table below still
395// lists every zone.
396//
397// Alarms are not in this list because they are no longer nodes at all —
398// they ride the node they watch (see archLayer).
399func dropSatellites(nodes []Node) []Node {
400 kept := nodes[:0]
401 for _, n := range nodes {
402 if strings.Contains(satelliteKinds, n.Kind) {
403 continue
404 }
405 kept = append(kept, n)
406 }
407 return kept
408}
409
410// pruneDanglingEdges drops any edge whose endpoints this view does not
411// draw. One rule in one place, applied last — after layout, which is what
412// decides which nodes are drawn — so every pass above can join on whatever
413// it reads without each of them checking. It matters because a
414// PARTIAL inventory is normal (infraview.Inventory.Partial): an EIP whose
415// instance read failed, or an alarm naming a machine that has since been
416// terminated, would otherwise leave an arrow pointing at nothing — and an
417// arrow pointing at nothing is exactly the kind of claim this page exists
418// to avoid making.
419func pruneDanglingEdges(v *ArchBand) {
420 drawn := make(map[string]bool, len(v.Nodes))
421 for _, n := range v.Nodes {
422 drawn[n.ID] = true
423 }
424 kept := v.Edges[:0]
425 for _, e := range v.Edges {
426 if drawn[e.From] && drawn[e.To] {
427 kept = append(kept, e)
428 }
429 }
430 v.Edges = kept
431}
432
433// ---- the joins ---------------------------------------------------------
434
435// machineFor finds the cloud machine a box runs on.
436//
437// The box's OWN report wins: status.BoxMetrics.InstanceID is the id it read
438// from the instance metadata service, and nothing else can be as sure. When
439// a box reports one, that id is the answer — a hit joins, and a MISS stays a
440// miss rather than falling through to a name match, because a box that has
441// told us which machine it is must not be quietly attached to a different
442// one. (A miss is real: an instance terminated between the box's last
443// snapshot and this inventory.)
444//
445// The Name-tag match is the fallback, and only for a box that reports no id
446// at all — an agent that predates the field, which during a roll is every
447// box until it restarts. It is a fallback rather than the rule because it
448// is a naming convention, and on the flagship that convention never held:
449// the instances are tagged carlos-flagship-ie while the boxes call
450// themselves annie-welding, so this function matched nothing at all and
451// every box on the page read as bring-your-own hardware.
452func machineFor(inv *infraview.Inventory, label string, reported string) *infraview.Resource {
453 if inv == nil {
454 return nil
455 }
456 if reported != "" {
457 for i := range inv.Resources {
458 r := &inv.Resources[i]
459 if r.Kind == infraview.KindMachine && r.ID == reported {
460 return r
461 }
462 }
463 return nil
464 }
465 if label == "" {
466 return nil
467 }
468 for i := range inv.Resources {
469 r := &inv.Resources[i]
470 if r.Kind == infraview.KindMachine && strings.EqualFold(r.Name, label) {
471 return r
472 }
473 }
474 return nil
475}
476
477// diskGBFor sums every disk attached to one machine. A size that will not
478// parse is absence, not zero — it simply does not contribute, and the card
479// then makes no disk claim rather than a false one.
480func diskGBFor(inv *infraview.Inventory, machineID string) float64 {
481 if inv == nil || machineID == "" {
482 return 0
483 }
484 var total float64
485 for _, d := range inv.ByKind(infraview.KindDisk) {
486 if d.Attr("attached_to") != machineID {
487 continue
488 }
489 if gb, err := strconv.ParseFloat(d.Attr("size_gb"), 64); err == nil {
490 total += gb
491 }
492 }
493 return total
494}
495
496// appsFor names the apps a box is running: its own RouteVersions keys are
497// its hostnames (the one place a box's real hostnames are carried), looked
498// up in the registry for the app they belong to. Distinct and sorted, so two
499// routes of one app read as one app and the order never moves.
500func appsFor(host HostRow, routes []registry.Route) []string {
501 if host.Metrics == nil || len(host.Metrics.RouteVersions) == 0 {
502 return nil
503 }
504 byHost := make(map[string]string, len(routes))
505 for _, r := range routes {
506 byHost[r.Host] = r.App
507 }
508 seen := map[string]bool{}
509 var out []string
510 for h := range host.Metrics.RouteVersions {
511 app := byHost[h]
512 if app == "" || seen[app] {
513 continue
514 }
515 seen[app] = true
516 out = append(out, app)
517 }
518 sort.Strings(out)
519 return out
520}
521
522// boxCost prices one box the way /fleet/economics prices it and no other
523// way: the rate card's own spec for that label, falling back to the ""
524// default exactly as economics.AllocateBox does. explicit is false when the
525// fallback was used, which is itself worth telling the operator — a box
526// priced by the default spec is priced by a guess.
527func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) {
528 spec, explicit = rc.Boxes[label]
529 if !explicit {
530 spec = rc.Boxes[""]
531 }
532 usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD
533 return usd, spec, explicit
534}
535
536// driftFor names every way the rate card and the estate disagree about this
537// box. Two checks only — the two this page can make honestly. Comparing the
538// instance TYPE against the card's cores/memory would need a type->shape
539// table, which is a second source of truth and out of scope (spec §10).
540func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string {
541 var out []string
542 if !explicit {
543 out = append(out, "priced by the default spec — no rate-card entry for this box")
544 }
545 if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 {
546 if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 {
547 out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB",
548 spec.EBSGB, card.DiscoveredDiskGB))
549 }
550 }
551 return out
552}
553
554// ---- box cards ---------------------------------------------------------
555
556// buildBoxCards is the union of the two reads that know about boxes: the
557// published fleet (regions, load, route counts, staleness) and the private
558// host table (release, hostnames, dark time). Either one alone still
559// produces a full set of cards, because either one alone is a legitimate
560// state of the page — the fleet read and the host read fail separately.
561func buildBoxCards(in ArchInput) []BoxCard {
562 fleetBoxes := map[string]status.FleetBox{}
563 var labels []string
564 seen := map[string]bool{}
565 add := func(label string) {
566 if label == "" || seen[label] {
567 return
568 }
569 seen[label] = true
570 labels = append(labels, label)
571 }
572 if in.Fleet != nil {
573 for _, b := range in.Fleet.Boxes {
574 fleetBoxes[b.Label] = b
575 add(b.Label)
576 }
577 }
578 hosts := map[string]HostRow{}
579 for _, h := range in.Hosts {
580 hosts[h.Label] = h
581 add(h.Label)
582 }
583 sort.Strings(labels)
584
585 cards := make([]BoxCard, 0, len(labels))
586 for _, label := range labels {
587 // described is whether the FLEET read produced a row for this box.
588 // A box the host table alone knows about — and every box when the
589 // fleet read failed — has no counts, and fb's zero value must not be
590 // mistaken for a fleet that counted zero of everything.
591 fb, described := fleetBoxes[label]
592 host := hosts[label]
593
594 card := BoxCard{
595 Label: label,
596 Region: firstNonEmpty(fb.Region, host.Region),
597 Static: fb.Routes.Static,
598 Resident: fb.Routes.Resident,
599 Hibernating: fb.Routes.Hibernating,
600 CountsKnown: described,
601 Awake: fb.Routes.Awake,
602 Asleep: fb.Routes.Asleep,
603 Stale: fb.Stale || host.Stale,
604 Dark: host.Dark,
605 Apps: appsFor(host, in.Routes),
606 }
607 if fb.Load != nil {
608 card.Load = *fb.Load
609 }
610 // Metrics is nil for a stale box by design (see HostRow) — an
611 // unreachable host's last happy numbers must never reach the page,
612 // and reading through the pointer is where that promise is kept.
613 // reportedID is the machine this box says it is. It comes from the
614 // same Metrics block as Release, so a STALE box reports neither —
615 // which is correct: a dark box's claim about which machine it was is
616 // exactly as old as its other readings, and the page falls back to
617 // the name match rather than trusting history.
618 var reportedID string
619 if host.Metrics != nil {
620 card.Release = host.Metrics.Release
621 reportedID = host.Metrics.InstanceID
622 }
623 if m := machineFor(in.Inv, label, reportedID); m != nil {
624 card.CloudID = m.ID
625 card.InstanceType = m.Attr("type")
626 card.State = m.Attr("state")
627 card.Region = firstNonEmpty(card.Region, m.Region)
628 card.DiscoveredDiskGB = diskGBFor(in.Inv, m.ID)
629 }
630
631 // A box with no cloud machine behind it is somebody else's
632 // hardware — a customer's fleet box, the Pi on a desk. The rate
633 // card prices what THIS platform pays for, so putting a monthly
634 // figure on one of those is a claim about a bill we do not pay,
635 // and "priced by the default spec" is a warning about a pricing
636 // that should not be happening at all. Both are withheld; the
637 // card still says plainly that there is no cloud machine.
638 usd, spec, explicit := boxCost(in.Rates, label)
639 if card.CloudID != "" {
640 card.USDMonth = usd
641 card.Cores, card.MemGB, card.EBSGB = spec.Cores, spec.MemGB, spec.EBSGB
642 card.Drift = driftFor(card, spec, explicit)
643 }
644 cards = append(cards, card)
645 }
646 return cards
647}
648
649// ---- nodes and edges ---------------------------------------------------
650
651// addDNSNodes draws the left of the picture: zones, their records, and where
652// each record actually points. A record whose value is an address the estate
653// does not own gets an external node — an arrow leaving the diagram, per
654// spec §6.3 — rather than being dropped, because "this name points somewhere
655// we do not run" is exactly the thing an operator opens this page to see.
656func addDNSNodes(v *archGraph, inv *infraview.Inventory, claimed map[string]string) {
657 if inv == nil {
658 return
659 }
660 zones := inv.ByKind(infraview.KindDNSZone)
661 for _, z := range zones {
662 sub := ""
663 if n := z.Attr("record_count"); n != "" {
664 sub = n + " records"
665 }
666 v.Nodes = append(v.Nodes, Node{
667 ID: "zone:" + z.ID, Kind: "zone", Title: nameOf(z), Subtitle: sub,
668 })
669 }
670 for _, ip := range inv.ByKind(infraview.KindIP) {
671 id := "ip:" + ip.ID
672 v.Nodes = append(v.Nodes, Node{
673 ID: id, Kind: "ip", Title: firstNonEmpty(ip.Attr("address"), nameOf(ip)),
674 Subtitle: ipSubtitle(ip, claimed),
675 })
676 if to := targetOf(ip.Attr("attached_to"), claimed); to != "" {
677 v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "routes"})
678 }
679 }
680 addRecordNodes(v, inv, zones, claimed)
681}
682
683// recordGroup collects every DNS record that shares a zone and a resolved
684// destination. The flagship carries one A record per app host, all pointing
685// at the same Elastic IP — without grouping that layer is dozens of nodes
686// wide and unreadable. Grouping happens only in the diagram: every record
687// still appears individually, in full, in the DNS resource group below
688// (resourceGroups walks the inventory directly and never sees a group).
689type recordGroup struct {
690 ids []string // record ids, sorted; ids[0] names the drawn node
691 names []string // record display names, sorted, for the example list
692 zone string // the raw "zone" attr this group's records share
693 typ string // record type, meaningful only when len(names) == 1
694 value string // record value, meaningful only when len(names) == 1
695 target string // edge destination: "", "ip:…", "box:…"/"machine:…", "external:…"
696}
697
698// addRecordNodes groups the estate's DNS records by (zone, resolved
699// destination) and draws one node per group, sorted by each group's lowest
700// record id — never by map iteration, which Go randomises on every run and
701// this diagram must not.
702func addRecordNodes(v *archGraph, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
703 groups := map[string]*recordGroup{}
704 for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
705 zoneName := rec.Attr("zone")
706 value := rec.Attr("value")
707 target := recordTarget(inv, value, claimed)
708 key := zoneName + "\x00" + target
709 g := groups[key]
710 if g == nil {
711 g = &recordGroup{zone: zoneName, target: target}
712 groups[key] = g
713 }
714 g.ids = append(g.ids, rec.ID)
715 g.names = append(g.names, nameOf(rec))
716 g.typ, g.value = rec.Attr("type"), value
717 }
718 keys := make([]string, 0, len(groups))
719 for k, g := range groups {
720 sort.Strings(g.ids)
721 sort.Strings(g.names)
722 keys = append(keys, k)
723 }
724 sort.Slice(keys, func(a, b int) bool {
725 return groups[keys[a]].ids[0] < groups[keys[b]].ids[0]
726 })
727
728 external := map[string]bool{}
729 for _, k := range keys {
730 g := groups[k]
731 id := "record:" + g.ids[0]
732 title, sub := recordNodeText(g)
733 v.Nodes = append(v.Nodes, Node{ID: id, Kind: "record", Title: title, Subtitle: sub})
734 for _, z := range zones {
735 if z.Name != "" && g.zone == z.Name {
736 v.Edges = append(v.Edges, Edge{From: "zone:" + z.ID, To: id, Kind: "resolves"})
737 }
738 }
739 switch {
740 case g.target == "":
741 // An absent value is absence. Nothing is claimed about it.
742 case strings.HasPrefix(g.target, "external:"):
743 val := strings.TrimPrefix(g.target, "external:")
744 if !external[val] {
745 external[val] = true
746 v.Nodes = append(v.Nodes, Node{
747 ID: g.target, Kind: "external", Title: val,
748 Subtitle: "outside this estate",
749 })
750 }
751 v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"})
752 default:
753 v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"})
754 }
755 }
756}
757
758// recordTarget is where a DNS record's value actually points: an allocated
759// IP, a machine holding that address directly, or outside the estate
760// entirely. Pure — it only reads the inventory — so the grouping pass above
761// can call it once per record before deciding which records share a
762// destination. Find refuses to match on an empty value, so an absent value
763// falls straight through to the "" case.
764func recordTarget(inv *infraview.Inventory, value string, claimed map[string]string) string {
765 if value == "" {
766 return ""
767 }
768 if ip := inv.Find(infraview.KindIP, "address", value); ip != nil {
769 return "ip:" + ip.ID
770 }
771 if m := inv.Find(infraview.KindMachine, "public_ip", value); m != nil {
772 // An address the estate owns but holds directly on a machine rather
773 // than through an allocated IP.
774 return targetOf(m.ID, claimed)
775 }
776 return "external:" + value
777}
778
779// recordNodeText is the node's label. A single record keeps exactly the
780// shape it had before grouping existed: its own name, and its type and
781// value as the subtitle. A group of more than one names the count and up to
782// three example names, sorted, with the remainder folded into a "+N".
783func recordNodeText(g *recordGroup) (title, subtitle string) {
784 if len(g.names) == 1 {
785 return g.names[0], strings.TrimSpace(g.typ + " " + g.value)
786 }
787 examples := g.names
788 if len(examples) > 3 {
789 examples = examples[:3]
790 }
791 sub := strings.Join(examples, ", ")
792 if rest := len(g.names) - len(examples); rest > 0 {
793 sub += fmt.Sprintf(", +%d", rest)
794 }
795 return fmt.Sprintf("%d records", len(g.names)), sub
796}
797
798// addBoxNodes draws every box CARLOS reports — with or without a machine
799// behind it — carrying its apps and instance counts on the card itself, and
800// the deployment-bucket edge when the caller named a bucket.
801//
802// The counts are one line per box, never one per instance: a fleet of a
803// hundred tenants must still produce a diagram a person can read (spec
804// §6.4).
805func addBoxNodes(v *archGraph, boxes []BoxCard, in ArchInput) {
806 for _, b := range boxes {
807 boxID := "box:" + b.Label
808 // The apps and the counts ride the box (Node.Extra) rather than
809 // standing as an "apps" node beside it. They were never a hop a
810 // request takes — they are what this box IS — and as a column they
811 // cost the picture 280 units of width to tell six boxes out of nine
812 // that they run nothing.
813 v.Nodes = append(v.Nodes, Node{
814 ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b),
815 Extra: []string{appsTitle(b), countsSubtitle(b)},
816 })
817 if in.Bucket != "" {
818 v.Edges = append(v.Edges, Edge{From: boxID, To: "bucket:" + in.Bucket, Kind: "stores"})
819 }
820 }
821 if in.Bucket == "" {
822 return
823 }
824 // The bucket node exists whether or not the estate read found it: the
825 // console knows this bucket first-hand, and an un-granted S3 read must
826 // not erase a relationship the console is certain of.
827 id := "bucket:" + in.Bucket
828 sub := "deployment bucket"
829 if in.Inv != nil {
830 for _, b := range in.Inv.ByKind(infraview.KindBucket) {
831 if nameOf(b) == in.Bucket && b.Region != "" {
832 sub = "deployment bucket · " + b.Region
833 }
834 }
835 }
836 v.Nodes = append(v.Nodes, Node{ID: id, Kind: "bucket", Title: in.Bucket, Subtitle: sub})
837}
838
839// addMachineNodes draws the machines NO box reports. That is the whole
840// point: an instance nobody is running CARLOS on, or a box that has gone
841// dark, is drift — and drift is only visible if it is drawn. A machine a box
842// does report is already on the page as that box, and drawing it twice would
843// say the estate holds two things where it holds one.
844func addMachineNodes(v *archGraph, inv *infraview.Inventory, claimed map[string]string) {
845 if inv == nil {
846 return
847 }
848 for _, m := range inv.ByKind(infraview.KindMachine) {
849 if claimed[m.ID] != "" {
850 continue
851 }
852 sub := "no box reporting"
853 if t := m.Attr("type"); t != "" {
854 sub = "no box reporting · " + t
855 }
856 v.Nodes = append(v.Nodes, Node{
857 ID: "machine:" + m.ID, Kind: "machine",
858 Title: firstNonEmpty(m.Name, m.ID), Subtitle: sub,
859 })
860 }
861}
862
863// addAlarmBadges hangs each alarm on whatever it watches — the box when a
864// box reports that machine, the bare machine otherwise. An alarm naming
865// nothing this page draws is still listed in the resource groups, and one
866// naming something the page DOES draw becomes a mark on that node rather
867// than a node in the flow (see Node.Alarms for why).
868func addAlarmBadges(v *archGraph, inv *infraview.Inventory, claimed map[string]string) {
869 if inv == nil {
870 return
871 }
872 at := map[string]int{}
873 for i := range v.Nodes {
874 at[v.Nodes[i].ID] = i
875 }
876 for _, a := range inv.ByKind(infraview.KindAlarm) {
877 to := targetOf(a.Attr("target"), claimed)
878 if to == "" {
879 continue
880 }
881 i, drawn := at[to]
882 if !drawn {
883 continue
884 }
885 state := a.Attr("state")
886 v.Nodes[i].Alarms = append(v.Nodes[i].Alarms, NodeAlarm{
887 Name: a.Name,
888 State: state,
889 Firing: state == "ALARM",
890 })
891 }
892}
893
894// ---- resource groups ---------------------------------------------------
895
896// archGroupOrder is the order the tables appear in, and archGroupOf maps a
897// kind onto one of them. A kind neither map knows lands in Other rather than
898// being dropped: infraview carries an unrecognised kind through verbatim
899// precisely so the page can show it.
900var archGroupOrder = []string{"Compute", "Network", "Storage", "DNS", "Identity", "Alarms", "Certificates", "Other"}
901
902var archGroupOf = map[string]string{
903 infraview.KindMachine: "Compute",
904 infraview.KindDisk: "Compute",
905 infraview.KindIP: "Network",
906 infraview.KindFirewall: "Network",
907 infraview.KindNetwork: "Network",
908 infraview.KindBucket: "Storage",
909 infraview.KindDNSZone: "DNS",
910 infraview.KindDNSRecord: "DNS",
911 infraview.KindIdentity: "Identity",
912 infraview.KindAlarm: "Alarms",
913 infraview.KindCertificate: "Certificates",
914 infraview.KindOther: "Other",
915}
916
917// splitResourceGroups deals the inventory's tables into the two banks. The
918// exactly-once promise below is now a promise across BOTH banks together:
919// every resource is in one of them, never in neither and never in both, and
920// archestate_test.go counts it.
921func splitResourceGroups(inv *infraview.Inventory, carlos map[string]bool) (inEstate, rest []ResourceGroup) {
922 if inv == nil {
923 return nil, nil
924 }
925 var mine, theirs []infraview.Resource
926 for _, r := range inv.Resources {
927 if carlos[r.ID] {
928 mine = append(mine, r)
929 } else {
930 theirs = append(theirs, r)
931 }
932 }
933 return resourceGroups(mine), resourceGroups(theirs)
934}
935
936// resourceGroups turns a set of resources into the tables below one
937// diagram. EVERY resource appears exactly once, in inventory order, and an
938// empty group is omitted rather than rendered as a confident "none".
939func resourceGroups(resources []infraview.Resource) []ResourceGroup {
940 rows := map[string][]ResourceRow{}
941 for _, r := range resources {
942 g, ok := archGroupOf[r.Kind]
943 if !ok {
944 g = "Other"
945 }
946 rows[g] = append(rows[g], ResourceRow{
947 Name: nameOf(r),
948 NativeType: firstNonEmpty(r.NativeType, r.Kind),
949 Region: r.Region,
950 Detail: detailOf(r),
951 })
952 }
953 var out []ResourceGroup
954 for _, title := range archGroupOrder {
955 if len(rows[title]) == 0 {
956 continue
957 }
958 out = append(out, ResourceGroup{Title: title, Rows: rows[title]})
959 }
960 return out
961}
962
963// detailOf renders a resource's own attributes, sorted by key, so an
964// attribute nobody wrote code for still reaches the page. The id leads when
965// it says something the Name column does not — for an `other` resource that
966// is its ARN, which is the whole of what spec §6.3 promises about them.
967func detailOf(r infraview.Resource) string {
968 keys := make([]string, 0, len(r.Attrs))
969 for k := range r.Attrs {
970 keys = append(keys, k)
971 }
972 sort.Strings(keys)
973 parts := make([]string, 0, len(keys)+1)
974 if r.ID != "" && r.ID != r.Name {
975 parts = append(parts, "id="+r.ID)
976 }
977 for _, k := range keys {
978 parts = append(parts, k+"="+r.Attrs[k])
979 }
980 return strings.Join(parts, ", ")
981}
982
983// ---- small shared helpers ----------------------------------------------
984
985// targetOf resolves a machine id to the node that stands for it: the box
986// when one reports it, the machine node otherwise. "" for an empty id — an
987// absent attribute is absence, and joining on it would attach every
988// unattached alarm to every machine with no id.
989func targetOf(machineID string, claimed map[string]string) string {
990 if machineID == "" {
991 return ""
992 }
993 if label := claimed[machineID]; label != "" {
994 return "box:" + label
995 }
996 return "machine:" + machineID
997}
998
999func ipSubtitle(ip infraview.Resource, claimed map[string]string) string {
1000 to := ip.Attr("attached_to")
1001 if to == "" {
1002 return "attached to nothing"
1003 }
1004 if label := claimed[to]; label != "" {
1005 return label
1006 }
1007 return to
1008}
1009
1010func boxSubtitle(b BoxCard) string {
1011 if b.CloudID == "" {
1012 return "bring-your-own box"
1013 }
1014 return strings.TrimSpace(b.InstanceType + " " + b.State)
1015}
1016
1017func appsTitle(b BoxCard) string {
1018 if len(b.Apps) == 0 {
1019 return "no apps"
1020 }
1021 return strings.Join(b.Apps, ", ")
1022}
1023
1024// countsSubtitle renders the box's instance counts. Two absences, both of
1025// which must read as absence: a box the fleet read never described has NO
1026// counts (three zeroes would state a read that never happened), and Awake
1027// and Asleep are *int whose null means the socket table was unreadable.
1028// "unknown", never 0, because 0 is a claim.
1029//
1030// The unknown form is deliberately short: this string is a subtitle inside a
1031// 190-unit node rect, and the long form already overruns it.
1032func countsSubtitle(b BoxCard) string {
1033 if !b.CountsKnown {
1034 return "counts unknown"
1035 }
1036 return fmt.Sprintf("static %d · resident %d · hibernating %d · awake %s · asleep %s",
1037 b.Static, b.Resident, b.Hibernating, countOrUnknown(b.Awake), countOrUnknown(b.Asleep))
1038}
1039
1040func countOrUnknown(n *int) string {
1041 if n == nil {
1042 return "unknown"
1043 }
1044 return strconv.Itoa(*n)
1045}
1046
1047// nameOf is a resource's display name, falling back to its id — every
1048// resource has an id, not every provider gives everything a name.
1049func nameOf(r infraview.Resource) string { return firstNonEmpty(r.Name, r.ID) }
1050
1051func firstNonEmpty(vals ...string) string {
1052 for _, v := range vals {
1053 if v != "" {
1054 return v
1055 }
1056 }
1057 return ""
1058}
1059