carlos / platform Public

console: architecture graph joins, box cards, rate-card cost and drift

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 45e3aefcf06626d1ee23cfb6157f99e06f581ca8 parent 48b9f29
2 files changed, +987 −0
  • internal/console/archgraph.go +620 −0
  • internal/console/archgraph_test.go +367 −0
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go
new file mode 100644
index 000000000..4db0a14e5
--- /dev/null
+++ b/internal/console/archgraph.go
@@ -0,0 +1,620 @@
+// The architecture graph behind /fleet/architecture: one pure function that
+// takes a cloud inventory plus everything CARLOS knows about itself and
+// produces the page's whole view model — nodes, edges, box cards and
+// resource groups.
+//
+// Three rules run through all of it (2026-08-19-fleet-architecture-diagram-
+// design.md §6):
+//
+// - UNJOINED IS DRAWN, NEVER HIDDEN. A machine no box reports is drift, a
+// box with no machine is a customer's own hardware, a DNS record pointing
+// outside the estate is an arrow leaving the diagram, and a resource this
+// vocabulary has no word for still reaches the resource groups with its
+// native type. The resource nobody wrote code for is the one worth
+// seeing, so nothing is silently dropped.
+// - COST COMES FROM THE RATE CARD AND NOWHERE ELSE, so this page and
+// /fleet/economics can never disagree about what a box costs. The
+// discovered instance type and disk sit BESIDE the price and are used
+// only to flag drift — never to compute a second one.
+// - EVERY READ IS INDEPENDENT. A nil inventory, a nil fleet, an empty host
+// table and an empty route list each cost their own section and nothing
+// else. BuildArch never panics on any combination, including all-nil.
+//
+// BuildArch is pure — no I/O, no clock, no registry or bucket access.
+// Everything it needs arrives in ArchInput, which is what makes it
+// table-testable and what lets archlayout.go depend on it being
+// deterministic.
+package console
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// ArchInput is every read the page makes, handed over as data. Each field is
+// independently optional: whichever reads succeeded are drawn, and the ones
+// that did not simply do not appear.
+type ArchInput struct {
+ Inv *infraview.Inventory
+ Fleet *status.Fleet
+ Hosts []HostRow
+ Routes []registry.Route
+ Rates economics.RateCard
+ // Bucket is the deployment bucket the console itself is configured with
+ // — the ONE bucket relationship the console can state as fact (spec
+ // §6.2). Empty draws no bucket edge at all: the estate may hold six
+ // buckets and guessing which one the fleet writes to would put a false
+ // arrow on a page whose whole value is that its arrows are true.
+ Bucket string
+}
+
+// KV is one detail line on a node.
+type KV struct{ K, V string }
+
+// Node is one drawn thing. X/Y/W/H are filled by layout (archlayout.go), not
+// here: BuildArch owns what the picture MEANS, layout owns where it sits.
+type Node struct {
+ ID, Kind, Title, Subtitle string
+ Detail []KV
+ X, Y, W, H int
+}
+
+// Edge is one relationship the console can state as fact. Kind is the
+// vocabulary of spec §6.1: resolves, routes, runs, stores, watches.
+type Edge struct{ From, To, Kind string }
+
+// BoxCard is one box below the diagram: what CARLOS reports about it, what
+// the estate says it is, what the rate card says it costs, and every way
+// those three disagree.
+type BoxCard struct {
+ Label, Region, InstanceType, State string
+ Cores, MemGB, EBSGB float64
+ DiscoveredDiskGB float64
+ USDMonth float64
+ Apps []string
+ Static, Resident, Hibernating int
+ Awake, Asleep *int
+ Release, Load string
+ Stale bool
+ Dark string
+ Drift []string
+ CloudID string // "" = no cloud machine behind this box
+}
+
+// ResourceGroup is one table below the diagram.
+type ResourceGroup struct {
+ Title string
+ Rows []ResourceRow
+}
+
+// ResourceRow is one resource in one of those tables.
+type ResourceRow struct{ Name, NativeType, Region, Detail string }
+
+// ArchView is the whole page.
+type ArchView struct {
+ Nodes []Node
+ Edges []Edge
+ Width, Height int
+ Boxes []BoxCard
+ Groups []ResourceGroup
+ TakenAt time.Time
+ Account string
+ Partial []string
+ Missing string // "" | "never" | "ungranted" | "unreadable"
+}
+
+// BuildArch joins the estate to the platform's own model of itself. It reads
+// as the sequence of joins spec §6.2 names, in the order a request travels:
+// boxes first (everything else needs to know which machine belongs to whom),
+// then DNS in at the left, then the boxes and their apps, then the
+// satellites.
+func BuildArch(in ArchInput) ArchView {
+ var v ArchView
+
+ if in.Inv == nil {
+ // The caller overwrites this with the reason it actually has
+ // ("ungranted", "unreadable"); "never" is the honest default for a
+ // builder handed nothing and told nothing.
+ v.Missing = "never"
+ } else {
+ v.TakenAt = in.Inv.TakenAt
+ v.Account = in.Inv.Account
+ v.Partial = in.Inv.Partial
+ }
+
+ v.Boxes = buildBoxCards(in)
+
+ // machineID -> box label, the join every later step asks about. Built
+ // once here so "does a box report this machine?" is one lookup and
+ // cannot drift between the DNS pass and the alarm pass.
+ claimed := map[string]string{}
+ for _, b := range v.Boxes {
+ if b.CloudID != "" {
+ claimed[b.CloudID] = b.Label
+ }
+ }
+
+ addDNSNodes(&v, in.Inv, claimed)
+ addBoxNodes(&v, in, claimed)
+ addMachineNodes(&v, in.Inv, claimed)
+ addAlarmNodes(&v, in.Inv, claimed)
+ v.Groups = resourceGroups(in.Inv)
+ return v
+}
+
+// ---- the joins ---------------------------------------------------------
+
+// machineFor matches a machine's Name tag against a box label, case-
+// insensitively and exactly — the Name tag is set to the host's name and the
+// box label IS the box's hostname, so this join needs no convention beyond
+// the one the module already follows. Returns nil when nothing matches,
+// which is the BYO-box case and not an error.
+func machineFor(inv *infraview.Inventory, label string) *infraview.Resource {
+ if inv == nil || label == "" {
+ return nil
+ }
+ for i := range inv.Resources {
+ r := &inv.Resources[i]
+ if r.Kind == infraview.KindMachine && strings.EqualFold(r.Name, label) {
+ return r
+ }
+ }
+ return nil
+}
+
+// diskGBFor sums every disk attached to one machine. A size that will not
+// parse is absence, not zero — it simply does not contribute, and the card
+// then makes no disk claim rather than a false one.
+func diskGBFor(inv *infraview.Inventory, machineID string) float64 {
+ if inv == nil || machineID == "" {
+ return 0
+ }
+ var total float64
+ for _, d := range inv.ByKind(infraview.KindDisk) {
+ if d.Attr("attached_to") != machineID {
+ continue
+ }
+ if gb, err := strconv.ParseFloat(d.Attr("size_gb"), 64); err == nil {
+ total += gb
+ }
+ }
+ return total
+}
+
+// appsFor names the apps a box is running: its own RouteVersions keys are
+// its hostnames (the one place a box's real hostnames are carried), looked
+// up in the registry for the app they belong to. Distinct and sorted, so two
+// routes of one app read as one app and the order never moves.
+func appsFor(host HostRow, routes []registry.Route) []string {
+ if host.Metrics == nil || len(host.Metrics.RouteVersions) == 0 {
+ return nil
+ }
+ byHost := make(map[string]string, len(routes))
+ for _, r := range routes {
+ byHost[r.Host] = r.App
+ }
+ seen := map[string]bool{}
+ var out []string
+ for h := range host.Metrics.RouteVersions {
+ app := byHost[h]
+ if app == "" || seen[app] {
+ continue
+ }
+ seen[app] = true
+ out = append(out, app)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// boxCost prices one box the way /fleet/economics prices it and no other
+// way: the rate card's own spec for that label, falling back to the ""
+// default exactly as economics.AllocateBox does. explicit is false when the
+// fallback was used, which is itself worth telling the operator — a box
+// priced by the default spec is priced by a guess.
+func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) {
+ spec, explicit = rc.Boxes[label]
+ if !explicit {
+ spec = rc.Boxes[""]
+ }
+ usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD
+ return usd, spec, explicit
+}
+
+// driftFor names every way the rate card and the estate disagree about this
+// box. Two checks only — the two this page can make honestly. Comparing the
+// instance TYPE against the card's cores/memory would need a type->shape
+// table, which is a second source of truth and out of scope (spec §10).
+func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string {
+ var out []string
+ if !explicit {
+ out = append(out, "priced by the default spec — no rate-card entry for this box")
+ }
+ if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 {
+ if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 {
+ out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB",
+ spec.EBSGB, card.DiscoveredDiskGB))
+ }
+ }
+ return out
+}
+
+// ---- box cards ---------------------------------------------------------
+
+// buildBoxCards is the union of the two reads that know about boxes: the
+// published fleet (regions, load, route counts, staleness) and the private
+// host table (release, hostnames, dark time). Either one alone still
+// produces a full set of cards, because either one alone is a legitimate
+// state of the page — the fleet read and the host read fail separately.
+func buildBoxCards(in ArchInput) []BoxCard {
+ fleetBoxes := map[string]status.FleetBox{}
+ var labels []string
+ seen := map[string]bool{}
+ add := func(label string) {
+ if label == "" || seen[label] {
+ return
+ }
+ seen[label] = true
+ labels = append(labels, label)
+ }
+ if in.Fleet != nil {
+ for _, b := range in.Fleet.Boxes {
+ fleetBoxes[b.Label] = b
+ add(b.Label)
+ }
+ }
+ hosts := map[string]HostRow{}
+ for _, h := range in.Hosts {
+ hosts[h.Label] = h
+ add(h.Label)
+ }
+ sort.Strings(labels)
+
+ cards := make([]BoxCard, 0, len(labels))
+ for _, label := range labels {
+ fb := fleetBoxes[label]
+ host := hosts[label]
+
+ card := BoxCard{
+ Label: label,
+ Region: firstNonEmpty(fb.Region, host.Region),
+ Static: fb.Routes.Static,
+ Resident: fb.Routes.Resident,
+ Hibernating: fb.Routes.Hibernating,
+ Awake: fb.Routes.Awake,
+ Asleep: fb.Routes.Asleep,
+ Stale: fb.Stale || host.Stale,
+ Dark: host.Dark,
+ Apps: appsFor(host, in.Routes),
+ }
+ if fb.Load != nil {
+ card.Load = *fb.Load
+ }
+ // Metrics is nil for a stale box by design (see HostRow) — an
+ // unreachable host's last happy numbers must never reach the page,
+ // and reading through the pointer is where that promise is kept.
+ if host.Metrics != nil {
+ card.Release = host.Metrics.Release
+ }
+ if m := machineFor(in.Inv, label); m != nil {
+ card.CloudID = m.ID
+ card.InstanceType = m.Attr("type")
+ card.State = m.Attr("state")
+ card.Region = firstNonEmpty(card.Region, m.Region)
+ card.DiscoveredDiskGB = diskGBFor(in.Inv, m.ID)
+ }
+
+ usd, spec, explicit := boxCost(in.Rates, label)
+ card.USDMonth = usd
+ card.Cores, card.MemGB, card.EBSGB = spec.Cores, spec.MemGB, spec.EBSGB
+ card.Drift = driftFor(card, spec, explicit)
+ cards = append(cards, card)
+ }
+ return cards
+}
+
+// ---- nodes and edges ---------------------------------------------------
+
+// addDNSNodes draws the left of the picture: zones, their records, and where
+// each record actually points. A record whose value is an address the estate
+// does not own gets an external node — an arrow leaving the diagram, per
+// spec §6.3 — rather than being dropped, because "this name points somewhere
+// we do not run" is exactly the thing an operator opens this page to see.
+func addDNSNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+ if inv == nil {
+ return
+ }
+ zones := inv.ByKind(infraview.KindDNSZone)
+ for _, z := range zones {
+ sub := ""
+ if n := z.Attr("record_count"); n != "" {
+ sub = n + " records"
+ }
+ v.Nodes = append(v.Nodes, Node{
+ ID: "zone:" + z.ID, Kind: "zone", Title: nameOf(z), Subtitle: sub,
+ })
+ }
+ for _, ip := range inv.ByKind(infraview.KindIP) {
+ id := "ip:" + ip.ID
+ v.Nodes = append(v.Nodes, Node{
+ ID: id, Kind: "ip", Title: firstNonEmpty(ip.Attr("address"), nameOf(ip)),
+ Subtitle: ipSubtitle(ip, claimed),
+ })
+ if to := targetOf(ip.Attr("attached_to"), claimed); to != "" {
+ v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "routes"})
+ }
+ }
+ external := map[string]bool{}
+ for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
+ id := "record:" + rec.ID
+ v.Nodes = append(v.Nodes, Node{
+ ID: id, Kind: "record", Title: nameOf(rec),
+ Subtitle: strings.TrimSpace(rec.Attr("type") + " " + rec.Attr("value")),
+ })
+ for _, z := range zones {
+ if z.Name != "" && rec.Attr("zone") == z.Name {
+ v.Edges = append(v.Edges, Edge{From: "zone:" + z.ID, To: id, Kind: "resolves"})
+ }
+ }
+ // Find refuses to match on an empty value, so an absent record value
+ // falls straight through both joins — and then out of the estate
+ // check below, which only fires on a value there is one of.
+ value := rec.Attr("value")
+ ip := inv.Find(infraview.KindIP, "address", value)
+ machine := inv.Find(infraview.KindMachine, "public_ip", value)
+ switch {
+ case value == "":
+ // An absent value is absence. Nothing is claimed about it.
+ case ip != nil:
+ v.Edges = append(v.Edges, Edge{From: id, To: "ip:" + ip.ID, Kind: "resolves"})
+ case machine != nil:
+ // An address the estate owns but holds directly on a machine
+ // rather than through an allocated IP.
+ v.Edges = append(v.Edges, Edge{From: id, To: targetOf(machine.ID, claimed), Kind: "resolves"})
+ default:
+ ext := "external:" + value
+ if !external[value] {
+ external[value] = true
+ v.Nodes = append(v.Nodes, Node{
+ ID: ext, Kind: "external", Title: value,
+ Subtitle: "outside this estate",
+ })
+ }
+ v.Edges = append(v.Edges, Edge{From: id, To: ext, Kind: "resolves"})
+ }
+ }
+}
+
+// addBoxNodes draws every box CARLOS reports — with or without a machine
+// behind it — plus the one apps node per box that carries its instance
+// counts, and the deployment-bucket edge when the caller named a bucket.
+//
+// One apps node per box, never one per instance: a fleet of a hundred
+// tenants must still produce a diagram a person can read (spec §6.4).
+func addBoxNodes(v *ArchView, in ArchInput, claimed map[string]string) {
+ for _, b := range v.Boxes {
+ boxID := "box:" + b.Label
+ v.Nodes = append(v.Nodes, Node{
+ ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b),
+ })
+ appsID := "apps:" + b.Label
+ v.Nodes = append(v.Nodes, Node{
+ ID: appsID, Kind: "apps", Title: appsTitle(b), Subtitle: countsSubtitle(b),
+ })
+ v.Edges = append(v.Edges, Edge{From: boxID, To: appsID, Kind: "runs"})
+ if in.Bucket != "" {
+ v.Edges = append(v.Edges, Edge{From: boxID, To: "bucket:" + in.Bucket, Kind: "stores"})
+ }
+ }
+ if in.Bucket == "" {
+ return
+ }
+ // The bucket node exists whether or not the estate read found it: the
+ // console knows this bucket first-hand, and an un-granted S3 read must
+ // not erase a relationship the console is certain of.
+ id := "bucket:" + in.Bucket
+ sub := "deployment bucket"
+ if in.Inv != nil {
+ for _, b := range in.Inv.ByKind(infraview.KindBucket) {
+ if nameOf(b) == in.Bucket && b.Region != "" {
+ sub = "deployment bucket · " + b.Region
+ }
+ }
+ }
+ v.Nodes = append(v.Nodes, Node{ID: id, Kind: "bucket", Title: in.Bucket, Subtitle: sub})
+}
+
+// addMachineNodes draws the machines NO box reports. That is the whole
+// point: an instance nobody is running CARLOS on, or a box that has gone
+// dark, is drift — and drift is only visible if it is drawn. A machine a box
+// does report is already on the page as that box, and drawing it twice would
+// say the estate holds two things where it holds one.
+func addMachineNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+ if inv == nil {
+ return
+ }
+ for _, m := range inv.ByKind(infraview.KindMachine) {
+ if claimed[m.ID] != "" {
+ continue
+ }
+ sub := "no box reporting"
+ if t := m.Attr("type"); t != "" {
+ sub = "no box reporting · " + t
+ }
+ v.Nodes = append(v.Nodes, Node{
+ ID: "machine:" + m.ID, Kind: "machine",
+ Title: firstNonEmpty(m.Name, m.ID), Subtitle: sub,
+ })
+ }
+}
+
+// addAlarmNodes hangs each alarm off whatever it watches — the box when a
+// box reports that machine, the bare machine otherwise. An alarm naming
+// nothing this page draws is still listed in the resource groups.
+func addAlarmNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+ if inv == nil {
+ return
+ }
+ for _, a := range inv.ByKind(infraview.KindAlarm) {
+ to := targetOf(a.Attr("target"), claimed)
+ if to == "" {
+ continue
+ }
+ id := "alarm:" + a.ID
+ v.Nodes = append(v.Nodes, Node{
+ ID: id, Kind: "alarm", Title: firstNonEmpty(a.Name, a.ID),
+ Subtitle: strings.TrimSpace(a.Attr("metric") + " " + a.Attr("state")),
+ })
+ v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "watches"})
+ }
+}
+
+// ---- resource groups ---------------------------------------------------
+
+// archGroupOrder is the order the tables appear in, and archGroupOf maps a
+// kind onto one of them. A kind neither map knows lands in Other rather than
+// being dropped: infraview carries an unrecognised kind through verbatim
+// precisely so the page can show it.
+var archGroupOrder = []string{"Compute", "Network", "Storage", "DNS", "Identity", "Alarms", "Certificates", "Other"}
+
+var archGroupOf = map[string]string{
+ infraview.KindMachine: "Compute",
+ infraview.KindDisk: "Compute",
+ infraview.KindIP: "Network",
+ infraview.KindFirewall: "Network",
+ infraview.KindNetwork: "Network",
+ infraview.KindBucket: "Storage",
+ infraview.KindDNSZone: "DNS",
+ infraview.KindDNSRecord: "DNS",
+ infraview.KindIdentity: "Identity",
+ infraview.KindAlarm: "Alarms",
+ infraview.KindCertificate: "Certificates",
+ infraview.KindOther: "Other",
+}
+
+// resourceGroups turns the whole inventory into the tables below the
+// diagram. EVERY resource appears exactly once, in inventory order, and an
+// empty group is omitted rather than rendered as a confident "none".
+func resourceGroups(inv *infraview.Inventory) []ResourceGroup {
+ if inv == nil {
+ return nil
+ }
+ rows := map[string][]ResourceRow{}
+ for _, r := range inv.Resources {
+ g, ok := archGroupOf[r.Kind]
+ if !ok {
+ g = "Other"
+ }
+ rows[g] = append(rows[g], ResourceRow{
+ Name: nameOf(r),
+ NativeType: firstNonEmpty(r.NativeType, r.Kind),
+ Region: r.Region,
+ Detail: detailOf(r),
+ })
+ }
+ var out []ResourceGroup
+ for _, title := range archGroupOrder {
+ if len(rows[title]) == 0 {
+ continue
+ }
+ out = append(out, ResourceGroup{Title: title, Rows: rows[title]})
+ }
+ return out
+}
+
+// detailOf renders a resource's own attributes, sorted by key, so an
+// attribute nobody wrote code for still reaches the page. The id leads when
+// it says something the Name column does not — for an `other` resource that
+// is its ARN, which is the whole of what spec §6.3 promises about them.
+func detailOf(r infraview.Resource) string {
+ keys := make([]string, 0, len(r.Attrs))
+ for k := range r.Attrs {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys)+1)
+ if r.ID != "" && r.ID != r.Name {
+ parts = append(parts, "id="+r.ID)
+ }
+ for _, k := range keys {
+ parts = append(parts, k+"="+r.Attrs[k])
+ }
+ return strings.Join(parts, ", ")
+}
+
+// ---- small shared helpers ----------------------------------------------
+
+// targetOf resolves a machine id to the node that stands for it: the box
+// when one reports it, the machine node otherwise. "" for an empty id — an
+// absent attribute is absence, and joining on it would attach every
+// unattached alarm to every machine with no id.
+func targetOf(machineID string, claimed map[string]string) string {
+ if machineID == "" {
+ return ""
+ }
+ if label := claimed[machineID]; label != "" {
+ return "box:" + label
+ }
+ return "machine:" + machineID
+}
+
+func ipSubtitle(ip infraview.Resource, claimed map[string]string) string {
+ to := ip.Attr("attached_to")
+ if to == "" {
+ return "attached to nothing"
+ }
+ if label := claimed[to]; label != "" {
+ return label
+ }
+ return to
+}
+
+func boxSubtitle(b BoxCard) string {
+ if b.CloudID == "" {
+ return "bring-your-own box"
+ }
+ return strings.TrimSpace(b.InstanceType + " " + b.State)
+}
+
+func appsTitle(b BoxCard) string {
+ if len(b.Apps) == 0 {
+ return "no apps"
+ }
+ return strings.Join(b.Apps, ", ")
+}
+
+// countsSubtitle renders the box's instance counts. Awake and Asleep are
+// *int and null means the socket table was unreadable — "unknown", never 0,
+// because 0 is a claim.
+func countsSubtitle(b BoxCard) string {
+ return fmt.Sprintf("static %d · resident %d · hibernating %d · awake %s · asleep %s",
+ b.Static, b.Resident, b.Hibernating, countOrUnknown(b.Awake), countOrUnknown(b.Asleep))
+}
+
+func countOrUnknown(n *int) string {
+ if n == nil {
+ return "unknown"
+ }
+ return strconv.Itoa(*n)
+}
+
+// nameOf is a resource's display name, falling back to its id — every
+// resource has an id, not every provider gives everything a name.
+func nameOf(r infraview.Resource) string { return firstNonEmpty(r.Name, r.ID) }
+
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go
new file mode 100644
index 000000000..20f4bd402
--- /dev/null
+++ b/internal/console/archgraph_test.go
@@ -0,0 +1,367 @@
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func archFixture() ArchInput {
+ inv := &infraview.Inventory{Provider: "aws", Account: "1234", Resources: []infraview.Resource{
+ {Provider: "aws", Kind: infraview.KindDNSZone, ID: "Z1", Name: "carloku.com",
+ Attrs: map[string]string{"record_count": "12"}},
+ {Provider: "aws", Kind: infraview.KindDNSRecord, ID: "r1", Name: "console.carloku.com",
+ Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"}},
+ {Provider: "aws", Kind: infraview.KindIP, ID: "eipalloc-1",
+ Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-123"}},
+ {Provider: "aws", Kind: infraview.KindMachine, ID: "i-123", Name: "carlos-eu-west-1", Region: "eu-west-1",
+ Attrs: map[string]string{"type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1"}},
+ {Provider: "aws", Kind: infraview.KindDisk, ID: "vol-1",
+ Attrs: map[string]string{"size_gb": "40", "attached_to": "i-123"}},
+ {Provider: "aws", Kind: infraview.KindMachine, ID: "i-999", Name: "mystery-box", Region: "eu-west-1",
+ Attrs: map[string]string{"type": "t4g.nano", "state": "running"}},
+ {Provider: "aws", Kind: infraview.KindBucket, ID: "carlos-data-ie", Name: "carlos-data-ie"},
+ {Provider: "aws", Kind: infraview.KindOther, NativeType: "sqs", ID: "arn:aws:sqs:eu-west-1:1234:q", Name: "q"},
+ }}
+ return ArchInput{
+ Inv: inv,
+ Fleet: &status.Fleet{Boxes: []status.FleetBox{
+ {Label: "carlos-eu-west-1", Region: "eu-west-1"},
+ {Label: "pi-dublin", Region: "home"},
+ }},
+ Hosts: []HostRow{
+ {Label: "carlos-eu-west-1", Region: "eu-west-1", Metrics: &status.BoxMetrics{
+ Release: "abc1234",
+ RouteVersions: map[string]string{"console.carloku.com": "abc1234"},
+ }},
+ {Label: "pi-dublin", Region: "home", Metrics: &status.BoxMetrics{}},
+ },
+ Routes: []registry.Route{
+ {Host: "console.carloku.com", App: "console", Kind: "instance", Status: "ready", Version: "abc1234"},
+ },
+ Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{
+ "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 30},
+ "carlos-eu-west-1": {HourUSD: 0.0336, Cores: 2, MemGB: 4, EBSGB: 30},
+ }, EBSGBMonthUSD: 0.088},
+ }
+}
+
+// The join that makes the picture true: a DNS record's value is an EIP's
+// address, that EIP is attached to a machine, and that machine's Name tag
+// is a reporting box's label.
+func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
+ v := BuildArch(archFixture())
+
+ var box *BoxCard
+ for i := range v.Boxes {
+ if v.Boxes[i].Label == "carlos-eu-west-1" {
+ box = &v.Boxes[i]
+ }
+ }
+ if box == nil {
+ t.Fatal("no card for carlos-eu-west-1")
+ }
+ if box.CloudID != "i-123" {
+ t.Errorf("CloudID = %q, want i-123 (Name tag matched to box label)", box.CloudID)
+ }
+ if box.InstanceType != "t4g.medium" {
+ t.Errorf("InstanceType = %q", box.InstanceType)
+ }
+ if box.DiscoveredDiskGB != 40 {
+ t.Errorf("DiscoveredDiskGB = %v, want 40", box.DiscoveredDiskGB)
+ }
+ if len(box.Apps) != 1 || box.Apps[0] != "console" {
+ t.Errorf("Apps = %v, want [console]", box.Apps)
+ }
+ if !hasEdge(v, "record:r1", "ip:eipalloc-1") || !hasEdge(v, "ip:eipalloc-1", "box:carlos-eu-west-1") {
+ t.Errorf("missing the DNS -> IP -> box chain in %+v", v.Edges)
+ }
+}
+
+func hasEdge(v ArchView, from, to string) bool {
+ for _, e := range v.Edges {
+ if e.From == from && e.To == to {
+ return true
+ }
+ }
+ return false
+}
+
+func hasNode(v ArchView, id string) bool {
+ for _, n := range v.Nodes {
+ if n.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
+// Unjoined in either direction is DRAWN, never hidden — the rule most of
+// this page's value rests on.
+func TestArchShowsUnjoinedBothWays(t *testing.T) {
+ v := BuildArch(archFixture())
+
+ var found bool
+ for _, n := range v.Nodes {
+ if n.ID == "machine:i-999" {
+ found = true
+ if n.Subtitle == "" {
+ t.Error("an unclaimed machine must say so")
+ }
+ }
+ }
+ if !found {
+ t.Error("a machine no box reports must still be drawn (it is drift)")
+ }
+
+ var pi *BoxCard
+ for i := range v.Boxes {
+ if v.Boxes[i].Label == "pi-dublin" {
+ pi = &v.Boxes[i]
+ }
+ }
+ if pi == nil {
+ t.Fatal("a box with no cloud machine must still get a card — it is a BYO box, not an error")
+ }
+ if pi.CloudID != "" {
+ t.Errorf("pi CloudID = %q, want empty", pi.CloudID)
+ }
+}
+
+// A machine claimed by a reporting box is drawn ONCE, as that box — a second
+// machine:<id> node beside it would say the estate has two things where it
+// has one.
+func TestArchClaimedMachineIsNotDrawnTwice(t *testing.T) {
+ v := BuildArch(archFixture())
+ if hasNode(v, "machine:i-123") {
+ t.Error("a machine a box reports must be drawn as the box, not a second time as a machine")
+ }
+ if !hasNode(v, "box:pi-dublin") {
+ t.Error("a BYO box must still be drawn")
+ }
+ if !hasNode(v, "apps:carlos-eu-west-1") || !hasEdge(v, "box:carlos-eu-west-1", "apps:carlos-eu-west-1") {
+ t.Error("every box must reach its apps node")
+ }
+}
+
+// A record pointing at an address the estate does not own leaves the
+// diagram rather than being dropped (spec §6.3).
+func TestArchDrawsRecordLeavingTheEstate(t *testing.T) {
+ in := archFixture()
+ in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
+ Kind: infraview.KindDNSRecord, ID: "r2", Name: "mail.carloku.com",
+ Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "203.0.113.9"},
+ })
+ v := BuildArch(in)
+ if !hasNode(v, "external:203.0.113.9") {
+ t.Errorf("an address the estate does not own must be drawn leaving the diagram: %+v", v.Nodes)
+ }
+ if !hasEdge(v, "record:r2", "external:203.0.113.9") {
+ t.Errorf("the record must still point somewhere: %+v", v.Edges)
+ }
+ if !hasEdge(v, "zone:Z1", "record:r2") {
+ t.Error("a record must hang off its zone")
+ }
+}
+
+// Cost is the rate card's, so this page and /fleet/economics can never
+// disagree; drift is flagged, not silently priced away.
+func TestArchCostAndDrift(t *testing.T) {
+ in := archFixture()
+ v := BuildArch(in)
+ var box BoxCard
+ for _, b := range v.Boxes {
+ if b.Label == "carlos-eu-west-1" {
+ box = b
+ }
+ }
+ want := 0.0336*economics.HoursPerMonth + 30*0.088
+ if diff := box.USDMonth - want; diff > 0.001 || diff < -0.001 {
+ t.Errorf("USDMonth = %v, want %v (rate card, not a second cost model)", box.USDMonth, want)
+ }
+ // The card says 30 GB of EBS; the estate has 40. That is drift.
+ if len(box.Drift) == 0 {
+ t.Error("a 30 GB rate-card entry against a 40 GB discovered disk must raise drift")
+ }
+
+ // A reporting box with no rate-card entry of its own is also drift:
+ // it is being priced by the default spec, which is a guess.
+ var pi BoxCard
+ for _, b := range v.Boxes {
+ if b.Label == "pi-dublin" {
+ pi = b
+ }
+ }
+ if len(pi.Drift) == 0 {
+ t.Error("a box priced by the default spec must say so")
+ }
+}
+
+// The two drift rules are independent: a box whose rate-card entry MATCHES
+// the estate reports no drift at all. Without this the test above passes on
+// a builder that flags every box unconditionally.
+func TestArchNoDriftWhenCardMatchesEstate(t *testing.T) {
+ in := archFixture()
+ rc := in.Rates.Boxes["carlos-eu-west-1"]
+ rc.EBSGB = 40
+ in.Rates.Boxes["carlos-eu-west-1"] = rc
+ v := BuildArch(in)
+ for _, b := range v.Boxes {
+ if b.Label == "carlos-eu-west-1" && len(b.Drift) != 0 {
+ t.Errorf("a rate card that agrees with the estate must raise no drift: %v", b.Drift)
+ }
+ }
+}
+
+// Resources the graph has no opinion about must still reach the page.
+func TestArchGroupsCarryOtherResources(t *testing.T) {
+ v := BuildArch(archFixture())
+ for _, g := range v.Groups {
+ for _, r := range g.Rows {
+ if r.NativeType == "sqs" {
+ return
+ }
+ }
+ }
+ t.Fatal("an unmapped resource must appear in the resource groups")
+}
+
+// A kind this vocabulary has no word for is carried through by infraview;
+// the page must not be the place it disappears.
+func TestArchGroupsCarryUnknownKinds(t *testing.T) {
+ in := archFixture()
+ in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
+ Kind: "quantum_widget", NativeType: "qc:widget", ID: "q-1", Name: "q-1",
+ Attrs: map[string]string{"spin": "up", "charm": "yes"},
+ })
+ v := BuildArch(in)
+ for _, g := range v.Groups {
+ for _, r := range g.Rows {
+ if r.Name == "q-1" {
+ if r.Detail != "charm=yes, spin=up" {
+ t.Errorf("Detail = %q, want every attr, sorted by key", r.Detail)
+ }
+ return
+ }
+ }
+ }
+ t.Fatal("a resource of an unknown kind must still reach the resource groups")
+}
+
+// No inventory at all: the CARLOS half still renders and Missing says which
+// of the three states applies.
+func TestArchWithoutInventory(t *testing.T) {
+ in := archFixture()
+ in.Inv = nil
+ v := BuildArch(in)
+ if v.Missing == "" {
+ t.Error("a nil inventory must set Missing")
+ }
+ if len(v.Boxes) != 2 {
+ t.Errorf("boxes = %d, want the CARLOS half to render regardless", len(v.Boxes))
+ }
+}
+
+// Every read is independent, so every combination of missing reads has to
+// produce a page rather than a panic — including all of them missing.
+func TestArchSurvivesEveryEmptyInput(t *testing.T) {
+ full := archFixture()
+ cases := map[string]ArchInput{
+ "all nil": {},
+ "no fleet": {Inv: full.Inv, Hosts: full.Hosts, Routes: full.Routes, Rates: full.Rates},
+ "no hosts": {Inv: full.Inv, Fleet: full.Fleet, Routes: full.Routes, Rates: full.Rates},
+ "no routes": {Inv: full.Inv, Fleet: full.Fleet, Hosts: full.Hosts, Rates: full.Rates},
+ "no rates": {Inv: full.Inv, Fleet: full.Fleet, Hosts: full.Hosts, Routes: full.Routes},
+ "carlos only": {Fleet: full.Fleet, Hosts: full.Hosts, Routes: full.Routes, Rates: full.Rates},
+ }
+ for name, in := range cases {
+ t.Run(name, func(t *testing.T) {
+ v := BuildArch(in)
+ if name == "all nil" && v.Missing != "never" {
+ t.Errorf("Missing = %q, want never", v.Missing)
+ }
+ if name == "no fleet" || name == "no hosts" {
+ if len(v.Boxes) != 2 {
+ t.Errorf("boxes = %d, want 2 from the half that did read", len(v.Boxes))
+ }
+ }
+ })
+ }
+}
+
+// A box reported by the fleet but absent from the host table (and the other
+// way round) is still one box, not two.
+func TestArchBoxCardsAreTheUnionOfBothReads(t *testing.T) {
+ in := archFixture()
+ in.Hosts = append(in.Hosts, HostRow{Label: "extra-box", Region: "us-east-1", Stale: true, Dark: "3h"})
+ v := BuildArch(in)
+ if len(v.Boxes) != 3 {
+ t.Fatalf("boxes = %d, want 3", len(v.Boxes))
+ }
+ if v.Boxes[0].Label != "carlos-eu-west-1" || v.Boxes[1].Label != "extra-box" || v.Boxes[2].Label != "pi-dublin" {
+ t.Errorf("cards must be sorted by label: %v %v %v", v.Boxes[0].Label, v.Boxes[1].Label, v.Boxes[2].Label)
+ }
+ if !v.Boxes[1].Stale || v.Boxes[1].Dark != "3h" {
+ t.Errorf("a host-only box must keep its staleness: %+v", v.Boxes[1])
+ }
+ if v.Boxes[0].Release != "abc1234" {
+ t.Errorf("Release = %q, want the host table's", v.Boxes[0].Release)
+ }
+}
+
+// The inventory header is carried verbatim: an operator reading a stale
+// estate must be able to see how stale, and which reads failed.
+func TestArchCarriesInventoryHeader(t *testing.T) {
+ in := archFixture()
+ in.Inv.Partial = []string{"security groups in eu-west-1: throttled"}
+ v := BuildArch(in)
+ if v.Account != "1234" {
+ t.Errorf("Account = %q", v.Account)
+ }
+ if !v.TakenAt.Equal(in.Inv.TakenAt) {
+ t.Errorf("TakenAt = %v", v.TakenAt)
+ }
+ if len(v.Partial) != 1 {
+ t.Errorf("Partial = %v", v.Partial)
+ }
+ if v.Missing != "" {
+ t.Errorf("Missing = %q, want empty when an inventory was handed over", v.Missing)
+ }
+}
+
+// The bucket edge is the one bucket relationship the console can state as
+// fact, and only when it has been told which bucket it is configured with —
+// a guessed edge on this page would be worse than no edge.
+func TestArchBucketEdgeOnlyWhenNamed(t *testing.T) {
+ v := BuildArch(archFixture())
+ if hasEdge(v, "box:carlos-eu-west-1", "bucket:carlos-data-ie") {
+ t.Error("no bucket was named, so no box may claim to store into one")
+ }
+ in := archFixture()
+ in.Bucket = "carlos-data-ie"
+ v = BuildArch(in)
+ if !hasEdge(v, "box:carlos-eu-west-1", "bucket:carlos-data-ie") ||
+ !hasEdge(v, "box:pi-dublin", "bucket:carlos-data-ie") {
+ t.Errorf("every reporting box edges to the deployment bucket: %+v", v.Edges)
+ }
+ if !hasNode(v, "bucket:carlos-data-ie") {
+ t.Error("the deployment bucket must be a node")
+ }
+}
+
+// An alarm watching a machine is an edge to the BOX running on it — the
+// operator's question is "is this box watched?", not "is i-123 watched?".
+func TestArchAlarmWatchesItsBox(t *testing.T) {
+ in := archFixture()
+ in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
+ Kind: infraview.KindAlarm, ID: "auto-recover-i-123", Name: "auto-recover",
+ Attrs: map[string]string{"target": "i-123", "state": "OK"},
+ })
+ v := BuildArch(in)
+ if !hasEdge(v, "alarm:auto-recover-i-123", "box:carlos-eu-west-1") {
+ t.Errorf("an alarm naming a box's machine must edge to that box: %+v", v.Edges)
+ }
+}