carlos / platform Public

console: the architecture page reads as two estates, not one (#387)

/fleet/architecture drew everything the account holds as one graph. On
Tito's account that is ~70 CARLOS resources inside ~1,750 belonging to a
Kubernetes estate CARLOS deliberately sits BESIDE — same account, so the
platform can reach the legacy database to sync from. One picture of that
is a hairball; two pictures are two readable pictures.

So the page is now two banks: "CARLOS" and "Also in this account", each
with its own diagram and its own resource tables. Nothing is filtered and
nothing is hidden — this is the same set of resources dealt into two,
which is what "unjoined is drawn, never hidden" asks for. The second
heading is estate-NEUTRAL on purpose: this console serves carloku,
keymail and woodstar too, and "legacy" is only true of Tito.

Membership is established, never guessed (archestate.go). A resource is
CARLOS because the console can trace a path to it from something it knows
first-hand — a machine one of its own boxes reported, or the deployment
bucket it is configured with — closed one hop through the estate's own
attachments: disks, addresses, alarms, security groups, the instance
role, and the DNS records resolving to those addresses.

Emphatically NOT by name. Tito's account holds eight buckets named
exactly like CARLOS's own that nothing can prove belong to it, and they
land in the second bank. A page that read the "carlos-" prefix would put
them in the first and be confidently wrong the day somebody renames one.
TestEstateClosureReachesWhatItCanProve pins that bucket by name.

awsinv now records two more machine attributes, security_groups and
iam_profile. Both already come back on the DescribeInstances call the
inventory pass was making anyway — no new API call, no new IAM action,
still read-only — and they are what let nine security groups and nine
instance roles join their boxes instead of reading as the account's
other ones. Without them the CARLOS bank showed machines with no
firewall and no role, which reads as wrong rather than as unjoined.

One derived join is admitted and labelled: an instance PROFILE's role is
only recoverable through iam:ListInstanceProfiles, which the console's
read grant does not hold, so the profile ARN and the role ARN are matched
on their trailing name within one account. EKS's profiles do not match
and correctly stay out. A wrong match there can only move a row between
two visible bands — it can never hide one.

ArchView carries two ArchEstates; layout and RenderArchSVG take one and
have no opinion about the other, so each picture stays byte-identical
across refreshes on its own. The shared tito.io zone is drawn in BOTH
diagrams — it genuinely answers for both halves, and a picture whose
records resolved from nowhere would be worse than a duplicated node — but
appears exactly once in the tables.

Measured on the real Tito snapshot (1,820 resources): CARLOS is 33 nodes
/ 62 rows at 1918x832; the rest is 170 nodes / 1,758 rows at 1358x6862.
Every resource lands in exactly one bank.

Fences, each watched failing on its own mutation: the closure reaches
what it can prove and no more; every resource appears exactly once across
both banks; no edge spans the two banks; both pictures are deterministic;
an account with only CARLOS in it draws an empty second bank rather than
inventing one; and the page ships both headings and both diagrams.


Claude-Session: https://claude.ai/code/session_0168XZiNjWvRouwshDyzefwQ
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 505addb644b1ad58cf595d41a8bc2921db10fac4 parent 6a59497
12 files changed, +901 −138
  • internal/awsinv/ec2.go +19 −0
  • internal/console/archestate.go +244 −0
  • internal/console/archestate_test.go +281 −0
  • internal/console/archgraph.go +87 −27
  • internal/console/archgraph_test.go +82 −29
  • internal/console/archlayout.go +2 −2
  • internal/console/archlayout_test.go +75 −44
  • internal/console/archlegibility_test.go +28 −24
  • internal/console/archpage.go +6 −1
  • internal/console/archpage_test.go +31 −0
  • internal/console/server.go +9 −3
  • internal/console/templates/architecture.html +37 −8
diff --git a/internal/awsinv/ec2.go b/internal/awsinv/ec2.go
index c1a07d092..b26f3c138 100644
--- a/internal/awsinv/ec2.go
+++ b/internal/awsinv/ec2.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
+ "strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
@@ -65,6 +66,24 @@ func ec2Resources(ctx context.Context, api EC2API, region string) (out []infravi
}
set(&r, "public_ip", aws.ToString(in.PublicIpAddress))
set(&r, "private_ip", aws.ToString(in.PrivateIpAddress))
+ // Which security groups and which instance role. Both come
+ // back on this same DescribeInstances call — no extra API
+ // call and no extra IAM action — and both are how
+ // /fleet/architecture tells a CARLOS security group and a
+ // CARLOS instance role from the account's other ones
+ // (console/archestate.go). GROUP IDS, not names: the
+ // firewall resource is keyed on the id, so the join is
+ // exact rather than a name match.
+ var groups []string
+ for _, g := range in.SecurityGroups {
+ if id := aws.ToString(g.GroupId); id != "" {
+ groups = append(groups, id)
+ }
+ }
+ set(&r, "security_groups", strings.Join(groups, ","))
+ if in.IamInstanceProfile != nil {
+ set(&r, "iam_profile", aws.ToString(in.IamInstanceProfile.Arn))
+ }
if in.LaunchTime != nil {
set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02"))
}
diff --git a/internal/console/archestate.go b/internal/console/archestate.go
new file mode 100644
index 000000000..2f49b5386
--- /dev/null
+++ b/internal/console/archestate.go
@@ -0,0 +1,244 @@
+// Which half of the account a resource belongs to.
+//
+// A CARLOS deployment is very often a guest in an account that was already
+// carrying something else — Tito's own account holds ~70 CARLOS resources
+// beside ~1,750 belonging to a Kubernetes estate that predates it, and the
+// two are deliberately kept in ONE account so the platform can reach the
+// legacy database to sync from. Drawn as one picture that is a hairball;
+// drawn as two it is two readable pictures. So /fleet/architecture splits
+// the page in half.
+//
+// This file decides the split, and one rule governs it:
+//
+// MEMBERSHIP IS ESTABLISHED, NEVER GUESSED. A resource is CARLOS because
+// the console can trace a path to it from something it knows first-hand —
+// a machine a box reported, or the deployment bucket it is configured
+// with — never because its name starts with "carlos-". A naming
+// convention is a convention: it holds until somebody renames something,
+// and then the page is confidently wrong with no way to tell.
+//
+// The consequence is that the join UNDER-reaches, and that is the honest
+// trade. Measured against the real Tito estate the closure reaches 44
+// resources; eight sibling regional deployment buckets are genuinely
+// unreachable from what this console knows and land in the other half. That
+// is not a bug to paper over with a name match — it is the page saying
+// exactly how much it can prove.
+//
+// Nothing is filtered and nothing is hidden: this is the same set of
+// resources archgraph.go has always drawn, dealt into two banks. A resource
+// the closure cannot reach is still on the page, still in a table, still
+// with its every attribute — just under the other heading.
+package console
+
+import (
+ "strings"
+
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// Estate bits. A node can carry both: the account's one DNS zone genuinely
+// serves both halves, and drawing it in only one picture would make the
+// other picture's records resolve from nowhere.
+const (
+ estateCARLOS = 1 << iota
+ estateRest
+)
+
+// carlosResources is the membership closure: the set of inventory resource
+// ids the console can PROVE belong to the CARLOS deployment.
+//
+// It is seeded by the two things the console knows first-hand — the machines
+// its own boxes reported (claimed, built in BuildArch from each box's own
+// instance-metadata read) and the deployment bucket it is configured with —
+// and closed one hop outward along relationships the estate itself carries:
+//
+// machine -> its disks (disk.attached_to)
+// machine -> its addresses (ip.attached_to)
+// machine -> its alarms (alarm.target)
+// machine -> its security groups (machine.security_groups)
+// machine -> its instance role (machine.iam_profile)
+// address -> the records naming it, and those records' zone
+//
+// Every one of those is an id-to-id join except the last two, which are
+// described where they are made.
+func carlosResources(inv *infraview.Inventory, claimed map[string]string, bucket string) map[string]bool {
+ in := map[string]bool{}
+ if inv == nil {
+ return in
+ }
+
+ // Seed: the machines the boxes themselves reported. claimed is keyed by
+ // machine id, so this is an exact join and a box whose machine has since
+ // been terminated simply seeds nothing.
+ machines := map[string]bool{}
+ for _, m := range inv.ByKind(infraview.KindMachine) {
+ if claimed[m.ID] == "" {
+ continue
+ }
+ machines[m.ID] = true
+ in[m.ID] = true
+ }
+
+ // The deployment bucket, matched the way addBoxNodes matches it — by the
+ // name the console was configured with. An empty bucket claims nothing.
+ if bucket != "" {
+ for _, b := range inv.ByKind(infraview.KindBucket) {
+ if nameOf(b) == bucket {
+ in[b.ID] = true
+ }
+ }
+ }
+
+ // Security groups and instance roles come off the machine itself, which
+ // is why they are cheap: ec2:DescribeInstances already returns both, so
+ // closing this hop needed no new API call and no new IAM action.
+ groups := map[string]bool{} // security-group id
+ profiles := map[string]bool{} // instance-profile ARN tail
+ for _, m := range inv.ByKind(infraview.KindMachine) {
+ if !machines[m.ID] {
+ continue
+ }
+ for _, g := range strings.Split(m.Attr("security_groups"), ",") {
+ if g = strings.TrimSpace(g); g != "" {
+ groups[g] = true
+ }
+ }
+ if tail := arnTail(m.Attr("iam_profile")); tail != "" {
+ profiles[tail] = true
+ }
+ }
+
+ for _, r := range inv.Resources {
+ switch r.Kind {
+ case infraview.KindDisk:
+ if machines[r.Attr("attached_to")] {
+ in[r.ID] = true
+ }
+ case infraview.KindIP:
+ if machines[r.Attr("attached_to")] {
+ in[r.ID] = true
+ }
+ case infraview.KindAlarm:
+ if machines[r.Attr("target")] {
+ in[r.ID] = true
+ }
+ case infraview.KindFirewall:
+ // The firewall's own id, exactly as the machine named it. No
+ // name matching: DescribeInstances reports group IDs and
+ // DescribeSecurityGroups keys on the same ids.
+ if groups[r.ID] {
+ in[r.ID] = true
+ }
+ case infraview.KindIdentity:
+ // The ONE derived join on the page, and it is worth naming. An
+ // EC2 instance carries an instance PROFILE, and the profile's
+ // role is only recoverable through iam:ListInstanceProfiles —
+ // an action the console's read grant does not hold, and adding
+ // it would mean a terraform apply across every region box
+ // before this page could render at all. So the profile ARN and
+ // the role ARN are matched on their trailing name, within one
+ // account. Every tool that creates an instance role for us
+ // creates the pair under one name; EKS's do not match and
+ // correctly stay out. A wrong match here can only ever move a
+ // row from one visible band to the other — it can never hide
+ // one, which is what makes the shortcut affordable.
+ if tail := arnTail(r.ID); tail != "" && profiles[tail] {
+ in[r.ID] = true
+ }
+ }
+ }
+
+ // DNS last: a record is CARLOS when the address it resolves to is, and
+ // a zone is CARLOS when it holds such a record. recordTarget is the
+ // same resolution addRecordNodes draws with, so a record's estate and
+ // its arrow can never disagree.
+ zones := map[string]bool{}
+ for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
+ t := recordTarget(inv, rec.Attr("value"), claimed)
+ switch {
+ case strings.HasPrefix(t, "box:"):
+ // targetOf only says "box:" for a machine a box reported, so
+ // this is CARLOS by construction.
+ case strings.HasPrefix(t, "ip:") && in[strings.TrimPrefix(t, "ip:")]:
+ default:
+ continue
+ }
+ in[rec.ID] = true
+ zones[rec.Attr("zone")] = true
+ }
+ for _, z := range inv.ByKind(infraview.KindDNSZone) {
+ if z.Name != "" && zones[z.Name] {
+ in[z.ID] = true
+ }
+ }
+ return in
+}
+
+// arnTail is the name at the end of an ARN — "carlos-tito-host-ie" out of
+// both arn:aws:iam::…:role/carlos-tito-host-ie and
+// arn:aws:iam::…:instance-profile/carlos-tito-host-ie. Empty for anything
+// that is not an ARN, which is how a provider that names identities some
+// other way declines this join rather than matching something wrong.
+func arnTail(arn string) string {
+ if !strings.HasPrefix(arn, "arn:") {
+ return ""
+ }
+ i := strings.LastIndex(arn, "/")
+ if i < 0 || i == len(arn)-1 {
+ return ""
+ }
+ return arn[i+1:]
+}
+
+// splitNodes deals the drawn nodes into the two banks.
+//
+// Most nodes answer directly: a box, its apps node and the deployment bucket
+// are CARLOS by definition; an IP, a record or an unclaimed machine is
+// whatever the closure said its resource is. Two kinds straddle, and both do
+// so legitimately — a DNS zone that answers for both halves, and an external
+// address named by records in both — so a node with no direct answer takes
+// the union of its neighbours' banks. That is what puts tito.io in both
+// pictures without either picture claiming to own it.
+//
+// A node that answers neither directly nor through a neighbour goes in BOTH
+// rather than neither: on this page an unplaceable thing is drawn twice, never
+// dropped.
+func splitNodes(nodes []Node, edges []Edge, in map[string]bool) map[string]int {
+ bank := make(map[string]int, len(nodes))
+ direct := make(map[string]bool, len(nodes))
+ for _, n := range nodes {
+ kind, ref, ok := strings.Cut(n.ID, ":")
+ if !ok {
+ continue
+ }
+ switch kind {
+ case "box", "apps", "bucket":
+ bank[n.ID], direct[n.ID] = estateCARLOS, true
+ case "ip", "record", "machine":
+ if in[ref] {
+ bank[n.ID] = estateCARLOS
+ } else {
+ bank[n.ID] = estateRest
+ }
+ direct[n.ID] = true
+ }
+ }
+ // One propagation pass is enough for the graph this is: every straddling
+ // node (zone, external) sits directly beside a record, and every record
+ // was placed above. The banks ACCUMULATE — a zone answering for both
+ // halves must end up carrying both bits, not whichever edge came first.
+ for _, e := range edges {
+ if !direct[e.From] {
+ bank[e.From] |= bank[e.To]
+ }
+ if !direct[e.To] {
+ bank[e.To] |= bank[e.From]
+ }
+ }
+ for _, n := range nodes {
+ if bank[n.ID] == 0 {
+ bank[n.ID] = estateCARLOS | estateRest
+ }
+ }
+ return bank
+}
diff --git a/internal/console/archestate_test.go b/internal/console/archestate_test.go
new file mode 100644
index 000000000..b74451603
--- /dev/null
+++ b/internal/console/archestate_test.go
@@ -0,0 +1,281 @@
+package console
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// sharedAccountFixture is the shape this whole feature exists for: one
+// account carrying a CARLOS deployment AND something else. It is modelled on
+// Tito's real account — a CARLOS box beside a Kubernetes estate, one DNS zone
+// answering for both, and a bucket named as if it were CARLOS's that the
+// console was never told about.
+func sharedAccountFixture() ArchInput {
+ inv := &infraview.Inventory{Provider: "aws", Account: "665106592142", Resources: []infraview.Resource{
+ // One zone, both estates. This is the coupling that makes the two
+ // halves one account rather than two.
+ {Kind: infraview.KindDNSZone, ID: "Z1", Name: "tito.io",
+ Attrs: map[string]string{"record_count": "202"}},
+
+ // --- the CARLOS half ------------------------------------------
+ {Kind: infraview.KindMachine, ID: "i-carlos", Name: "carlos-tito-ie", Region: "eu-west-1",
+ Attrs: map[string]string{
+ "type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1",
+ "security_groups": "sg-carlos",
+ "iam_profile": "arn:aws:iam::665106592142:instance-profile/carlos-tito-host-ie",
+ }},
+ {Kind: infraview.KindIP, ID: "eip-carlos",
+ Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-carlos"}},
+ {Kind: infraview.KindDisk, ID: "vol-carlos",
+ Attrs: map[string]string{"size_gb": "40", "attached_to": "i-carlos"}},
+ {Kind: infraview.KindFirewall, ID: "sg-carlos", Name: "carlos-tito-ie"},
+ {Kind: infraview.KindIdentity, ID: "arn:aws:iam::665106592142:role/carlos-tito-host-ie",
+ Name: "carlos-tito-host-ie", Attrs: map[string]string{"kind": "role"}},
+ {Kind: infraview.KindAlarm, ID: "alarm-carlos", Name: "auto-recover-ie",
+ Attrs: map[string]string{"target": "i-carlos", "state": "OK"}},
+ {Kind: infraview.KindDNSRecord, ID: "rec-carlos", Name: "go.tito.io",
+ Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "52.0.0.1"}},
+ {Kind: infraview.KindBucket, ID: "carlos-tito-platform", Name: "carlos-tito-platform"},
+
+ // --- everything else the account carries -----------------------
+ {Kind: infraview.KindMachine, ID: "i-eks", Name: "workers-a-03", Region: "eu-west-1",
+ Attrs: map[string]string{
+ "type": "m5.large", "state": "running", "public_ip": "52.0.0.9",
+ "security_groups": "sg-eks",
+ "iam_profile": "arn:aws:iam::665106592142:instance-profile/eks-2acb3b4e",
+ }},
+ {Kind: infraview.KindFirewall, ID: "sg-eks", Name: "eks-cluster-sg"},
+ {Kind: infraview.KindIdentity, ID: "arn:aws:iam::665106592142:role/eks-node-role",
+ Name: "eks-node-role", Attrs: map[string]string{"kind": "role"}},
+ {Kind: infraview.KindDNSRecord, ID: "rec-legacy", Name: "ti.to",
+ Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "52.0.0.9"}},
+ {Kind: infraview.KindNetwork, ID: "vpc-1", Name: "tito-production"},
+
+ // A bucket the console was NEVER told about, wearing the CARLOS
+ // name. It is the whole reason this file matches on joins rather
+ // than on names: nothing here can prove it belongs to CARLOS.
+ {Kind: infraview.KindBucket, ID: "carlos-tito-se-665106592142", Name: "carlos-tito-se-665106592142"},
+ }}
+ return ArchInput{
+ Inv: inv,
+ Bucket: "carlos-tito-platform",
+ Fleet: &status.Fleet{Boxes: []status.FleetBox{{Label: "carlos-tito-ie", Region: "eu-west-1"}}},
+ Hosts: []HostRow{{Label: "carlos-tito-ie", Region: "eu-west-1", Metrics: &status.BoxMetrics{
+ Release: "abc1234", InstanceID: "i-carlos",
+ }}},
+ Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{
+ "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 40},
+ }, EBSGBMonthUSD: 0.088},
+ }
+}
+
+// The membership closure, one join at a time. Each of these is a hop the
+// console can PROVE; the failure this pins is a join quietly widening into a
+// name match, which would make every row on the page a guess.
+func TestEstateClosureReachesWhatItCanProve(t *testing.T) {
+ v := BuildArch(sharedAccountFixture())
+
+ // Keyed by (table, name) rather than name alone: on the real estate a
+ // security group is named after the box it guards, so "carlos-tito-ie"
+ // is both a machine and a firewall — and the fixture keeps that.
+ rows := bankOfEachRow(v)
+
+ for _, c := range []struct{ table, name, want, why string }{
+ {"Compute", "carlos-tito-ie", "CARLOS", "the box itself reported this machine id"},
+ {"Compute", "vol-carlos", "CARLOS", "a disk attached to a reported machine"},
+ {"Network", "eip-carlos", "CARLOS", "an address attached to a reported machine"},
+ {"Alarms", "auto-recover-ie", "CARLOS", "an alarm watching a reported machine"},
+ {"Network", "carlos-tito-ie", "CARLOS", "a security group the machine itself named"},
+ {"Identity", "carlos-tito-host-ie", "CARLOS", "the role behind the machine's instance profile"},
+ {"DNS", "go.tito.io", "CARLOS", "a record resolving to a CARLOS address"},
+ {"DNS", "tito.io", "CARLOS", "the zone holding such a record"},
+ {"Storage", "carlos-tito-platform", "CARLOS", "the deployment bucket the console is configured with"},
+
+ {"Compute", "workers-a-03", "rest", "a machine no box reports"},
+ {"Network", "eks-cluster-sg", "rest", "a security group no CARLOS machine names"},
+ {"Identity", "eks-node-role", "rest", "a role no CARLOS instance profile names"},
+ {"DNS", "ti.to", "rest", "a record resolving into the other estate"},
+ {"Network", "tito-production", "rest", "a VPC nothing joins it to CARLOS"},
+
+ // The load-bearing one. This bucket is named exactly as CARLOS's
+ // own buckets are, and it still lands in the other half, because
+ // the console cannot trace a path to it. A page that put it in
+ // CARLOS would be reading a naming convention, and the day
+ // somebody renames a bucket that page becomes confidently wrong.
+ {"Storage", "carlos-tito-se-665106592142", "rest", "a carlos-NAMED bucket the console cannot prove is its own"},
+ } {
+ if got := rows[c.table+"/"+c.name]; got != c.want {
+ t.Errorf("%s %s is in %q, want %q — %s", c.table, c.name, got, c.want, c.why)
+ }
+ }
+}
+
+// bankOfEachRow maps "<table>/<name>" to the bank its row was rendered in,
+// or "both"/"" when the split lost track of it.
+func bankOfEachRow(v ArchView) map[string]string {
+ out := map[string]string{}
+ for bank, groups := range map[string][]ResourceGroup{"CARLOS": v.CARLOS.Groups, "rest": v.Rest.Groups} {
+ for _, g := range groups {
+ for _, r := range g.Rows {
+ k := g.Title + "/" + r.Name
+ if out[k] != "" {
+ out[k] = "both"
+ continue
+ }
+ out[k] = bank
+ }
+ }
+ }
+ return out
+}
+
+// Nothing is filtered and nothing is hidden: this page GROUPS. Every
+// resource in the inventory appears exactly once across the two banks
+// together — never in neither (which would hide it) and never in both
+// (which would double-count the estate).
+func TestEveryResourceAppearsExactlyOnceAcrossBothBanks(t *testing.T) {
+ in := sharedAccountFixture()
+ v := BuildArch(in)
+
+ count := map[string]int{}
+ total := 0
+ for _, g := range append(append([]ResourceGroup{}, v.CARLOS.Groups...), v.Rest.Groups...) {
+ for _, r := range g.Rows {
+ count[g.Title+"/"+r.Name]++
+ total++
+ }
+ }
+ if total != len(in.Inv.Resources) {
+ t.Errorf("banks carry %d rows, inventory has %d resources", total, len(in.Inv.Resources))
+ }
+ for _, r := range in.Inv.Resources {
+ k := archGroupOf[r.Kind] + "/" + nameOf(r)
+ switch n := count[k]; n {
+ case 1:
+ case 0:
+ t.Errorf("%s (%s) is on neither bank — the split must group, never hide", nameOf(r), r.Kind)
+ default:
+ t.Errorf("%s (%s) appears %d times — the estate must not be double-counted", nameOf(r), r.Kind, n)
+ }
+ }
+}
+
+// The split is only honest if no relationship is lost to it. An edge whose
+// ends fell into different banks would be pruned out of both and vanish
+// silently, so the shape of the graph has to be such that none exists: a
+// node that genuinely serves both halves (the shared DNS zone) is DRAWN IN
+// BOTH pictures rather than picked for one.
+func TestNoEdgeSpansTheTwoBanks(t *testing.T) {
+ v := BuildArch(sharedAccountFixture())
+
+ bank := map[string]string{}
+ for _, n := range v.CARLOS.Nodes {
+ bank[n.ID] = "CARLOS"
+ }
+ for _, n := range v.Rest.Nodes {
+ if bank[n.ID] == "CARLOS" {
+ bank[n.ID] = "both"
+ continue
+ }
+ bank[n.ID] = "rest"
+ }
+ for _, e := range archEdges(v) {
+ f, to := bank[e.From], bank[e.To]
+ if f == "both" || to == "both" || f == to {
+ continue
+ }
+ t.Errorf("edge %s (%s) -> %s (%s) spans both banks and would be pruned out of both",
+ e.From, f, e.To, to)
+ }
+
+ // And the zone really is the node that straddles: it answers for both
+ // halves, so both pictures show where their records resolve FROM.
+ if bank["zone:Z1"] != "both" {
+ t.Errorf("the shared zone is in %q, want both pictures", bank["zone:Z1"])
+ }
+ // Drawn twice, listed once: a node is not a resource row.
+ if got := bankOfEachRow(v)["DNS/tito.io"]; got != "CARLOS" {
+ t.Errorf("the shared zone's table row is %q, want exactly one row, on the CARLOS side", got)
+ }
+}
+
+// Each picture is deterministic on its own, and neither depends on the
+// other: layout runs twice over disjoint node sets and the second run must
+// not be able to move the first.
+func TestBothPicturesAreDeterministic(t *testing.T) {
+ a := BuildArch(sharedAccountFixture())
+ b := BuildArch(sharedAccountFixture())
+ if renderBoth(a) != renderBoth(b) {
+ t.Fatal("two builds of the same shared account produced different SVG")
+ }
+ if string(RenderArchSVG(a.CARLOS)) == string(RenderArchSVG(a.Rest)) {
+ t.Fatal("fixture drift: the two pictures should differ, or this test proves nothing")
+ }
+}
+
+// An account with nothing but CARLOS in it — the ordinary deployment —
+// draws an empty second picture and no orphan rows, rather than inventing a
+// second estate out of whatever it could not join.
+func TestAnAccountWithOnlyCARLOSHasAnEmptySecondBank(t *testing.T) {
+ in := sharedAccountFixture()
+ kept := in.Inv.Resources[:0]
+ for _, r := range in.Inv.Resources {
+ switch r.ID {
+ case "i-eks", "sg-eks", "arn:aws:iam::665106592142:role/eks-node-role",
+ "rec-legacy", "vpc-1", "carlos-tito-se-665106592142":
+ continue
+ }
+ kept = append(kept, r)
+ }
+ in.Inv.Resources = kept
+
+ v := BuildArch(in)
+ for _, g := range v.Rest.Groups {
+ t.Errorf("nothing should be in the second bank, found %s: %+v", g.Title, g.Rows)
+ }
+ for _, n := range v.Rest.Nodes {
+ t.Errorf("nothing should be drawn in the second picture, found %s", n.ID)
+ }
+ if v.Rest.Width <= 0 || v.Rest.Height <= 0 {
+ t.Errorf("an empty second picture must still carry a positive viewBox: %dx%d", v.Rest.Width, v.Rest.Height)
+ }
+}
+
+// The one derived join on the page, pinned in both directions. An instance
+// profile's ARN and its role's ARN are matched on their trailing name; a
+// profile whose name matches no role must join nothing rather than reach for
+// the nearest thing.
+func TestInstanceProfileJoinMatchesOnlyItsOwnRole(t *testing.T) {
+ for _, c := range []struct{ arn, want string }{
+ {"arn:aws:iam::1:role/carlos-tito-host-ie", "carlos-tito-host-ie"},
+ {"arn:aws:iam::1:instance-profile/carlos-tito-host-ie", "carlos-tito-host-ie"},
+ {"arn:aws:iam::1:role/service-role/nested", "nested"},
+ {"carlos-tito-host-ie", ""},
+ {"arn:aws:iam::1:role/", ""},
+ {"", ""},
+ } {
+ if got := arnTail(c.arn); got != c.want {
+ t.Errorf("arnTail(%q) = %q, want %q", c.arn, got, c.want)
+ }
+ }
+
+ in := sharedAccountFixture()
+ for i := range in.Inv.Resources {
+ if in.Inv.Resources[i].ID == "i-carlos" {
+ in.Inv.Resources[i].Attrs["iam_profile"] =
+ "arn:aws:iam::665106592142:instance-profile/something-else"
+ }
+ }
+ v := BuildArch(in)
+ for _, g := range v.CARLOS.Groups {
+ for _, r := range g.Rows {
+ if strings.Contains(r.Name, "carlos-tito-host-ie") && g.Title == "Identity" {
+ t.Error("a role whose name no CARLOS instance profile carries must not join")
+ }
+ }
+ }
+}
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go
index fdcd2abf9..d8b891848 100644
--- a/internal/console/archgraph.go
+++ b/internal/console/archgraph.go
@@ -123,17 +123,34 @@ type ResourceGroup struct {
// 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 {
+// ArchEstate is one half of the page: its own picture and its own tables.
+// Nothing else in this type knows which half it is — layout and
+// RenderArchSVG take one of these and have no opinion about the other,
+// which is what makes running them twice free and each picture
+// independently deterministic.
+type ArchEstate struct {
Nodes []Node
Edges []Edge
Width, Height int
- Boxes []BoxCard
Groups []ResourceGroup
- TakenAt time.Time
- Account string
- Partial []string
- Missing string // "" | "never" | "ungranted" | "unreadable"
+}
+
+// ArchView is the whole page: two estates drawn one above the other, and
+// the facts that belong to neither.
+//
+// Rest is deliberately NOT called "legacy" anywhere a person can read it.
+// This console serves every CARLOS deployment, and what shares Tito's
+// account is a Kubernetes estate while what shares somebody else's will be
+// something else entirely. The page says "also in this account", which is
+// the only thing that is true in all of them.
+type ArchView struct {
+ CARLOS ArchEstate
+ Rest ArchEstate
+ Boxes []BoxCard
+ 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
@@ -167,17 +184,44 @@ func BuildArch(in ArchInput) ArchView {
}
}
- addDNSNodes(&v, in.Inv, claimed)
- addBoxNodes(&v, in)
- addMachineNodes(&v, in.Inv, claimed)
- addAlarmBadges(&v, in.Inv, claimed)
- v.Groups = resourceGroups(in.Inv)
+ // The whole graph is built once, exactly as it always was, and only then
+ // dealt into two banks. Building it whole is what keeps the joins in one
+ // place: a record still finds its IP, and an alarm still finds its box,
+ // without either pass having to know which half of the account it is
+ // looking at.
+ var whole ArchEstate
+ addDNSNodes(&whole, in.Inv, claimed)
+ addBoxNodes(&whole, v.Boxes, in)
+ addMachineNodes(&whole, in.Inv, claimed)
+ addAlarmBadges(&whole, in.Inv, claimed)
+
+ carlos := carlosResources(in.Inv, claimed, in.Bucket)
+ bank := splitNodes(whole.Nodes, whole.Edges, carlos)
+ for _, n := range whole.Nodes {
+ if bank[n.ID]&estateCARLOS != 0 {
+ v.CARLOS.Nodes = append(v.CARLOS.Nodes, n)
+ }
+ if bank[n.ID]&estateRest != 0 {
+ v.Rest.Nodes = append(v.Rest.Nodes, n)
+ }
+ }
+ // Both banks get every edge and each then prunes what it does not draw.
+ // An edge whose ends are in different banks therefore survives in
+ // neither — which is why archestate_test.go asserts no such edge exists,
+ // rather than this loop quietly deciding it does not matter.
+ v.CARLOS.Edges = append([]Edge(nil), whole.Edges...)
+ v.Rest.Edges = append([]Edge(nil), whole.Edges...)
+
+ v.CARLOS.Groups, v.Rest.Groups = splitResourceGroups(in.Inv, carlos)
+
// AFTER layout, not before: layout is what makes the node set final (it
// drops the satellite kinds the diagram lists in tables instead), so an
// edge is only knowably dangling once layout has run. Pruning earlier
// left the shipped ArchView carrying edges to nodes it does not draw.
- layout(&v)
- pruneDanglingEdges(&v)
+ for _, e := range []*ArchEstate{&v.CARLOS, &v.Rest} {
+ layout(e)
+ pruneDanglingEdges(e)
+ }
return v
}
@@ -190,7 +234,7 @@ func BuildArch(in ArchInput) ArchView {
// terminated, would otherwise leave an arrow pointing at nothing — and an
// arrow pointing at nothing is exactly the kind of claim this page exists
// to avoid making.
-func pruneDanglingEdges(v *ArchView) {
+func pruneDanglingEdges(v *ArchEstate) {
drawn := make(map[string]bool, len(v.Nodes))
for _, n := range v.Nodes {
drawn[n.ID] = true
@@ -427,7 +471,7 @@ func buildBoxCards(in ArchInput) []BoxCard {
// 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) {
+func addDNSNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -473,7 +517,7 @@ type recordGroup struct {
// 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) {
+func addRecordNodes(v *ArchEstate, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
groups := map[string]*recordGroup{}
for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
zoneName := rec.Attr("zone")
@@ -575,8 +619,8 @@ func recordNodeText(g *recordGroup) (title, subtitle string) {
//
// 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) {
- for _, b := range v.Boxes {
+func addBoxNodes(v *ArchEstate, boxes []BoxCard, in ArchInput) {
+ for _, b := range boxes {
boxID := "box:" + b.Label
v.Nodes = append(v.Nodes, Node{
ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b),
@@ -613,7 +657,7 @@ func addBoxNodes(v *ArchView, in ArchInput) {
// 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) {
+func addMachineNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -637,7 +681,7 @@ func addMachineNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]s
// nothing this page draws is still listed in the resource groups, and one
// naming something the page DOES draw becomes a mark on that node rather
// than a node in the flow (see Node.Alarms for why).
-func addAlarmBadges(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+func addAlarmBadges(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -686,15 +730,31 @@ var archGroupOf = map[string]string{
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 {
+// splitResourceGroups deals the inventory's tables into the two banks. The
+// exactly-once promise below is now a promise across BOTH banks together:
+// every resource is in one of them, never in neither and never in both, and
+// archestate_test.go counts it.
+func splitResourceGroups(inv *infraview.Inventory, carlos map[string]bool) (inEstate, rest []ResourceGroup) {
if inv == nil {
- return nil
+ return nil, nil
}
- rows := map[string][]ResourceRow{}
+ var mine, theirs []infraview.Resource
for _, r := range inv.Resources {
+ if carlos[r.ID] {
+ mine = append(mine, r)
+ } else {
+ theirs = append(theirs, r)
+ }
+ }
+ return resourceGroups(mine), resourceGroups(theirs)
+}
+
+// resourceGroups turns a set of resources into the tables below one
+// diagram. EVERY resource appears exactly once, in inventory order, and an
+// empty group is omitted rather than rendered as a confident "none".
+func resourceGroups(resources []infraview.Resource) []ResourceGroup {
+ rows := map[string][]ResourceRow{}
+ for _, r := range resources {
g, ok := archGroupOf[r.Kind]
if !ok {
g = "Other"
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go
index 56a90122a..1d93f942e 100644
--- a/internal/console/archgraph_test.go
+++ b/internal/console/archgraph_test.go
@@ -79,12 +79,59 @@ func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
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)
+ t.Errorf("missing the DNS -> IP -> box chain in %+v", archEdges(v))
}
}
+// The page draws two estates (archestate.go), and almost every test in this
+// file is about a JOIN rather than about which half a thing landed in. These
+// three read the whole page, so those tests keep asking exactly what they
+// always asked. Which half is which is archestate_test.go's subject.
+//
+// Nodes are deduped by id because one legitimately appears in both pictures:
+// a DNS zone that answers for both halves of the account.
+func archNodes(v ArchView) []Node {
+ seen := map[string]bool{}
+ var out []Node
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, n := range e.Nodes {
+ if !seen[n.ID] {
+ seen[n.ID] = true
+ out = append(out, n)
+ }
+ }
+ }
+ return out
+}
+
+// Edges are NOT deduped: no edge may span the two banks (archestate_test.go
+// pins that), so every edge belongs to exactly one of them and the
+// concatenation is the whole edge set with nothing counted twice.
+func archEdges(v ArchView) []Edge {
+ return append(append([]Edge(nil), v.CARLOS.Edges...), v.Rest.Edges...)
+}
+
+// archGroups merges the two banks' tables back into one table per title, so
+// a test asking "is this resource on the page" does not have to care which
+// half it is on.
+func archGroups(v ArchView) []ResourceGroup {
+ var out []ResourceGroup
+ at := map[string]int{}
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, g := range e.Groups {
+ if i, ok := at[g.Title]; ok {
+ out[i].Rows = append(out[i].Rows, g.Rows...)
+ continue
+ }
+ at[g.Title] = len(out)
+ out = append(out, g)
+ }
+ }
+ return out
+}
+
func hasEdge(v ArchView, from, to string) bool {
- for _, e := range v.Edges {
+ for _, e := range archEdges(v) {
if e.From == from && e.To == to {
return true
}
@@ -93,7 +140,7 @@ func hasEdge(v ArchView, from, to string) bool {
}
func hasNode(v ArchView, id string) bool {
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.ID == id {
return true
}
@@ -107,7 +154,7 @@ func TestArchShowsUnjoinedBothWays(t *testing.T) {
v := BuildArch(archFixture())
var found bool
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.ID == "machine:i-999" {
found = true
if n.Subtitle == "" {
@@ -159,10 +206,10 @@ func TestArchDrawsRecordLeavingTheEstate(t *testing.T) {
})
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)
+ t.Errorf("an address the estate does not own must be drawn leaving the diagram: %+v", archNodes(v))
}
if !hasEdge(v, "record:r2", "external:203.0.113.9") {
- t.Errorf("the record must still point somewhere: %+v", v.Edges)
+ t.Errorf("the record must still point somewhere: %+v", archEdges(v))
}
if !hasEdge(v, "zone:Z1", "record:r2") {
t.Error("a record must hang off its zone")
@@ -234,7 +281,7 @@ func TestArchNoDriftWhenCardMatchesEstate(t *testing.T) {
// 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 _, g := range archGroups(v) {
for _, r := range g.Rows {
if r.NativeType == "sqs" {
return
@@ -253,7 +300,7 @@ func TestArchGroupsCarryUnknownKinds(t *testing.T) {
Attrs: map[string]string{"spin": "up", "charm": "yes"},
})
v := BuildArch(in)
- for _, g := range v.Groups {
+ for _, g := range archGroups(v) {
for _, r := range g.Rows {
if r.Name == "q-1" {
if r.Detail != "charm=yes, spin=up" {
@@ -360,7 +407,7 @@ func TestArchBucketEdgeOnlyWhenNamed(t *testing.T) {
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)
+ t.Errorf("every reporting box edges to the deployment bucket: %+v", archEdges(v))
}
if !hasNode(v, "bucket:carlos-data-ie") {
t.Error("the deployment bucket must be a node")
@@ -378,13 +425,17 @@ func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) {
Attrs: map[string]string{"target": "i-gone"}},
)
v := BuildArch(in)
- drawn := map[string]bool{}
- for _, n := range v.Nodes {
- drawn[n.ID] = true
- }
- for _, e := range v.Edges {
- if !drawn[e.From] || !drawn[e.To] {
- t.Errorf("edge %+v points at a node this view does not draw", e)
+ // Per PICTURE, not across the two: an edge is only not-dangling if both
+ // its ends are drawn in the same diagram it is drawn in.
+ for _, est := range []ArchEstate{v.CARLOS, v.Rest} {
+ drawn := map[string]bool{}
+ for _, n := range est.Nodes {
+ drawn[n.ID] = true
+ }
+ for _, e := range est.Edges {
+ if !drawn[e.From] || !drawn[e.To] {
+ t.Errorf("edge %+v points at a node this picture does not draw", e)
+ }
}
}
if !hasNode(v, "ip:eipalloc-2") {
@@ -404,9 +455,9 @@ func TestEveryEdgeReachesThePage(t *testing.T) {
Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"},
})
v := BuildArch(in)
- out := string(RenderArchSVG(v))
- if got := strings.Count(out, `<path class="archedge"`); got != len(v.Edges) {
- t.Errorf("page draws %d edges, view carries %d", got, len(v.Edges))
+ out := string(RenderArchSVG(v.CARLOS)) + string(RenderArchSVG(v.Rest))
+ if got := strings.Count(out, `<path class="archedge"`); got != len(archEdges(v)) {
+ t.Errorf("page draws %d edges, view carries %d", got, len(archEdges(v)))
}
}
@@ -428,7 +479,7 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
v := BuildArch(in)
recordNodes := 0
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.Kind == "record" {
recordNodes++
}
@@ -440,15 +491,16 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
// 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)
+ t.Errorf("grouped node must use its lowest-id member's id: %+v", archNodes(v))
}
// Ruling 19 asked the grouped node to carry the count AND up to 3
// example names — pin the label text, not just the node count.
var recordNode *Node
- for i := range v.Nodes {
- if v.Nodes[i].Kind == "record" {
- recordNode = &v.Nodes[i]
+ nodes := archNodes(v)
+ for i := range nodes {
+ if nodes[i].Kind == "record" {
+ recordNode = &nodes[i]
}
}
if recordNode == nil {
@@ -463,7 +515,7 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
}
var dnsRows int
- for _, g := range v.Groups {
+ for _, g := range archGroups(v) {
if g.Title == "DNS" {
dnsRows = len(g.Rows)
}
@@ -531,7 +583,7 @@ func TestArchCountsAreUnknownWithNoFleetRead(t *testing.T) {
t.Errorf("%s: counts claimed with no fleet read at all", b.Label)
}
}
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.Kind == "apps" && strings.Contains(n.Subtitle, "static") {
t.Errorf("%s subtitle states counts nobody read: %q", n.ID, n.Subtitle)
}
@@ -539,9 +591,10 @@ func TestArchCountsAreUnknownWithNoFleetRead(t *testing.T) {
}
func nodeByID(v ArchView, id string) *Node {
- for i := range v.Nodes {
- if v.Nodes[i].ID == id {
- return &v.Nodes[i]
+ nodes := archNodes(v)
+ for i := range nodes {
+ if nodes[i].ID == id {
+ return &nodes[i]
}
}
return nil
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go
index 1bc88f76c..9da06f8cb 100644
--- a/internal/console/archlayout.go
+++ b/internal/console/archlayout.go
@@ -42,7 +42,7 @@ const (
satelliteKinds = "identity firewall network disk certificate other"
)
-func layout(v *ArchView) {
+func layout(v *ArchEstate) {
// Identities, firewalls, networks, disks and certificates are listed in
// the tables below the diagram rather than drawn: a graph that draws
// every IAM role is a hairball, and the resource groups already carry
@@ -259,7 +259,7 @@ func nodeTitleText(n Node) string {
return out
}
-func RenderArchSVG(v ArchView) template.HTML {
+func RenderArchSVG(v ArchEstate) template.HTML {
var b strings.Builder
fmt.Fprintf(&b, `<svg class="archsvg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`,
v.Width, v.Height, v.Width, v.Height)
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go
index a011c6bb8..f4899041a 100644
--- a/internal/console/archlayout_test.go
+++ b/internal/console/archlayout_test.go
@@ -12,13 +12,27 @@ import (
// 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()))
+ a := renderBoth(BuildArch(archFixture()))
+ b := renderBoth(BuildArch(archFixture()))
if a != b {
t.Fatal("two builds of the same input produced different SVG")
}
}
+// renderBoth is the whole page's drawing: the CARLOS picture and the
+// account's other one. Determinism is a property of the PAGE, so every test
+// about it renders both — a split that made one picture stable by letting
+// the other reshuffle would be no improvement on one that reshuffled.
+func renderBoth(v ArchView) string {
+ return string(RenderArchSVG(v.CARLOS)) + "\n" + string(RenderArchSVG(v.Rest))
+}
+
+// eachEstate names the two pictures, so a test that must hold of both says
+// which one failed.
+func eachEstate(v ArchView) map[string]ArchEstate {
+ return map[string]ArchEstate{"CARLOS": v.CARLOS, "rest of the account": v.Rest}
+}
+
// 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
@@ -31,7 +45,7 @@ func TestLayoutOrdersLayers(t *testing.T) {
})
v := BuildArch(in)
x := map[string]int{}
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
x[n.ID] = n.X
}
if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] &&
@@ -47,11 +61,15 @@ func TestLayoutOrdersLayers(t *testing.T) {
// 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)
+ // Per picture: two nodes in DIFFERENT diagrams sharing a coordinate is
+ // not an overlap, it is two diagrams.
+ for name, est := range eachEstate(v) {
+ for i := range est.Nodes {
+ for j := i + 1; j < len(est.Nodes); j++ {
+ a, b := est.Nodes[i], est.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: %s overlaps %s", name, a.ID, b.ID)
+ }
}
}
}
@@ -63,7 +81,7 @@ 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)))
+ out := renderBoth(BuildArch(in))
if !strings.Contains(out, "viewBox=") {
t.Error("SVG must carry a viewBox so it scales with no JS")
}
@@ -77,15 +95,17 @@ func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
// page an operator sees after this ships, not a contrived corner case.
func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) {
v := BuildArch(ArchInput{})
- if len(v.Nodes) != 0 {
- t.Fatalf("this fixture is expected to draw no nodes at all, got %d", len(v.Nodes))
+ for name, est := range eachEstate(v) {
+ if len(est.Nodes) != 0 {
+ t.Fatalf("%s: expected to draw no nodes at all, got %d", name, len(est.Nodes))
+ }
+ if est.Width <= 0 || est.Height <= 0 {
+ t.Fatalf("%s: Width/Height must be positive even with zero nodes: %dx%d", name, est.Width, est.Height)
+ }
}
- if v.Width <= 0 || v.Height <= 0 {
- t.Fatalf("Width/Height must be positive even with zero nodes: %dx%d", v.Width, v.Height)
- }
- out := string(RenderArchSVG(v))
- if !strings.Contains(out, "viewBox=") {
- t.Error("SVG must carry a viewBox even when the graph is empty")
+ out := renderBoth(v)
+ if strings.Count(out, "viewBox=") != 2 {
+ t.Error("both SVGs must carry a viewBox even when the graph is empty")
}
if strings.Contains(out, `viewBox="0 0 -`) {
t.Error("viewBox must never carry a negative dimension")
@@ -101,15 +121,17 @@ func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) {
// make it a diagram rather than a texture.
func TestSVGCarriesIntrinsicSize(t *testing.T) {
v := BuildArch(archFixture())
- out := string(RenderArchSVG(v))
- if want := fmt.Sprintf(`width="%d" height="%d"`, v.Width, v.Height); !strings.Contains(out, want) {
- t.Errorf("SVG must carry %s so it renders 1:1 and .archwrap scrolls; got:\n%s", want, out)
- }
- if !strings.Contains(out, fmt.Sprintf(`viewBox="0 0 %d %d"`, v.Width, v.Height)) {
- t.Error("the viewBox must still match the intrinsic size")
+ out := renderBoth(v)
+ for name, est := range eachEstate(v) {
+ if want := fmt.Sprintf(`width="%d" height="%d"`, est.Width, est.Height); !strings.Contains(out, want) {
+ t.Errorf("%s: SVG must carry %s so it renders 1:1 and .archwrap scrolls; got:\n%s", name, want, out)
+ }
+ if !strings.Contains(out, fmt.Sprintf(`viewBox="0 0 %d %d"`, est.Width, est.Height)) {
+ t.Errorf("%s: the viewBox must still match the intrinsic size", name)
+ }
}
// Deterministic with the attributes as it was without them.
- if out != string(RenderArchSVG(BuildArch(archFixture()))) {
+ if out != renderBoth(BuildArch(archFixture())) {
t.Error("two renders of the same input differ")
}
}
@@ -135,30 +157,35 @@ func archAlarmFixture() ArchInput {
func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) {
v := BuildArch(archAlarmFixture())
- for _, n := range v.Nodes {
+ nodes := archNodes(v)
+ for _, n := range nodes {
if n.Kind == "alarm" {
t.Fatalf("an alarm must not be a node in the flow: %+v", n)
}
}
worn := map[string]int{}
- for _, n := range v.Nodes {
+ for _, n := range nodes {
worn[n.ID] = len(n.Alarms)
}
+ // One in each picture: the box's alarm is CARLOS, the alarm on a machine
+ // no box reports belongs with the rest of the account.
for _, id := range []string{"box:carlos-eu-west-1", "machine:i-999"} {
if worn[id] == 0 {
- t.Errorf("%s should wear the alarm that watches it: %+v", id, v.Nodes)
+ t.Errorf("%s should wear the alarm that watches it: %+v", id, nodes)
}
}
- 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)
+ for name, est := range eachEstate(v) {
+ for i := range est.Nodes {
+ for j := i + 1; j < len(est.Nodes); j++ {
+ a, b := est.Nodes[i], est.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: %s overlaps %s", name, a.ID, b.ID)
+ }
}
}
}
- if RenderArchSVG(v) != RenderArchSVG(BuildArch(archAlarmFixture())) {
+ if renderBoth(v) != renderBoth(BuildArch(archAlarmFixture())) {
t.Error("two builds of an alarm-bearing estate produced different SVG")
}
}
@@ -170,20 +197,22 @@ func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) {
// rests entirely on RenderArchSVG's skip. Two halves: the two functions
// compose in that order, and a built view is already clean when it ships.
func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) {
- v := ArchView{
+ e := ArchEstate{
Nodes: []Node{
{ID: "box:a", Kind: "box", Title: "a"},
{ID: "identity:role", Kind: "identity", Title: "carlos-box-role"},
},
Edges: []Edge{{From: "box:a", To: "identity:role", Kind: "runs"}},
}
- layout(&v)
- pruneDanglingEdges(&v)
- if hasNode(v, "identity:role") {
- t.Fatal("an identity is table-only; layout must drop it")
+ layout(&e)
+ pruneDanglingEdges(&e)
+ for _, n := range e.Nodes {
+ if n.ID == "identity:role" {
+ t.Fatal("an identity is table-only; layout must drop it")
+ }
}
- if len(v.Edges) != 0 {
- t.Errorf("edges = %+v, want none — the only edge pointed at a node layout dropped", v.Edges)
+ if len(e.Edges) != 0 {
+ t.Errorf("edges = %+v, want none — the only edge pointed at a node layout dropped", e.Edges)
}
// And on a real build carrying both drawn alarms and a partial estate:
@@ -195,9 +224,11 @@ func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) {
Attrs: map[string]string{"target": "i-terminated", "state": "OK"},
})
built := BuildArch(in)
- before := len(built.Edges)
- pruneDanglingEdges(&built)
- if len(built.Edges) != before {
- t.Errorf("a shipped view still had %d dangling edges in it", before-len(built.Edges))
+ for name, est := range eachEstate(built) {
+ before := len(est.Edges)
+ pruneDanglingEdges(&est)
+ if len(est.Edges) != before {
+ t.Errorf("%s: a shipped view still had %d dangling edges in it", name, before-len(est.Edges))
+ }
}
}
diff --git a/internal/console/archlegibility_test.go b/internal/console/archlegibility_test.go
index 039536fa9..84be2a0c9 100644
--- a/internal/console/archlegibility_test.go
+++ b/internal/console/archlegibility_test.go
@@ -17,24 +17,27 @@ import (
// the two horizontal runs at the endpoints' own y values plus the vertical
// hop between them.
func TestNoNodeSitsOnAnEdgePath(t *testing.T) {
- v := BuildArch(archFixture())
- pos := map[string]Node{}
- for _, n := range v.Nodes {
- pos[n.ID] = n
- }
- for _, e := range v.Edges {
- from, to := pos[e.From], pos[e.To]
- x1, y1 := from.X+from.W, from.Y+from.H/2
- x2, y2 := to.X, to.Y+to.H/2
- mid := (x1 + x2) / 2
- for _, n := range v.Nodes {
- if n.ID == e.From || n.ID == e.To {
- continue
- }
- if segmentHitsNode(x1, mid, y1, n) || segmentHitsNode(mid, x2, y2, n) ||
- verticalHitsNode(mid, y1, y2, n) {
- t.Errorf("edge %s -> %s passes through node %s (%d,%d %dx%d)",
- e.From, e.To, n.ID, n.X, n.Y, n.W, n.H)
+ // Per picture: a node in the other diagram cannot obscure this one's
+ // lines, and coordinates only mean anything inside one picture.
+ for name, est := range eachEstate(BuildArch(archFixture())) {
+ pos := map[string]Node{}
+ for _, n := range est.Nodes {
+ pos[n.ID] = n
+ }
+ for _, e := range est.Edges {
+ from, to := pos[e.From], pos[e.To]
+ x1, y1 := from.X+from.W, from.Y+from.H/2
+ x2, y2 := to.X, to.Y+to.H/2
+ mid := (x1 + x2) / 2
+ for _, n := range est.Nodes {
+ if n.ID == e.From || n.ID == e.To {
+ continue
+ }
+ if segmentHitsNode(x1, mid, y1, n) || segmentHitsNode(mid, x2, y2, n) ||
+ verticalHitsNode(mid, y1, y2, n) {
+ t.Errorf("%s: edge %s -> %s passes through node %s (%d,%d %dx%d)",
+ name, e.From, e.To, n.ID, n.X, n.Y, n.W, n.H)
+ }
}
}
}
@@ -93,7 +96,7 @@ func TestAlarmsRideTheNodeTheyWatch(t *testing.T) {
t.Errorf("firing state not carried per alarm: %+v", box.Alarms)
}
- out := string(RenderArchSVG(v))
+ out := renderBoth(v)
if !strings.Contains(out, "archalarm-firing") {
t.Error("a firing alarm must render differently from a healthy one")
}
@@ -109,13 +112,14 @@ func TestSVGTextIsFittedToItsBox(t *testing.T) {
in := archFixture()
v := BuildArch(in)
long := "console, kass, keymail, platform-site, woodstar, status, carloku-site"
- for i := range v.Nodes {
- if v.Nodes[i].Kind == "apps" {
- v.Nodes[i].Title = long
- v.Nodes[i].Subtitle = "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3"
+ // apps nodes are CARLOS by definition — a box's apps belong to the box.
+ for i := range v.CARLOS.Nodes {
+ if v.CARLOS.Nodes[i].Kind == "apps" {
+ v.CARLOS.Nodes[i].Title = long
+ v.CARLOS.Nodes[i].Subtitle = "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3"
}
}
- out := string(RenderArchSVG(v))
+ out := renderBoth(v)
if strings.Contains(out, ">"+long+"<") {
t.Error("a title wider than its rect must be truncated, not drawn past the box")
}
diff --git a/internal/console/archpage.go b/internal/console/archpage.go
index ff809358f..2f1e351f1 100644
--- a/internal/console/archpage.go
+++ b/internal/console/archpage.go
@@ -95,7 +95,12 @@ func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) {
v.Partial = append(append([]string{}, v.Partial...), failed...)
}
p := page{Title: "Architecture", Address: addr, CSRF: raw,
- Arch: &v, ArchSVG: RenderArchSVG(v), ArchBoxesUnread: boxesUnread}
+ Arch: &v, ArchBoxesUnread: boxesUnread,
+ // One picture per estate, rendered here rather than in the template
+ // for the reason the single picture was: html/template cannot call
+ // RenderArchSVG, and the SVG must be built in Go so it stays
+ // deterministic and testable as a whole.
+ ArchSVG: RenderArchSVG(v.CARLOS), ArchRestSVG: RenderArchSVG(v.Rest)}
p.Flash, p.FlashErr = s.takeFlash(w, r)
s.render(w, http.StatusOK, "architecture", p)
}
diff --git a/internal/console/archpage_test.go b/internal/console/archpage_test.go
index 00a4e0d0e..9266b2836 100644
--- a/internal/console/archpage_test.go
+++ b/internal/console/archpage_test.go
@@ -224,6 +224,37 @@ func TestArchitectureDrawsConfiguredBucket(t *testing.T) {
}
}
+// The page is two estates, both headed and both drawn — including on a
+// deployment where the second one is empty, which is what most accounts
+// are. A page that dropped the second heading when it had nothing under it
+// would be the page quietly deciding the account holds nothing else, which
+// is a claim rather than a read.
+func TestArchitectureDrawsBothEstates(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture")
+ if st != 200 {
+ t.Fatalf("= %d, want 200", st)
+ }
+ if !strings.Contains(body, "<h2>CARLOS</h2>") {
+ t.Errorf("the CARLOS estate is not headed:\n%s", body)
+ }
+ if !strings.Contains(body, "<h2>Also in this account</h2>") {
+ t.Errorf("the second estate is not headed:\n%s", body)
+ }
+ // Estate-neutral wording: this console serves carloku, keymail and
+ // woodstar too, and "legacy" describes only Tito's own account.
+ if strings.Contains(body, "legacy") || strings.Contains(body, "Legacy") {
+ t.Errorf("the second bank must not be named for one deployment's history:\n%s", body)
+ }
+ if n := strings.Count(body, `class="archsvg"`); n != 2 {
+ t.Errorf("page draws %d diagrams, want 2 — one per estate", n)
+ }
+}
+
// A read that failed is SAID on the page. An empty Boxes section with
// nothing beside it would read as "this deployment has no boxes" — the one
// claim a page whose registry read just failed must not make.
diff --git a/internal/console/server.go b/internal/console/server.go
index 1acdcf8bd..6e6401caa 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1249,9 +1249,14 @@ type page struct {
MetricAlerts []AlertRow
// /fleet/architecture (archgraph.go): the deployment's map. Arch is nil
- // only on paths this handler never takes; ArchSVG is pre-rendered in Go
- // rather than assembled in the template because the layout has to be
- // deterministic and testable as a whole.
+ // only on paths this handler never takes; the two SVGs are pre-rendered
+ // in Go rather than assembled in the template because the layout has to
+ // be deterministic and testable as a whole.
+ //
+ // Two of them because the page is two pictures: ArchSVG is the CARLOS
+ // deployment, ArchRestSVG is whatever else the account is carrying
+ // (archestate.go). Neither is ever the other's summary — both are drawn
+ // in full.
//
// ArchBoxesUnread is why the box list is empty: an empty Boxes with a
// failed fleet/host read behind it must say "could not be read", never
@@ -1259,6 +1264,7 @@ type page struct {
// alone — both cases are len 0 — so the handler carries it here.
Arch *ArchView
ArchSVG template.HTML
+ ArchRestSVG template.HTML
ArchBoxesUnread bool
// /fleet/economics: the operator unit-economics dashboard (Task 13),
diff --git a/internal/console/templates/architecture.html b/internal/console/templates/architecture.html
index c7b0fa52f..ad63e2f14 100644
--- a/internal/console/templates/architecture.html
+++ b/internal/console/templates/architecture.html
@@ -26,12 +26,23 @@
<p class="small">Estate {{.Account}} &middot; inventoried {{fmtTime .TakenAt}}</p>
{{end}}
+{{/* Two estates, one above the other, never one picture with a line
+ through it. The account holds a CARLOS deployment and, very often,
+ whatever was already there — Tito's own account carries a Kubernetes
+ estate CARLOS deliberately sits beside so it can reach the legacy
+ database. Drawn together that is one unreadable graph; drawn apart it
+ is two readable ones.
+
+ NOTHING IS FILTERED. Every node and every resource on the old single
+ page is still here, under one of the two headings — the split groups,
+ it does not hide (archestate.go). */}}
+<h2>CARLOS</h2>
<div class="archwrap">{{$.ArchSVG}}</div>
-<h2>Boxes</h2>
+<h3>Boxes</h3>
{{range .Boxes}}
<section class="archbox">
- <h3>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h3>
+ <h4>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h4>
{{/* Each fact is its own span and the separators are drawn by CSS
(.fact + .fact::before), because every one of these is independently
absent — a deployment with no rate card entry has no size and no
@@ -72,12 +83,23 @@
{{else}}<p>No box has reported to this deployment yet.</p>{{end}}
{{end}}
-{{range .Groups}}
-<h2>{{.Title}}</h2>
-<div class="tablewrap"><table>
- <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr>
- {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}}
-</table></div>
+{{range .CARLOS.Groups}}
+<h3>{{.Title}}</h3>
+{{template "archtable" .}}
+{{end}}
+
+{{/* The second bank is named for what is TRUE of it in every deployment,
+ not for what it happens to be here. This console serves every CARLOS
+ estate; "legacy" describes Tito's Kubernetes cluster and would be
+ wrong, and unexplained, on the next account. */}}
+<h2>Also in this account</h2>
+<p class="small">Everything else the account is carrying. The console has no
+first-hand knowledge of these — it can see them, and it cannot trace a path
+to any of them from a box it runs or the bucket it deploys from.</p>
+<div class="archwrap">{{$.ArchRestSVG}}</div>
+{{range .Rest.Groups}}
+<h3>{{.Title}}</h3>
+{{template "archtable" .}}
{{end}}
{{/* The inventory's own partial-read list: which service or region the
@@ -94,3 +116,10 @@
<button type="submit">Refresh inventory</button>
</form>
{{template "bottom" .}}{{end}}
+
+{{/* One resource table, rendered identically in both banks — the two halves
+ of this page differ in what they contain and in nothing else. */}}
+{{define "archtable"}}<div class="tablewrap"><table>
+ <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr>
+ {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}}
+</table></div>{{end}}