| 1 | // Which region band each drawn node belongs to. |
| 2 | // |
| 3 | // THE GOVERNING RULE: A NODE'S BAND IS THE BAND OF THE MACHINE IT REACHES. |
| 4 | // |
| 5 | // A region is a physical fact about a MACHINE and about almost nothing else |
| 6 | // on this page. DNS is global — every one of Route 53's records and zones |
| 7 | // comes back from the estate read with no region at all — an Elastic IP is |
| 8 | // in a region but is only interesting for the box it is attached to, and a |
| 9 | // bucket is written to from everywhere. So exactly two kinds ANCHOR a band |
| 10 | // by stating their own region (a box, and a machine no box reports), and |
| 11 | // everything else FOLLOWS the anchors it is joined to. |
| 12 | // |
| 13 | // The payoff is a property, not a preference: because a follower takes the |
| 14 | // band of whatever it is joined to, no edge can span two bands, and each |
| 15 | // band is therefore a self-contained little diagram that can be laid out |
| 16 | // and drawn on its own. That is what lets the page scroll down instead of |
| 17 | // across — the width of the picture stops being the width of the whole |
| 18 | // estate and becomes the width of one region's flow. |
| 19 | // |
| 20 | // A follower joined to two regions is drawn in BOTH, exactly as the zone |
| 21 | // shared by both estate banks is (archestate.go). The deployment bucket is |
| 22 | // the everyday case: nine boxes in six regions all write to it, and the |
| 23 | // alternative — drawing it once and letting five edges dangle — would hide |
| 24 | // a relationship the console knows first-hand. |
| 25 | // |
| 26 | // A node that reaches no anchor at all lands in the region-less band. That |
| 27 | // is not a failure state: a DNS name pointing outside the estate genuinely |
| 28 | // has no region, and saying so is the honest answer. It is drawn last, |
| 29 | // under its own heading, and never dropped. |
| 30 | package console |
| 31 | |
| 32 | import ( |
| 33 | "sort" |
| 34 | |
| 35 | "github.com/carlosframework/platform/internal/infraview" |
| 36 | ) |
| 37 | |
| 38 | // noRegion is the band for everything that reaches no machine. It sorts |
| 39 | // last and is labelled in words rather than left blank, because an unlabelled |
| 40 | // band reads as a rendering fault. |
| 41 | const noRegion = "" |
| 42 | |
| 43 | // anchorKinds state their own region. Deliberately just these two: they are |
| 44 | // the only nodes on the page that stand for something with a physical |
| 45 | // location. Adding a kind here would make it a source of truth about where |
| 46 | // its NEIGHBOURS live, which is how a bucket in eu-west-1 would drag nine |
| 47 | // boxes out of five other regions into one band. |
| 48 | func isAnchor(kind string) bool { return kind == "box" || kind == "machine" } |
| 49 | |
| 50 | // regionSeeds is what each node says about its OWN region, before any |
| 51 | // propagation. Anchors are believed; a follower's own region is kept only |
| 52 | // as the last resort in bandsOf (an allocated address attached to nothing |
| 53 | // still has a region, and putting it in the region-less band would be a |
| 54 | // worse answer than the one the estate gave us). |
| 55 | func regionSeeds(inv *infraview.Inventory, boxes []BoxCard) map[string]string { |
| 56 | seeds := map[string]string{} |
| 57 | for _, b := range boxes { |
| 58 | if b.Region != "" { |
| 59 | seeds["box:"+b.Label] = b.Region |
| 60 | } |
| 61 | } |
| 62 | if inv == nil { |
| 63 | return seeds |
| 64 | } |
| 65 | for _, r := range inv.Resources { |
| 66 | if r.Region == "" { |
| 67 | continue |
| 68 | } |
| 69 | switch r.Kind { |
| 70 | case infraview.KindMachine: |
| 71 | seeds["machine:"+r.ID] = r.Region |
| 72 | case infraview.KindIP: |
| 73 | seeds["ip:"+r.ID] = r.Region |
| 74 | case infraview.KindBucket: |
| 75 | seeds["bucket:"+nameOf(r)] = r.Region |
| 76 | } |
| 77 | } |
| 78 | return seeds |
| 79 | } |
| 80 | |
| 81 | // bandsOf decides, for every node, the set of bands it is drawn in. |
| 82 | // |
| 83 | // Anchors take their seed and are then fixed. Followers accumulate the |
| 84 | // bands of their neighbours — in BOTH directions along an edge, because a |
| 85 | // record reaches its box forwards and a bucket reaches its boxes backwards, |
| 86 | // and a rule that only looked one way would put half the followers in the |
| 87 | // region-less band. The sweep repeats until nothing changes; it is bounded |
| 88 | // by the node count, since each pass either adds a band to some node or is |
| 89 | // the last one. |
| 90 | // |
| 91 | // Union is commutative, so the result cannot depend on the order edges or |
| 92 | // nodes are visited — which is what keeps the whole picture deterministic |
| 93 | // (archlayout.go's contract) without this pass having to sort anything. |
| 94 | func bandsOf(nodes []Node, edges []Edge, seeds map[string]string) map[string]map[string]bool { |
| 95 | in := make(map[string]map[string]bool, len(nodes)) |
| 96 | kind := make(map[string]string, len(nodes)) |
| 97 | for _, n := range nodes { |
| 98 | in[n.ID] = map[string]bool{} |
| 99 | kind[n.ID] = n.Kind |
| 100 | if isAnchor(n.Kind) { |
| 101 | if r, ok := seeds[n.ID]; ok && r != "" { |
| 102 | in[n.ID][r] = true |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | for pass := 0; pass <= len(nodes); pass++ { |
| 107 | changed := false |
| 108 | for _, e := range edges { |
| 109 | from, okF := in[e.From] |
| 110 | to, okT := in[e.To] |
| 111 | if !okF || !okT { |
| 112 | continue |
| 113 | } |
| 114 | if !isAnchor(kind[e.To]) { |
| 115 | for r := range from { |
| 116 | if !to[r] { |
| 117 | to[r] = true |
| 118 | changed = true |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | if !isAnchor(kind[e.From]) { |
| 123 | for r := range to { |
| 124 | if !from[r] { |
| 125 | from[r] = true |
| 126 | changed = true |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | if !changed { |
| 132 | break |
| 133 | } |
| 134 | } |
| 135 | // Last resort, and only now: a follower that reached no anchor keeps |
| 136 | // whatever region the estate gave it. Applying this before the sweep |
| 137 | // would let an Elastic IP's own region compete with the box it is |
| 138 | // attached to, which is the same mistake as making it an anchor. |
| 139 | for id, set := range in { |
| 140 | if len(set) == 0 { |
| 141 | if r := seeds[id]; r != "" { |
| 142 | set[r] = true |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | return in |
| 147 | } |
| 148 | |
| 149 | // bandOrder is the order the bands are drawn: alphabetical by region code, |
| 150 | // with the region-less band last. |
| 151 | // |
| 152 | // Alphabetical rather than by size, and rather than by any home-region |
| 153 | // notion, for the reason the whole diagram is deterministic: an operator |
| 154 | // reads this page to see what CHANGED, and an order that reshuffles when a |
| 155 | // box is added makes every band look like it moved. Region CODES rather |
| 156 | // than city names, because a code-to-city table would be a second source of |
| 157 | // truth about a cloud's geography and the code is what every other console, |
| 158 | // every CLI and every bill already says. |
| 159 | func bandOrder(bands map[string]bool) []string { |
| 160 | var named []string |
| 161 | for r := range bands { |
| 162 | if r != noRegion { |
| 163 | named = append(named, r) |
| 164 | } |
| 165 | } |
| 166 | sort.Strings(named) |
| 167 | if bands[noRegion] { |
| 168 | named = append(named, noRegion) |
| 169 | } |
| 170 | return named |
| 171 | } |
| 172 | |
| 173 | // bandLabel is the heading over one band. |
| 174 | func bandLabel(region string) string { |
| 175 | if region == noRegion { |
| 176 | return "Not in a region" |
| 177 | } |
| 178 | return region |
| 179 | } |
| 180 | |