carlos / platform Public

console: deterministic architecture layout, inline SVG, and record grouping

- archlayout.go: layer-by-kind layout (barycentre sweeps, stable id
  tiebreak) and RenderArchSVG, an escaped inline-SVG renderer with no JS
  and no layout library. BuildArch now calls layout(&v) as its last step.
- archLayer gains "external": 3 (ruling 17) — without it the
  "leaves the estate" node fell into the zone column, drawing the
  spec's headline unjoined case as a right-to-left arrow. Layer 3 was
  otherwise empty (no router node exists yet).
- archgraph.go: DNS record nodes are now grouped by (zone, resolved
  target) (ruling 19) — the flagship carries one A record per app host,
  all pointing at one Elastic IP, so the ungrouped layer was dozens of
  nodes wide. A group's node id is its lowest record id, by string sort,
  so a single-record group keeps the exact id/title/subtitle it had
  before grouping existed. The DNS resource group still lists every
  record individually — grouping is diagram-only.

Tests: TestLayoutIsDeterministic, TestLayoutOrdersLayers (extended with
the external-sits-right-of-its-record assertion), TestLayoutDoesNotOverlap,
TestSVGIsSelfContainedAndEscaped, TestArchGroupsRecordsBySameZoneAndTarget
(20 A records + the fixture's r1 collapse to one node, 22 DNS resource
rows). All of Task 5's TestArch* tests pass unchanged — the fixture never
exercises more than one record per (zone, target).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 442323a4bda836216338cf0f4c9f780f2efd5e3a parent 3539748
4 files changed, +405 −26
  • internal/console/archgraph.go +104 −26
  • internal/console/archgraph_test.go +47 −0
  • internal/console/archlayout.go +182 −0
  • internal/console/archlayout_test.go +72 −0
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go
index 1e52a133f..51c4959f8 100644
--- a/internal/console/archgraph.go
+++ b/internal/console/archgraph.go
@@ -148,6 +148,7 @@ func BuildArch(in ArchInput) ArchView {
addAlarmNodes(&v, in.Inv, claimed)
v.Groups = resourceGroups(in.Inv)
pruneDanglingEdges(&v)
+ layout(&v)
return v
}
@@ -375,47 +376,124 @@ func addDNSNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]strin
v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "routes"})
}
}
- external := map[string]bool{}
+ addRecordNodes(v, inv, zones, claimed)
+}
+
+// recordGroup collects every DNS record that shares a zone and a resolved
+// destination. The flagship carries one A record per app host, all pointing
+// at the same Elastic IP — without grouping that layer is dozens of nodes
+// wide and unreadable. Grouping happens only in the diagram: every record
+// still appears individually, in full, in the DNS resource group below
+// (resourceGroups walks the inventory directly and never sees a group).
+type recordGroup struct {
+ ids []string // record ids, sorted; ids[0] names the drawn node
+ names []string // record display names, sorted, for the example list
+ zone string // the raw "zone" attr this group's records share
+ typ string // record type, meaningful only when len(names) == 1
+ value string // record value, meaningful only when len(names) == 1
+ target string // edge destination: "", "ip:…", "box:…"/"machine:…", "external:…"
+}
+
+// addRecordNodes groups the estate's DNS records by (zone, resolved
+// destination) and draws one node per group, sorted by each group's lowest
+// record id — never by map iteration, which Go randomises on every run and
+// this diagram must not.
+func addRecordNodes(v *ArchView, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
+ groups := map[string]*recordGroup{}
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")),
- })
+ zoneName := rec.Attr("zone")
+ value := rec.Attr("value")
+ target := recordTarget(inv, value, claimed)
+ key := zoneName + "\x00" + target
+ g := groups[key]
+ if g == nil {
+ g = &recordGroup{zone: zoneName, target: target}
+ groups[key] = g
+ }
+ g.ids = append(g.ids, rec.ID)
+ g.names = append(g.names, nameOf(rec))
+ g.typ, g.value = rec.Attr("type"), value
+ }
+ keys := make([]string, 0, len(groups))
+ for k, g := range groups {
+ sort.Strings(g.ids)
+ sort.Strings(g.names)
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(a, b int) bool {
+ return groups[keys[a]].ids[0] < groups[keys[b]].ids[0]
+ })
+
+ external := map[string]bool{}
+ for _, k := range keys {
+ g := groups[k]
+ id := "record:" + g.ids[0]
+ title, sub := recordNodeText(g)
+ v.Nodes = append(v.Nodes, Node{ID: id, Kind: "record", Title: title, Subtitle: sub})
for _, z := range zones {
- if z.Name != "" && rec.Attr("zone") == z.Name {
+ if z.Name != "" && g.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 == "":
+ case g.target == "":
// 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
+ case strings.HasPrefix(g.target, "external:"):
+ val := strings.TrimPrefix(g.target, "external:")
+ if !external[val] {
+ external[val] = true
v.Nodes = append(v.Nodes, Node{
- ID: ext, Kind: "external", Title: value,
+ ID: g.target, Kind: "external", Title: val,
Subtitle: "outside this estate",
})
}
- v.Edges = append(v.Edges, Edge{From: id, To: ext, Kind: "resolves"})
+ v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"})
+ default:
+ v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"})
}
}
}
+// recordTarget is where a DNS record's value actually points: an allocated
+// IP, a machine holding that address directly, or outside the estate
+// entirely. Pure — it only reads the inventory — so the grouping pass above
+// can call it once per record before deciding which records share a
+// destination. Find refuses to match on an empty value, so an absent value
+// falls straight through to the "" case.
+func recordTarget(inv *infraview.Inventory, value string, claimed map[string]string) string {
+ if value == "" {
+ return ""
+ }
+ if ip := inv.Find(infraview.KindIP, "address", value); ip != nil {
+ return "ip:" + ip.ID
+ }
+ if m := inv.Find(infraview.KindMachine, "public_ip", value); m != nil {
+ // An address the estate owns but holds directly on a machine rather
+ // than through an allocated IP.
+ return targetOf(m.ID, claimed)
+ }
+ return "external:" + value
+}
+
+// recordNodeText is the node's label. A single record keeps exactly the
+// shape it had before grouping existed: its own name, and its type and
+// value as the subtitle. A group of more than one names the count and up to
+// three example names, sorted, with the remainder folded into a "+N".
+func recordNodeText(g *recordGroup) (title, subtitle string) {
+ if len(g.names) == 1 {
+ return g.names[0], strings.TrimSpace(g.typ + " " + g.value)
+ }
+ examples := g.names
+ if len(examples) > 3 {
+ examples = examples[:3]
+ }
+ sub := strings.Join(examples, ", ")
+ if rest := len(g.names) - len(examples); rest > 0 {
+ sub += fmt.Sprintf(", +%d", rest)
+ }
+ return fmt.Sprintf("%d records", len(g.names)), sub
+}
+
// 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.
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go
index a4f072c6e..b3dce8c09 100644
--- a/internal/console/archgraph_test.go
+++ b/internal/console/archgraph_test.go
@@ -1,6 +1,7 @@
package console
import (
+ "fmt"
"testing"
"github.com/carlosframework/platform/internal/economics"
@@ -390,3 +391,49 @@ func TestArchAlarmWatchesItsBox(t *testing.T) {
t.Errorf("an alarm naming a box's machine must edge to that box: %+v", v.Edges)
}
}
+
+// A zone can carry dozens of A records aimed at one address — one per app
+// host, all behind the same Elastic IP — and the diagram must draw that as
+// ONE arrow, not dozens, while the DNS resource group below still lists
+// every record individually: grouping happens only in the picture.
+func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
+ in := archFixture()
+ // r1 (from the fixture) already shares zone "carloku.com" and value
+ // "52.0.0.1" with these — 21 records in all should collapse to one node.
+ for i := 0; i < 20; i++ {
+ in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
+ Kind: infraview.KindDNSRecord, ID: fmt.Sprintf("bulk-%02d", i),
+ Name: fmt.Sprintf("app%02d.carloku.com", i),
+ Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"},
+ })
+ }
+ v := BuildArch(in)
+
+ recordNodes := 0
+ for _, n := range v.Nodes {
+ if n.Kind == "record" {
+ recordNodes++
+ }
+ }
+ if recordNodes != 1 {
+ t.Errorf("record nodes = %d, want 1 (all 21 records share zone+target)", recordNodes)
+ }
+ // The grouped node's id is deterministic (the lowest record id in the
+ // group, by string sort) rather than depending on input order — here
+ // that is "bulk-00", which sorts before "r1".
+ if !hasEdge(v, "record:bulk-00", "ip:eipalloc-1") {
+ t.Errorf("grouped node must use its lowest-id member's id: %+v", v.Nodes)
+ }
+
+ var dnsRows int
+ for _, g := range v.Groups {
+ if g.Title == "DNS" {
+ dnsRows = len(g.Rows)
+ }
+ }
+ // 1 zone row + 21 record rows: nothing hidden, just not repeated in the
+ // diagram.
+ if dnsRows != 22 {
+ t.Errorf("DNS resource group rows = %d, want 22 (every record listed individually)", dnsRows)
+ }
+}
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go
new file mode 100644
index 000000000..dd37133f5
--- /dev/null
+++ b/internal/console/archlayout.go
@@ -0,0 +1,182 @@
+// Layout and SVG emission for /fleet/architecture. Deterministic by
+// construction: layer by kind, order within a layer by one barycentre sweep
+// each way, then a stable sort by node id. The same fleet must produce a
+// byte-identical picture on every refresh — a diagram that reshuffles
+// cannot be read for change.
+//
+// No JavaScript and no layout library: the console vendors its icons and
+// loads nothing from a CDN, and a server-computed layout is the only way to
+// keep that promise while still drawing a graph.
+package console
+
+import (
+ "fmt"
+ "html/template"
+ "sort"
+ "strings"
+)
+
+// Layer assignment by node kind — the story the page tells, left to right.
+// "external" shares layer 3 with "router": nothing emits a router node yet,
+// so the layer is otherwise empty, and an address leaving the estate must
+// sit to the RIGHT of the record that names it, not in the zone column.
+var archLayer = map[string]int{
+ "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3,
+ "box": 4, "machine": 4, "apps": 5, "bucket": 6,
+}
+
+const (
+ nodeW = 190
+ nodeH = 64
+ gapX = 90
+ gapY = 26
+ padding = 24
+ satelliteKinds = "alarm identity firewall network disk certificate other"
+)
+
+func layout(v *ArchView) {
+ // Satellites (alarms, identities, firewalls…) are listed in the cards
+ // below the diagram rather than drawn: a graph that draws every IAM role
+ // is a hairball, and the resource groups already carry them in full.
+ kept := v.Nodes[:0]
+ for _, n := range v.Nodes {
+ if strings.Contains(satelliteKinds, n.Kind) {
+ continue
+ }
+ kept = append(kept, n)
+ }
+ v.Nodes = kept
+
+ byLayer := map[int][]int{}
+ for i := range v.Nodes {
+ l := archLayer[v.Nodes[i].Kind]
+ byLayer[l] = append(byLayer[l], i)
+ }
+ // Stable order first, so the barycentre sweeps start from a fixed
+ // arrangement and cannot depend on map iteration order anywhere.
+ for l := range byLayer {
+ idx := byLayer[l]
+ sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID })
+ }
+ // One sweep forward and one back, ordering each layer by the mean row of
+ // its neighbours in the adjacent layer — the standard crossing
+ // reduction, and enough for a graph this shape. Ties break on node id so
+ // the result cannot depend on map or slice iteration order anywhere.
+ row := map[string]int{}
+ for _, idx := range byLayer {
+ for r, i := range idx {
+ row[v.Nodes[i].ID] = r
+ }
+ }
+ neighbours := func(id string, forward bool) []string {
+ var out []string
+ for _, e := range v.Edges {
+ if forward && e.To == id {
+ out = append(out, e.From)
+ }
+ if !forward && e.From == id {
+ out = append(out, e.To)
+ }
+ }
+ return out
+ }
+ sweep := func(forward bool) {
+ layers := make([]int, 0, len(byLayer))
+ for l := range byLayer {
+ layers = append(layers, l)
+ }
+ sort.Ints(layers)
+ if !forward {
+ for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 {
+ layers[i], layers[j] = layers[j], layers[i]
+ }
+ }
+ for _, l := range layers {
+ idx := byLayer[l]
+ bary := map[string]float64{}
+ for _, i := range idx {
+ id := v.Nodes[i].ID
+ ns := neighbours(id, forward)
+ if len(ns) == 0 {
+ bary[id] = float64(row[id])
+ continue
+ }
+ sum := 0
+ for _, n := range ns {
+ sum += row[n]
+ }
+ bary[id] = float64(sum) / float64(len(ns))
+ }
+ sort.SliceStable(idx, func(a, b int) bool {
+ ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID
+ if bary[ia] != bary[ib] {
+ return bary[ia] < bary[ib]
+ }
+ return ia < ib
+ })
+ for r, i := range idx {
+ row[v.Nodes[i].ID] = r
+ }
+ }
+ }
+ sweep(true)
+ sweep(false)
+
+ maxRows := 0
+ for l, idx := range byLayer {
+ for r, i := range idx {
+ v.Nodes[i].W, v.Nodes[i].H = nodeW, nodeH
+ v.Nodes[i].X = padding + l*(nodeW+gapX)
+ v.Nodes[i].Y = padding + r*(nodeH+gapY)
+ }
+ if len(idx) > maxRows {
+ maxRows = len(idx)
+ }
+ }
+ layers := 0
+ for l := range byLayer {
+ if l+1 > layers {
+ layers = l + 1
+ }
+ }
+ v.Width = padding*2 + layers*nodeW + (layers-1)*gapX
+ v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY
+}
+
+// RenderArchSVG emits the diagram. Every string that came from a hostname,
+// a tag or a label goes through template.HTMLEscapeString — these are
+// operator-supplied values from a registry and a cloud account, and the one
+// thing this page must never do is execute one of them.
+func RenderArchSVG(v ArchView) template.HTML {
+ var b strings.Builder
+ fmt.Fprintf(&b, `<svg class="archsvg" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`, v.Width, v.Height)
+ pos := map[string]Node{}
+ for _, n := range v.Nodes {
+ pos[n.ID] = n
+ }
+ // Edges first so nodes paint over them.
+ for _, e := range v.Edges {
+ from, okF := pos[e.From]
+ to, okT := pos[e.To]
+ if !okF || !okT {
+ continue // a satellite edge whose node the layout dropped
+ }
+ x1, y1 := from.X+from.W, from.Y+from.H/2
+ x2, y2 := to.X, to.Y+to.H/2
+ mid := (x1 + x2) / 2
+ fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2)
+ }
+ for _, n := range v.Nodes {
+ fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, template.HTMLEscapeString(n.Kind))
+ fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H)
+ fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`,
+ n.X+12, n.Y+24, template.HTMLEscapeString(n.Title))
+ if n.Subtitle != "" {
+ fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
+ n.X+12, n.Y+44, template.HTMLEscapeString(n.Subtitle))
+ }
+ b.WriteString(`</g>`)
+ }
+ b.WriteString(`</svg>`)
+ return template.HTML(b.String())
+}
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go
new file mode 100644
index 000000000..d113e5792
--- /dev/null
+++ b/internal/console/archlayout_test.go
@@ -0,0 +1,72 @@
+package console
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// The same fleet must draw the same picture every time. A diagram that
+// reshuffles between refreshes cannot be read for change, which is most of
+// what an operator opens it for.
+func TestLayoutIsDeterministic(t *testing.T) {
+ a := RenderArchSVG(BuildArch(archFixture()))
+ b := RenderArchSVG(BuildArch(archFixture()))
+ if a != b {
+ t.Fatal("two builds of the same input produced different SVG")
+ }
+}
+
+// Layer order is the story the page tells: DNS enters at the left, routers
+// and boxes in the middle, apps at the right. A record pointing outside the
+// estate must sit to the RIGHT of the record that names it — the diagram's
+// headline unjoined case reads left-to-right like everything else.
+func TestLayoutOrdersLayers(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)
+ x := map[string]int{}
+ for _, n := range v.Nodes {
+ x[n.ID] = n.X
+ }
+ if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] &&
+ x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"] && x["box:carlos-eu-west-1"] < x["apps:carlos-eu-west-1"]) {
+ t.Fatalf("layers out of order: %+v", x)
+ }
+ if !(x["record:r2"] < x["external:203.0.113.9"]) {
+ t.Fatalf("external must sit right of the record that points at it: %+v", x)
+ }
+}
+
+// No node may overlap another: the layout is the only thing standing
+// between a growing fleet and an unreadable page.
+func TestLayoutDoesNotOverlap(t *testing.T) {
+ v := BuildArch(archFixture())
+ for i := range v.Nodes {
+ for j := i + 1; j < len(v.Nodes); j++ {
+ a, b := v.Nodes[i], v.Nodes[j]
+ if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
+ t.Fatalf("%s overlaps %s", a.ID, b.ID)
+ }
+ }
+ }
+}
+
+// The SVG must scale without script and must not carry raw user strings
+// unescaped.
+func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
+ in := archFixture()
+ in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>`
+ in.Hosts[0].Label = in.Fleet.Boxes[0].Label
+ out := string(RenderArchSVG(BuildArch(in)))
+ if !strings.Contains(out, "viewBox=") {
+ t.Error("SVG must carry a viewBox so it scales with no JS")
+ }
+ if strings.Contains(out, "<script") {
+ t.Error("a box label must never reach the SVG unescaped")
+ }
+}