package console import ( "fmt" "strings" "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", 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 more than one // picture: the deployment bucket is drawn in the band of every region that // writes to it, and before zones stopped being drawn a shared zone appeared // in both banks. 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 by id — they have none — but they ARE deduped as // values, because a node drawn in two bands brings its edges into both (the // deployment bucket, which every region writes to). No edge spans two banks // or two bands, so after that the concatenation is the whole edge set with // nothing counted twice. func archEdges(v ArchView) []Edge { seen := map[Edge]bool{} var out []Edge for _, e := range append(v.CARLOS.Edges(), v.Rest.Edges()...) { if !seen[e] { seen[e] = true out = append(out, e) } } return out } // 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 archEdges(v) { if e.From == from && e.To == to { return true } } return false } func hasNode(v ArchView, id string) bool { for _, n := range archNodes(v) { 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 archNodes(v) { 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: 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") } // The apps a box runs are ON the box, not a node beside it. They used to // be a column; a picture that fits the page cannot spare one for a fact // that is a property of something already drawn. if hasNode(v, "apps:carlos-eu-west-1") { t.Error("apps are a line on the box card, not a node of their own") } if n := nodeByID(v, "box:carlos-eu-west-1"); n == nil { t.Fatal("no box node") } else if len(n.Extra) != 2 { t.Errorf("box card Extra = %q, want the apps line and the counts line", n.Extra) } } // 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", archNodes(v)) } if !hasEdge(v, "record:r2", "external:203.0.113.9") { t.Errorf("the record must still point somewhere: %+v", archEdges(v)) } // The zone is NOT drawn — it is global, and a global node cannot sit in a // region band without being either nowhere or everywhere (archregion.go). // It is still listed in full in the DNS table. if hasNode(v, "zone:Z1") { t.Error("a zone is table-only; it belongs to no region and must not be drawn") } } // 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 box WE pay for that has no rate-card entry of its own is also // drift: it is being priced by the default spec, which is a guess. // Machine-backed on purpose — a bring-your-own box is not priced at // all, so it has no pricing to be wrong about (TestByoBoxIsNotPriced). noEntry := archFixture() delete(noEntry.Rates.Boxes, "carlos-eu-west-1") var defaulted BoxCard for _, b := range BuildArch(noEntry).Boxes { if b.Label == "carlos-eu-west-1" { defaulted = b } } if defaulted.CloudID == "" { t.Fatal("fixture drift: carlos-eu-west-1 must be machine-backed for this case") } var said bool for _, d := range defaulted.Drift { if strings.Contains(d, "default spec") { said = true } } if !said { t.Errorf("a box we pay for, priced by the default spec, must say so: %v", defaulted.Drift) } } // 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 archGroups(v) { 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 archGroups(v) { 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", archEdges(v)) } if !hasNode(v, "bucket:carlos-data-ie") { t.Error("the deployment bucket must be a node") } } // A partial inventory is normal, so an attribute naming a resource the // estate read never returned must not leave an arrow pointing at nothing. func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) { in := archFixture() in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{Kind: infraview.KindIP, ID: "eipalloc-2", Attrs: map[string]string{"address": "52.0.0.2", "attached_to": "i-gone"}}, infraview.Resource{Kind: infraview.KindAlarm, ID: "a-gone", Attrs: map[string]string{"target": "i-gone"}}, ) v := BuildArch(in) // Per PICTURE, not across the page: an edge is only not-dangling if both // its ends are drawn in the same diagram it is drawn in, and a picture // is now one region band rather than one estate. for name, est := range eachBand(v) { drawn := map[string]bool{} for _, n := range est.Nodes { drawn[n.ID] = true } _ = name 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") { t.Error("an IP attached to a machine the read missed is still part of the estate") } } // Every edge on a built view must reach the page. RenderArchSVG skips an // edge whose endpoints it cannot place, and that skip is the second net, // not the mechanism: BuildArch prunes dangling edges after layout, so by // the time the renderer runs there is nothing left for the skip to catch. // A build that quietly lost an edge to the skip would otherwise look fine. // // The count matches the class ATTRIBUTE's opening rather than the whole // attribute, because an edge is drawn one of two ways: an adjacent-layer edge // carries `class="archedge"` and a layer-skipping one `class="archedge // archedge-skip"` (RenderArchSVG). Matching the closed `archedge"` counted // only the first kind, so a page drawing every edge correctly still failed // this — and, worse, a page that genuinely LOST a skipping edge would have // passed once the numbers happened to line up. func TestEveryEdgeReachesThePage(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", "metric": "StatusCheckFailed"}, }) v := BuildArch(in) out := renderBoth(v) // No closing quote on the class: a layer-skipping edge is drawn as // `archedge archedge-skip` and is an edge the page draws all the same. if got := strings.Count(out, `