| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/carlosframework/platform/internal/economics" |
| 8 | "github.com/carlosframework/platform/internal/infraview" |
| 9 | "github.com/carlosframework/platform/internal/status" |
| 10 | ) |
| 11 | |
| 12 | // sharedAccountFixture is the shape this whole feature exists for: one |
| 13 | // account carrying a CARLOS deployment AND something else. It is modelled on |
| 14 | // Tito's real account — a CARLOS box beside a Kubernetes estate, one DNS zone |
| 15 | // answering for both, and a bucket named as if it were CARLOS's that the |
| 16 | // console was never told about. |
| 17 | func sharedAccountFixture() ArchInput { |
| 18 | inv := &infraview.Inventory{Provider: "aws", Account: "665106592142", Resources: []infraview.Resource{ |
| 19 | // One zone, both estates. This is the coupling that makes the two |
| 20 | // halves one account rather than two. |
| 21 | {Kind: infraview.KindDNSZone, ID: "Z1", Name: "tito.io", |
| 22 | Attrs: map[string]string{"record_count": "202"}}, |
| 23 | |
| 24 | // --- the CARLOS half ------------------------------------------ |
| 25 | {Kind: infraview.KindMachine, ID: "i-carlos", Name: "carlos-tito-ie", Region: "eu-west-1", |
| 26 | Attrs: map[string]string{ |
| 27 | "type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1", |
| 28 | "security_groups": "sg-carlos", |
| 29 | "iam_profile": "arn:aws:iam::665106592142:instance-profile/carlos-tito-host-ie", |
| 30 | }}, |
| 31 | {Kind: infraview.KindIP, ID: "eip-carlos", |
| 32 | Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-carlos"}}, |
| 33 | {Kind: infraview.KindDisk, ID: "vol-carlos", |
| 34 | Attrs: map[string]string{"size_gb": "40", "attached_to": "i-carlos"}}, |
| 35 | {Kind: infraview.KindFirewall, ID: "sg-carlos", Name: "carlos-tito-ie"}, |
| 36 | {Kind: infraview.KindIdentity, ID: "arn:aws:iam::665106592142:role/carlos-tito-host-ie", |
| 37 | Name: "carlos-tito-host-ie", Attrs: map[string]string{"kind": "role"}}, |
| 38 | {Kind: infraview.KindAlarm, ID: "alarm-carlos", Name: "auto-recover-ie", |
| 39 | Attrs: map[string]string{"target": "i-carlos", "state": "OK"}}, |
| 40 | {Kind: infraview.KindDNSRecord, ID: "rec-carlos", Name: "go.tito.io", |
| 41 | Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "52.0.0.1"}}, |
| 42 | {Kind: infraview.KindBucket, ID: "carlos-tito-platform", Name: "carlos-tito-platform"}, |
| 43 | |
| 44 | // --- everything else the account carries ----------------------- |
| 45 | {Kind: infraview.KindMachine, ID: "i-eks", Name: "workers-a-03", Region: "eu-west-1", |
| 46 | Attrs: map[string]string{ |
| 47 | "type": "m5.large", "state": "running", "public_ip": "52.0.0.9", |
| 48 | "security_groups": "sg-eks", |
| 49 | "iam_profile": "arn:aws:iam::665106592142:instance-profile/eks-2acb3b4e", |
| 50 | }}, |
| 51 | {Kind: infraview.KindFirewall, ID: "sg-eks", Name: "eks-cluster-sg"}, |
| 52 | {Kind: infraview.KindIdentity, ID: "arn:aws:iam::665106592142:role/eks-node-role", |
| 53 | Name: "eks-node-role", Attrs: map[string]string{"kind": "role"}}, |
| 54 | {Kind: infraview.KindDNSRecord, ID: "rec-legacy", Name: "ti.to", |
| 55 | Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "52.0.0.9"}}, |
| 56 | {Kind: infraview.KindNetwork, ID: "vpc-1", Name: "tito-production"}, |
| 57 | |
| 58 | // A bucket the console was NEVER told about, wearing the CARLOS |
| 59 | // name. It is the whole reason this file matches on joins rather |
| 60 | // than on names: nothing here can prove it belongs to CARLOS. |
| 61 | {Kind: infraview.KindBucket, ID: "carlos-tito-se-665106592142", Name: "carlos-tito-se-665106592142"}, |
| 62 | }} |
| 63 | return ArchInput{ |
| 64 | Inv: inv, |
| 65 | Bucket: "carlos-tito-platform", |
| 66 | Fleet: &status.Fleet{Boxes: []status.FleetBox{{Label: "carlos-tito-ie", Region: "eu-west-1"}}}, |
| 67 | Hosts: []HostRow{{Label: "carlos-tito-ie", Region: "eu-west-1", Metrics: &status.BoxMetrics{ |
| 68 | Release: "abc1234", InstanceID: "i-carlos", |
| 69 | }}}, |
| 70 | Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{ |
| 71 | "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 40}, |
| 72 | }, EBSGBMonthUSD: 0.088}, |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // The membership closure, one join at a time. Each of these is a hop the |
| 77 | // console can PROVE; the failure this pins is a join quietly widening into a |
| 78 | // name match, which would make every row on the page a guess. |
| 79 | func TestEstateClosureReachesWhatItCanProve(t *testing.T) { |
| 80 | v := BuildArch(sharedAccountFixture()) |
| 81 | |
| 82 | // Keyed by (table, name) rather than name alone: on the real estate a |
| 83 | // security group is named after the box it guards, so "carlos-tito-ie" |
| 84 | // is both a machine and a firewall — and the fixture keeps that. |
| 85 | rows := bankOfEachRow(v) |
| 86 | |
| 87 | for _, c := range []struct{ table, name, want, why string }{ |
| 88 | {"Compute", "carlos-tito-ie", "CARLOS", "the box itself reported this machine id"}, |
| 89 | {"Compute", "vol-carlos", "CARLOS", "a disk attached to a reported machine"}, |
| 90 | {"Network", "eip-carlos", "CARLOS", "an address attached to a reported machine"}, |
| 91 | {"Alarms", "auto-recover-ie", "CARLOS", "an alarm watching a reported machine"}, |
| 92 | {"Network", "carlos-tito-ie", "CARLOS", "a security group the machine itself named"}, |
| 93 | {"Identity", "carlos-tito-host-ie", "CARLOS", "the role behind the machine's instance profile"}, |
| 94 | {"DNS", "go.tito.io", "CARLOS", "a record resolving to a CARLOS address"}, |
| 95 | {"DNS", "tito.io", "CARLOS", "the zone holding such a record"}, |
| 96 | {"Storage", "carlos-tito-platform", "CARLOS", "the deployment bucket the console is configured with"}, |
| 97 | |
| 98 | {"Compute", "workers-a-03", "rest", "a machine no box reports"}, |
| 99 | {"Network", "eks-cluster-sg", "rest", "a security group no CARLOS machine names"}, |
| 100 | {"Identity", "eks-node-role", "rest", "a role no CARLOS instance profile names"}, |
| 101 | {"DNS", "ti.to", "rest", "a record resolving into the other estate"}, |
| 102 | {"Network", "tito-production", "rest", "a VPC nothing joins it to CARLOS"}, |
| 103 | |
| 104 | // The load-bearing one. This bucket is named exactly as CARLOS's |
| 105 | // own buckets are, and it still lands in the other half, because |
| 106 | // the console cannot trace a path to it. A page that put it in |
| 107 | // CARLOS would be reading a naming convention, and the day |
| 108 | // somebody renames a bucket that page becomes confidently wrong. |
| 109 | {"Storage", "carlos-tito-se-665106592142", "rest", "a carlos-NAMED bucket the console cannot prove is its own"}, |
| 110 | } { |
| 111 | if got := rows[c.table+"/"+c.name]; got != c.want { |
| 112 | t.Errorf("%s %s is in %q, want %q — %s", c.table, c.name, got, c.want, c.why) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // bankOfEachRow maps "<table>/<name>" to the bank its row was rendered in, |
| 118 | // or "both"/"" when the split lost track of it. |
| 119 | func bankOfEachRow(v ArchView) map[string]string { |
| 120 | out := map[string]string{} |
| 121 | for bank, groups := range map[string][]ResourceGroup{"CARLOS": v.CARLOS.Groups, "rest": v.Rest.Groups} { |
| 122 | for _, g := range groups { |
| 123 | for _, r := range g.Rows { |
| 124 | k := g.Title + "/" + r.Name |
| 125 | if out[k] != "" { |
| 126 | out[k] = "both" |
| 127 | continue |
| 128 | } |
| 129 | out[k] = bank |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | return out |
| 134 | } |
| 135 | |
| 136 | // Nothing is filtered and nothing is hidden: this page GROUPS. Every |
| 137 | // resource in the inventory appears exactly once across the two banks |
| 138 | // together — never in neither (which would hide it) and never in both |
| 139 | // (which would double-count the estate). |
| 140 | func TestEveryResourceAppearsExactlyOnceAcrossBothBanks(t *testing.T) { |
| 141 | in := sharedAccountFixture() |
| 142 | v := BuildArch(in) |
| 143 | |
| 144 | count := map[string]int{} |
| 145 | total := 0 |
| 146 | for _, g := range append(append([]ResourceGroup{}, v.CARLOS.Groups...), v.Rest.Groups...) { |
| 147 | for _, r := range g.Rows { |
| 148 | count[g.Title+"/"+r.Name]++ |
| 149 | total++ |
| 150 | } |
| 151 | } |
| 152 | if total != len(in.Inv.Resources) { |
| 153 | t.Errorf("banks carry %d rows, inventory has %d resources", total, len(in.Inv.Resources)) |
| 154 | } |
| 155 | for _, r := range in.Inv.Resources { |
| 156 | k := archGroupOf[r.Kind] + "/" + nameOf(r) |
| 157 | switch n := count[k]; n { |
| 158 | case 1: |
| 159 | case 0: |
| 160 | t.Errorf("%s (%s) is on neither bank — the split must group, never hide", nameOf(r), r.Kind) |
| 161 | default: |
| 162 | t.Errorf("%s (%s) appears %d times — the estate must not be double-counted", nameOf(r), r.Kind, n) |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // The split is only honest if no relationship is lost to it. An edge whose |
| 168 | // ends fell into different banks would be pruned out of both and vanish |
| 169 | // silently, so the shape of the graph has to be such that none exists: a |
| 170 | // node that genuinely serves both halves (the shared DNS zone) is DRAWN IN |
| 171 | // BOTH pictures rather than picked for one. |
| 172 | func TestNoEdgeSpansTheTwoBanks(t *testing.T) { |
| 173 | v := BuildArch(sharedAccountFixture()) |
| 174 | |
| 175 | bank := map[string]string{} |
| 176 | for _, n := range v.CARLOS.Nodes() { |
| 177 | bank[n.ID] = "CARLOS" |
| 178 | } |
| 179 | for _, n := range v.Rest.Nodes() { |
| 180 | if bank[n.ID] == "CARLOS" { |
| 181 | bank[n.ID] = "both" |
| 182 | continue |
| 183 | } |
| 184 | bank[n.ID] = "rest" |
| 185 | } |
| 186 | for _, e := range archEdges(v) { |
| 187 | f, to := bank[e.From], bank[e.To] |
| 188 | if f == "both" || to == "both" || f == to { |
| 189 | continue |
| 190 | } |
| 191 | t.Errorf("edge %s (%s) -> %s (%s) spans both banks and would be pruned out of both", |
| 192 | e.From, f, e.To, to) |
| 193 | } |
| 194 | |
| 195 | // The zone straddles both halves — it answers for records on either side |
| 196 | // — and is drawn in NEITHER, because a zone belongs to no region and a |
| 197 | // region band is what a picture is now (archregion.go). It reaches the |
| 198 | // page as a table row, exactly once, on the side that can prove it. |
| 199 | if got := bankOfEachRow(v)["DNS/tito.io"]; got != "CARLOS" { |
| 200 | t.Errorf("the shared zone's table row is %q, want exactly one row, on the CARLOS side", got) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // Each picture is deterministic on its own, and neither depends on the |
| 205 | // other: layout runs twice over disjoint node sets and the second run must |
| 206 | // not be able to move the first. |
| 207 | func TestBothPicturesAreDeterministic(t *testing.T) { |
| 208 | a := BuildArch(sharedAccountFixture()) |
| 209 | b := BuildArch(sharedAccountFixture()) |
| 210 | if renderBoth(a) != renderBoth(b) { |
| 211 | t.Fatal("two builds of the same shared account produced different SVG") |
| 212 | } |
| 213 | if renderEstate(a.CARLOS) == renderEstate(a.Rest) { |
| 214 | t.Fatal("fixture drift: the two pictures should differ, or this test proves nothing") |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // An account with nothing but CARLOS in it — the ordinary deployment — |
| 219 | // draws an empty second picture and no orphan rows, rather than inventing a |
| 220 | // second estate out of whatever it could not join. |
| 221 | func TestAnAccountWithOnlyCARLOSHasAnEmptySecondBank(t *testing.T) { |
| 222 | in := sharedAccountFixture() |
| 223 | kept := in.Inv.Resources[:0] |
| 224 | for _, r := range in.Inv.Resources { |
| 225 | switch r.ID { |
| 226 | case "i-eks", "sg-eks", "arn:aws:iam::665106592142:role/eks-node-role", |
| 227 | "rec-legacy", "vpc-1", "carlos-tito-se-665106592142": |
| 228 | continue |
| 229 | } |
| 230 | kept = append(kept, r) |
| 231 | } |
| 232 | in.Inv.Resources = kept |
| 233 | |
| 234 | v := BuildArch(in) |
| 235 | for _, g := range v.Rest.Groups { |
| 236 | t.Errorf("nothing should be in the second bank, found %s: %+v", g.Title, g.Rows) |
| 237 | } |
| 238 | for _, n := range v.Rest.Nodes() { |
| 239 | t.Errorf("nothing should be drawn in the second picture, found %s", n.ID) |
| 240 | } |
| 241 | // An empty estate still draws ONE band, so the page has a picture where |
| 242 | // one belongs rather than a gap, and that band still carries a positive |
| 243 | // viewBox. |
| 244 | if len(v.Rest.Bands) != 1 { |
| 245 | t.Fatalf("an empty second estate should still draw one band, got %d", len(v.Rest.Bands)) |
| 246 | } |
| 247 | if b := v.Rest.Bands[0]; b.Width <= 0 || b.Height <= 0 { |
| 248 | t.Errorf("an empty second picture must still carry a positive viewBox: %dx%d", b.Width, b.Height) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // The one derived join on the page, pinned in both directions. An instance |
| 253 | // profile's ARN and its role's ARN are matched on their trailing name; a |
| 254 | // profile whose name matches no role must join nothing rather than reach for |
| 255 | // the nearest thing. |
| 256 | func TestInstanceProfileJoinMatchesOnlyItsOwnRole(t *testing.T) { |
| 257 | for _, c := range []struct{ arn, want string }{ |
| 258 | {"arn:aws:iam::1:role/carlos-tito-host-ie", "carlos-tito-host-ie"}, |
| 259 | {"arn:aws:iam::1:instance-profile/carlos-tito-host-ie", "carlos-tito-host-ie"}, |
| 260 | {"arn:aws:iam::1:role/service-role/nested", "nested"}, |
| 261 | {"carlos-tito-host-ie", ""}, |
| 262 | {"arn:aws:iam::1:role/", ""}, |
| 263 | {"", ""}, |
| 264 | } { |
| 265 | if got := arnTail(c.arn); got != c.want { |
| 266 | t.Errorf("arnTail(%q) = %q, want %q", c.arn, got, c.want) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | in := sharedAccountFixture() |
| 271 | for i := range in.Inv.Resources { |
| 272 | if in.Inv.Resources[i].ID == "i-carlos" { |
| 273 | in.Inv.Resources[i].Attrs["iam_profile"] = |
| 274 | "arn:aws:iam::665106592142:instance-profile/something-else" |
| 275 | } |
| 276 | } |
| 277 | v := BuildArch(in) |
| 278 | for _, g := range v.CARLOS.Groups { |
| 279 | for _, r := range g.Rows { |
| 280 | if strings.Contains(r.Name, "carlos-tito-host-ie") && g.Title == "Identity" { |
| 281 | t.Error("a role whose name no CARLOS instance profile carries must not join") |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | |