carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7
8 "github.com/carlosframework/platform/internal/economics"
9 "github.com/carlosframework/platform/internal/infraview"
10 "github.com/carlosframework/platform/internal/registry"
11 "github.com/carlosframework/platform/internal/status"
12)
13
14func archFixture() ArchInput {
15 inv := &infraview.Inventory{Provider: "aws", Account: "1234", Resources: []infraview.Resource{
16 {Provider: "aws", Kind: infraview.KindDNSZone, ID: "Z1", Name: "carloku.com",
17 Attrs: map[string]string{"record_count": "12"}},
18 {Provider: "aws", Kind: infraview.KindDNSRecord, ID: "r1", Name: "console.carloku.com",
19 Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"}},
20 {Provider: "aws", Kind: infraview.KindIP, ID: "eipalloc-1",
21 Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-123"}},
22 {Provider: "aws", Kind: infraview.KindMachine, ID: "i-123", Name: "carlos-eu-west-1", Region: "eu-west-1",
23 Attrs: map[string]string{"type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1"}},
24 {Provider: "aws", Kind: infraview.KindDisk, ID: "vol-1",
25 Attrs: map[string]string{"size_gb": "40", "attached_to": "i-123"}},
26 {Provider: "aws", Kind: infraview.KindMachine, ID: "i-999", Name: "mystery-box", Region: "eu-west-1",
27 Attrs: map[string]string{"type": "t4g.nano", "state": "running"}},
28 {Provider: "aws", Kind: infraview.KindBucket, ID: "carlos-data-ie", Name: "carlos-data-ie"},
29 {Provider: "aws", Kind: infraview.KindOther, NativeType: "sqs", ID: "arn:aws:sqs:eu-west-1:1234:q", Name: "q"},
30 }}
31 return ArchInput{
32 Inv: inv,
33 Fleet: &status.Fleet{Boxes: []status.FleetBox{
34 {Label: "carlos-eu-west-1", Region: "eu-west-1"},
35 {Label: "pi-dublin", Region: "home"},
36 }},
37 Hosts: []HostRow{
38 {Label: "carlos-eu-west-1", Region: "eu-west-1", Metrics: &status.BoxMetrics{
39 Release: "abc1234",
40 RouteVersions: map[string]string{"console.carloku.com": "abc1234"},
41 }},
42 {Label: "pi-dublin", Region: "home", Metrics: &status.BoxMetrics{}},
43 },
44 Routes: []registry.Route{
45 {Host: "console.carloku.com", App: "console", Kind: "instance", Status: "ready", Version: "abc1234"},
46 },
47 Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{
48 "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 30},
49 "carlos-eu-west-1": {HourUSD: 0.0336, Cores: 2, MemGB: 4, EBSGB: 30},
50 }, EBSGBMonthUSD: 0.088},
51 }
52}
53
54// The join that makes the picture true: a DNS record's value is an EIP's
55// address, that EIP is attached to a machine, and that machine's Name tag
56// is a reporting box's label.
57func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
58 v := BuildArch(archFixture())
59
60 var box *BoxCard
61 for i := range v.Boxes {
62 if v.Boxes[i].Label == "carlos-eu-west-1" {
63 box = &v.Boxes[i]
64 }
65 }
66 if box == nil {
67 t.Fatal("no card for carlos-eu-west-1")
68 }
69 if box.CloudID != "i-123" {
70 t.Errorf("CloudID = %q, want i-123 (Name tag matched to box label)", box.CloudID)
71 }
72 if box.InstanceType != "t4g.medium" {
73 t.Errorf("InstanceType = %q", box.InstanceType)
74 }
75 if box.DiscoveredDiskGB != 40 {
76 t.Errorf("DiscoveredDiskGB = %v, want 40", box.DiscoveredDiskGB)
77 }
78 if len(box.Apps) != 1 || box.Apps[0] != "console" {
79 t.Errorf("Apps = %v, want [console]", box.Apps)
80 }
81 if !hasEdge(v, "record:r1", "ip:eipalloc-1") || !hasEdge(v, "ip:eipalloc-1", "box:carlos-eu-west-1") {
82 t.Errorf("missing the DNS -> IP -> box chain in %+v", archEdges(v))
83 }
84}
85
86// The page draws two estates (archestate.go), and almost every test in this
87// file is about a JOIN rather than about which half a thing landed in. These
88// three read the whole page, so those tests keep asking exactly what they
89// always asked. Which half is which is archestate_test.go's subject.
90//
91// Nodes are deduped by id because one legitimately appears in more than one
92// picture: the deployment bucket is drawn in the band of every region that
93// writes to it, and before zones stopped being drawn a shared zone appeared
94// in both banks.
95func archNodes(v ArchView) []Node {
96 seen := map[string]bool{}
97 var out []Node
98 for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
99 for _, n := range e.Nodes() {
100 if !seen[n.ID] {
101 seen[n.ID] = true
102 out = append(out, n)
103 }
104 }
105 }
106 return out
107}
108
109// Edges are NOT deduped by id — they have none — but they ARE deduped as
110// values, because a node drawn in two bands brings its edges into both (the
111// deployment bucket, which every region writes to). No edge spans two banks
112// or two bands, so after that the concatenation is the whole edge set with
113// nothing counted twice.
114func archEdges(v ArchView) []Edge {
115 seen := map[Edge]bool{}
116 var out []Edge
117 for _, e := range append(v.CARLOS.Edges(), v.Rest.Edges()...) {
118 if !seen[e] {
119 seen[e] = true
120 out = append(out, e)
121 }
122 }
123 return out
124}
125
126// archGroups merges the two banks' tables back into one table per title, so
127// a test asking "is this resource on the page" does not have to care which
128// half it is on.
129func archGroups(v ArchView) []ResourceGroup {
130 var out []ResourceGroup
131 at := map[string]int{}
132 for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
133 for _, g := range e.Groups {
134 if i, ok := at[g.Title]; ok {
135 out[i].Rows = append(out[i].Rows, g.Rows...)
136 continue
137 }
138 at[g.Title] = len(out)
139 out = append(out, g)
140 }
141 }
142 return out
143}
144
145func hasEdge(v ArchView, from, to string) bool {
146 for _, e := range archEdges(v) {
147 if e.From == from && e.To == to {
148 return true
149 }
150 }
151 return false
152}
153
154func hasNode(v ArchView, id string) bool {
155 for _, n := range archNodes(v) {
156 if n.ID == id {
157 return true
158 }
159 }
160 return false
161}
162
163// Unjoined in either direction is DRAWN, never hidden — the rule most of
164// this page's value rests on.
165func TestArchShowsUnjoinedBothWays(t *testing.T) {
166 v := BuildArch(archFixture())
167
168 var found bool
169 for _, n := range archNodes(v) {
170 if n.ID == "machine:i-999" {
171 found = true
172 if n.Subtitle == "" {
173 t.Error("an unclaimed machine must say so")
174 }
175 }
176 }
177 if !found {
178 t.Error("a machine no box reports must still be drawn (it is drift)")
179 }
180
181 var pi *BoxCard
182 for i := range v.Boxes {
183 if v.Boxes[i].Label == "pi-dublin" {
184 pi = &v.Boxes[i]
185 }
186 }
187 if pi == nil {
188 t.Fatal("a box with no cloud machine must still get a card — it is a BYO box, not an error")
189 }
190 if pi.CloudID != "" {
191 t.Errorf("pi CloudID = %q, want empty", pi.CloudID)
192 }
193}
194
195// A machine claimed by a reporting box is drawn ONCE, as that box — a second
196// machine:<id> node beside it would say the estate has two things where it
197// has one.
198func TestArchClaimedMachineIsNotDrawnTwice(t *testing.T) {
199 v := BuildArch(archFixture())
200 if hasNode(v, "machine:i-123") {
201 t.Error("a machine a box reports must be drawn as the box, not a second time as a machine")
202 }
203 if !hasNode(v, "box:pi-dublin") {
204 t.Error("a BYO box must still be drawn")
205 }
206 // The apps a box runs are ON the box, not a node beside it. They used to
207 // be a column; a picture that fits the page cannot spare one for a fact
208 // that is a property of something already drawn.
209 if hasNode(v, "apps:carlos-eu-west-1") {
210 t.Error("apps are a line on the box card, not a node of their own")
211 }
212 if n := nodeByID(v, "box:carlos-eu-west-1"); n == nil {
213 t.Fatal("no box node")
214 } else if len(n.Extra) != 2 {
215 t.Errorf("box card Extra = %q, want the apps line and the counts line", n.Extra)
216 }
217}
218
219// A record pointing at an address the estate does not own leaves the
220// diagram rather than being dropped (spec §6.3).
221func TestArchDrawsRecordLeavingTheEstate(t *testing.T) {
222 in := archFixture()
223 in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
224 Kind: infraview.KindDNSRecord, ID: "r2", Name: "mail.carloku.com",
225 Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "203.0.113.9"},
226 })
227 v := BuildArch(in)
228 if !hasNode(v, "external:203.0.113.9") {
229 t.Errorf("an address the estate does not own must be drawn leaving the diagram: %+v", archNodes(v))
230 }
231 if !hasEdge(v, "record:r2", "external:203.0.113.9") {
232 t.Errorf("the record must still point somewhere: %+v", archEdges(v))
233 }
234 // The zone is NOT drawn — it is global, and a global node cannot sit in a
235 // region band without being either nowhere or everywhere (archregion.go).
236 // It is still listed in full in the DNS table.
237 if hasNode(v, "zone:Z1") {
238 t.Error("a zone is table-only; it belongs to no region and must not be drawn")
239 }
240}
241
242// Cost is the rate card's, so this page and /fleet/economics can never
243// disagree; drift is flagged, not silently priced away.
244func TestArchCostAndDrift(t *testing.T) {
245 in := archFixture()
246 v := BuildArch(in)
247 var box BoxCard
248 for _, b := range v.Boxes {
249 if b.Label == "carlos-eu-west-1" {
250 box = b
251 }
252 }
253 want := 0.0336*economics.HoursPerMonth + 30*0.088
254 if diff := box.USDMonth - want; diff > 0.001 || diff < -0.001 {
255 t.Errorf("USDMonth = %v, want %v (rate card, not a second cost model)", box.USDMonth, want)
256 }
257 // The card says 30 GB of EBS; the estate has 40. That is drift.
258 if len(box.Drift) == 0 {
259 t.Error("a 30 GB rate-card entry against a 40 GB discovered disk must raise drift")
260 }
261
262 // A box WE pay for that has no rate-card entry of its own is also
263 // drift: it is being priced by the default spec, which is a guess.
264 // Machine-backed on purpose — a bring-your-own box is not priced at
265 // all, so it has no pricing to be wrong about (TestByoBoxIsNotPriced).
266 noEntry := archFixture()
267 delete(noEntry.Rates.Boxes, "carlos-eu-west-1")
268 var defaulted BoxCard
269 for _, b := range BuildArch(noEntry).Boxes {
270 if b.Label == "carlos-eu-west-1" {
271 defaulted = b
272 }
273 }
274 if defaulted.CloudID == "" {
275 t.Fatal("fixture drift: carlos-eu-west-1 must be machine-backed for this case")
276 }
277 var said bool
278 for _, d := range defaulted.Drift {
279 if strings.Contains(d, "default spec") {
280 said = true
281 }
282 }
283 if !said {
284 t.Errorf("a box we pay for, priced by the default spec, must say so: %v", defaulted.Drift)
285 }
286}
287
288// The two drift rules are independent: a box whose rate-card entry MATCHES
289// the estate reports no drift at all. Without this the test above passes on
290// a builder that flags every box unconditionally.
291func TestArchNoDriftWhenCardMatchesEstate(t *testing.T) {
292 in := archFixture()
293 rc := in.Rates.Boxes["carlos-eu-west-1"]
294 rc.EBSGB = 40
295 in.Rates.Boxes["carlos-eu-west-1"] = rc
296 v := BuildArch(in)
297 for _, b := range v.Boxes {
298 if b.Label == "carlos-eu-west-1" && len(b.Drift) != 0 {
299 t.Errorf("a rate card that agrees with the estate must raise no drift: %v", b.Drift)
300 }
301 }
302}
303
304// Resources the graph has no opinion about must still reach the page.
305func TestArchGroupsCarryOtherResources(t *testing.T) {
306 v := BuildArch(archFixture())
307 for _, g := range archGroups(v) {
308 for _, r := range g.Rows {
309 if r.NativeType == "sqs" {
310 return
311 }
312 }
313 }
314 t.Fatal("an unmapped resource must appear in the resource groups")
315}
316
317// A kind this vocabulary has no word for is carried through by infraview;
318// the page must not be the place it disappears.
319func TestArchGroupsCarryUnknownKinds(t *testing.T) {
320 in := archFixture()
321 in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
322 Kind: "quantum_widget", NativeType: "qc:widget", ID: "q-1", Name: "q-1",
323 Attrs: map[string]string{"spin": "up", "charm": "yes"},
324 })
325 v := BuildArch(in)
326 for _, g := range archGroups(v) {
327 for _, r := range g.Rows {
328 if r.Name == "q-1" {
329 if r.Detail != "charm=yes, spin=up" {
330 t.Errorf("Detail = %q, want every attr, sorted by key", r.Detail)
331 }
332 return
333 }
334 }
335 }
336 t.Fatal("a resource of an unknown kind must still reach the resource groups")
337}
338
339// No inventory at all: the CARLOS half still renders and Missing says which
340// of the three states applies.
341func TestArchWithoutInventory(t *testing.T) {
342 in := archFixture()
343 in.Inv = nil
344 v := BuildArch(in)
345 if v.Missing == "" {
346 t.Error("a nil inventory must set Missing")
347 }
348 if len(v.Boxes) != 2 {
349 t.Errorf("boxes = %d, want the CARLOS half to render regardless", len(v.Boxes))
350 }
351}
352
353// Every read is independent, so every combination of missing reads has to
354// produce a page rather than a panic — including all of them missing.
355func TestArchSurvivesEveryEmptyInput(t *testing.T) {
356 full := archFixture()
357 cases := map[string]ArchInput{
358 "all nil": {},
359 "no fleet": {Inv: full.Inv, Hosts: full.Hosts, Routes: full.Routes, Rates: full.Rates},
360 "no hosts": {Inv: full.Inv, Fleet: full.Fleet, Routes: full.Routes, Rates: full.Rates},
361 "no routes": {Inv: full.Inv, Fleet: full.Fleet, Hosts: full.Hosts, Rates: full.Rates},
362 "no rates": {Inv: full.Inv, Fleet: full.Fleet, Hosts: full.Hosts, Routes: full.Routes},
363 "carlos only": {Fleet: full.Fleet, Hosts: full.Hosts, Routes: full.Routes, Rates: full.Rates},
364 }
365 for name, in := range cases {
366 t.Run(name, func(t *testing.T) {
367 v := BuildArch(in)
368 if name == "all nil" && v.Missing != "never" {
369 t.Errorf("Missing = %q, want never", v.Missing)
370 }
371 if name == "no fleet" || name == "no hosts" {
372 if len(v.Boxes) != 2 {
373 t.Errorf("boxes = %d, want 2 from the half that did read", len(v.Boxes))
374 }
375 }
376 })
377 }
378}
379
380// A box reported by the fleet but absent from the host table (and the other
381// way round) is still one box, not two.
382func TestArchBoxCardsAreTheUnionOfBothReads(t *testing.T) {
383 in := archFixture()
384 in.Hosts = append(in.Hosts, HostRow{Label: "extra-box", Region: "us-east-1", Stale: true, Dark: "3h"})
385 v := BuildArch(in)
386 if len(v.Boxes) != 3 {
387 t.Fatalf("boxes = %d, want 3", len(v.Boxes))
388 }
389 if v.Boxes[0].Label != "carlos-eu-west-1" || v.Boxes[1].Label != "extra-box" || v.Boxes[2].Label != "pi-dublin" {
390 t.Errorf("cards must be sorted by label: %v %v %v", v.Boxes[0].Label, v.Boxes[1].Label, v.Boxes[2].Label)
391 }
392 if !v.Boxes[1].Stale || v.Boxes[1].Dark != "3h" {
393 t.Errorf("a host-only box must keep its staleness: %+v", v.Boxes[1])
394 }
395 if v.Boxes[0].Release != "abc1234" {
396 t.Errorf("Release = %q, want the host table's", v.Boxes[0].Release)
397 }
398}
399
400// The inventory header is carried verbatim: an operator reading a stale
401// estate must be able to see how stale, and which reads failed.
402func TestArchCarriesInventoryHeader(t *testing.T) {
403 in := archFixture()
404 in.Inv.Partial = []string{"security groups in eu-west-1: throttled"}
405 v := BuildArch(in)
406 if v.Account != "1234" {
407 t.Errorf("Account = %q", v.Account)
408 }
409 if !v.TakenAt.Equal(in.Inv.TakenAt) {
410 t.Errorf("TakenAt = %v", v.TakenAt)
411 }
412 if len(v.Partial) != 1 {
413 t.Errorf("Partial = %v", v.Partial)
414 }
415 if v.Missing != "" {
416 t.Errorf("Missing = %q, want empty when an inventory was handed over", v.Missing)
417 }
418}
419
420// The bucket edge is the one bucket relationship the console can state as
421// fact, and only when it has been told which bucket it is configured with —
422// a guessed edge on this page would be worse than no edge.
423func TestArchBucketEdgeOnlyWhenNamed(t *testing.T) {
424 v := BuildArch(archFixture())
425 if hasEdge(v, "box:carlos-eu-west-1", "bucket:carlos-data-ie") {
426 t.Error("no bucket was named, so no box may claim to store into one")
427 }
428 in := archFixture()
429 in.Bucket = "carlos-data-ie"
430 v = BuildArch(in)
431 if !hasEdge(v, "box:carlos-eu-west-1", "bucket:carlos-data-ie") ||
432 !hasEdge(v, "box:pi-dublin", "bucket:carlos-data-ie") {
433 t.Errorf("every reporting box edges to the deployment bucket: %+v", archEdges(v))
434 }
435 if !hasNode(v, "bucket:carlos-data-ie") {
436 t.Error("the deployment bucket must be a node")
437 }
438}
439
440// A partial inventory is normal, so an attribute naming a resource the
441// estate read never returned must not leave an arrow pointing at nothing.
442func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) {
443 in := archFixture()
444 in.Inv.Resources = append(in.Inv.Resources,
445 infraview.Resource{Kind: infraview.KindIP, ID: "eipalloc-2",
446 Attrs: map[string]string{"address": "52.0.0.2", "attached_to": "i-gone"}},
447 infraview.Resource{Kind: infraview.KindAlarm, ID: "a-gone",
448 Attrs: map[string]string{"target": "i-gone"}},
449 )
450 v := BuildArch(in)
451 // Per PICTURE, not across the page: an edge is only not-dangling if both
452 // its ends are drawn in the same diagram it is drawn in, and a picture
453 // is now one region band rather than one estate.
454 for name, est := range eachBand(v) {
455 drawn := map[string]bool{}
456 for _, n := range est.Nodes {
457 drawn[n.ID] = true
458 }
459 _ = name
460 for _, e := range est.Edges {
461 if !drawn[e.From] || !drawn[e.To] {
462 t.Errorf("edge %+v points at a node this picture does not draw", e)
463 }
464 }
465 }
466 if !hasNode(v, "ip:eipalloc-2") {
467 t.Error("an IP attached to a machine the read missed is still part of the estate")
468 }
469}
470
471// Every edge on a built view must reach the page. RenderArchSVG skips an
472// edge whose endpoints it cannot place, and that skip is the second net,
473// not the mechanism: BuildArch prunes dangling edges after layout, so by
474// the time the renderer runs there is nothing left for the skip to catch.
475// A build that quietly lost an edge to the skip would otherwise look fine.
476//
477// The count matches the class ATTRIBUTE's opening rather than the whole
478// attribute, because an edge is drawn one of two ways: an adjacent-layer edge
479// carries `class="archedge"` and a layer-skipping one `class="archedge
480// archedge-skip"` (RenderArchSVG). Matching the closed `archedge"` counted
481// only the first kind, so a page drawing every edge correctly still failed
482// this — and, worse, a page that genuinely LOST a skipping edge would have
483// passed once the numbers happened to line up.
484func TestEveryEdgeReachesThePage(t *testing.T) {
485 in := archFixture()
486 in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
487 Kind: infraview.KindAlarm, ID: "auto-recover-i-123", Name: "auto-recover",
488 Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"},
489 })
490 v := BuildArch(in)
491 out := renderBoth(v)
492 // No closing quote on the class: a layer-skipping edge is drawn as
493 // `archedge archedge-skip` and is an edge the page draws all the same.
494 if got := strings.Count(out, `<path class="archedge`); got != len(archEdges(v)) {
495 t.Errorf("page draws %d edges, view carries %d", got, len(archEdges(v)))
496 }
497}
498
499// A zone can carry dozens of A records aimed at one address — one per app
500// host, all behind the same Elastic IP — and the diagram must draw that as
501// ONE arrow, not dozens, while the DNS resource group below still lists
502// every record individually: grouping happens only in the picture.
503func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
504 in := archFixture()
505 // r1 (from the fixture) already shares zone "carloku.com" and value
506 // "52.0.0.1" with these — 21 records in all should collapse to one node.
507 for i := 0; i < 20; i++ {
508 in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
509 Kind: infraview.KindDNSRecord, ID: fmt.Sprintf("bulk-%02d", i),
510 Name: fmt.Sprintf("app%02d.carloku.com", i),
511 Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"},
512 })
513 }
514 v := BuildArch(in)
515
516 recordNodes := 0
517 for _, n := range archNodes(v) {
518 if n.Kind == "record" {
519 recordNodes++
520 }
521 }
522 if recordNodes != 1 {
523 t.Errorf("record nodes = %d, want 1 (all 21 records share zone+target)", recordNodes)
524 }
525 // The grouped node's id is deterministic (the lowest record id in the
526 // group, by string sort) rather than depending on input order — here
527 // that is "bulk-00", which sorts before "r1".
528 if !hasEdge(v, "record:bulk-00", "ip:eipalloc-1") {
529 t.Errorf("grouped node must use its lowest-id member's id: %+v", archNodes(v))
530 }
531
532 // Ruling 19 asked the grouped node to carry the count AND up to 3
533 // example names — pin the label text, not just the node count.
534 var recordNode *Node
535 nodes := archNodes(v)
536 for i := range nodes {
537 if nodes[i].Kind == "record" {
538 recordNode = &nodes[i]
539 }
540 }
541 if recordNode == nil {
542 t.Fatal("no record node drawn")
543 }
544 if recordNode.Title != "21 records" {
545 t.Errorf("Title = %q, want %q", recordNode.Title, "21 records")
546 }
547 wantSub := "app00.carloku.com, app01.carloku.com, app02.carloku.com, +18"
548 if recordNode.Subtitle != wantSub {
549 t.Errorf("Subtitle = %q, want %q (3 sorted example names + the remainder)", recordNode.Subtitle, wantSub)
550 }
551
552 var dnsRows int
553 for _, g := range archGroups(v) {
554 if g.Title == "DNS" {
555 dnsRows = len(g.Rows)
556 }
557 }
558 // 1 zone row + 21 record rows: nothing hidden, just not repeated in the
559 // diagram.
560 if dnsRows != 22 {
561 t.Errorf("DNS resource group rows = %d, want 22 (every record listed individually)", dnsRows)
562 }
563}
564
565// A count is a claim. The fleet aggregate is the ONLY read that carries
566// static/resident/hibernating, so a box that read never described — a box
567// present only in the private host table, or every box when the fleet read
568// failed outright — must say its counts are unknown rather than print three
569// confident zeroes beside an "unknown" awake/asleep two words away.
570func TestArchCountsAreUnknownWhenTheFleetNeverDescribedTheBox(t *testing.T) {
571 in := archFixture()
572 in.Hosts = append(in.Hosts, HostRow{Label: "extra-box", Region: "us-east-1"})
573 v := BuildArch(in)
574
575 byLabel := map[string]BoxCard{}
576 for _, b := range v.Boxes {
577 byLabel[b.Label] = b
578 }
579 described, ok := byLabel["carlos-eu-west-1"]
580 if !ok {
581 t.Fatal("no card for the fleet-described box")
582 }
583 if !described.CountsKnown {
584 t.Error("a box the fleet described must carry its counts")
585 }
586 if got := countsSubtitle(described); !strings.Contains(got, "static 0 · resident 0 · hibernating 0") {
587 t.Errorf("countsSubtitle = %q, want the fleet's own counts", got)
588 }
589
590 undescribed, ok := byLabel["extra-box"]
591 if !ok {
592 t.Fatal("no card for the host-only box")
593 }
594 if undescribed.CountsKnown {
595 t.Error("the fleet never described this box, so its counts are not known")
596 }
597 if got := countsSubtitle(undescribed); got != "counts unknown" {
598 t.Errorf("countsSubtitle = %q, want %q — a read that never happened is not a zero", got, "counts unknown")
599 }
600 if n := nodeByID(v, "box:extra-box"); n == nil {
601 t.Fatal("no box node for the host-only box")
602 } else if strings.Contains(strings.Join(n.Extra, " "), "static") {
603 t.Errorf("the diagram states counts for a box the fleet never described: %q", n.Extra)
604 }
605}
606
607// The same rule with the fleet read gone entirely: every card says unknown,
608// and no zero reaches the diagram.
609func TestArchCountsAreUnknownWithNoFleetRead(t *testing.T) {
610 in := archFixture()
611 in.Fleet = nil
612 v := BuildArch(in)
613 if len(v.Boxes) == 0 {
614 t.Fatal("the host table alone must still produce cards")
615 }
616 for _, b := range v.Boxes {
617 if b.CountsKnown {
618 t.Errorf("%s: counts claimed with no fleet read at all", b.Label)
619 }
620 }
621 for _, n := range archNodes(v) {
622 if n.Kind == "apps" && strings.Contains(n.Subtitle, "static") {
623 t.Errorf("%s subtitle states counts nobody read: %q", n.ID, n.Subtitle)
624 }
625 }
626}
627
628func nodeByID(v ArchView, id string) *Node {
629 nodes := archNodes(v)
630 for i := range nodes {
631 if nodes[i].ID == id {
632 return &nodes[i]
633 }
634 }
635 return nil
636}
637