| 1 | // Which half of the account a resource belongs to. |
| 2 | // |
| 3 | // A CARLOS deployment is very often a guest in an account that was already |
| 4 | // carrying something else — Tito's own account holds ~70 CARLOS resources |
| 5 | // beside ~1,750 belonging to a Kubernetes estate that predates it, and the |
| 6 | // two are deliberately kept in ONE account so the platform can reach the |
| 7 | // legacy database to sync from. Drawn as one picture that is a hairball; |
| 8 | // drawn as two it is two readable pictures. So /fleet/architecture splits |
| 9 | // the page in half. |
| 10 | // |
| 11 | // This file decides the split, and one rule governs it: |
| 12 | // |
| 13 | // MEMBERSHIP IS ESTABLISHED, NEVER GUESSED. A resource is CARLOS because |
| 14 | // the console can trace a path to it from something it knows first-hand — |
| 15 | // a machine a box reported, or the deployment bucket it is configured |
| 16 | // with — never because its name starts with "carlos-". A naming |
| 17 | // convention is a convention: it holds until somebody renames something, |
| 18 | // and then the page is confidently wrong with no way to tell. |
| 19 | // |
| 20 | // The consequence is that the join UNDER-reaches, and that is the honest |
| 21 | // trade. Measured against the real Tito estate the closure reaches 44 |
| 22 | // resources; eight sibling regional deployment buckets are genuinely |
| 23 | // unreachable from what this console knows and land in the other half. That |
| 24 | // is not a bug to paper over with a name match — it is the page saying |
| 25 | // exactly how much it can prove. |
| 26 | // |
| 27 | // Nothing is filtered and nothing is hidden: this is the same set of |
| 28 | // resources archgraph.go has always drawn, dealt into two banks. A resource |
| 29 | // the closure cannot reach is still on the page, still in a table, still |
| 30 | // with its every attribute — just under the other heading. |
| 31 | package console |
| 32 | |
| 33 | import ( |
| 34 | "strings" |
| 35 | |
| 36 | "github.com/carlosframework/platform/internal/infraview" |
| 37 | ) |
| 38 | |
| 39 | // Estate bits. A node can carry both: the account's one DNS zone genuinely |
| 40 | // serves both halves, and drawing it in only one picture would make the |
| 41 | // other picture's records resolve from nowhere. |
| 42 | const ( |
| 43 | estateCARLOS = 1 << iota |
| 44 | estateRest |
| 45 | ) |
| 46 | |
| 47 | // carlosResources is the membership closure: the set of inventory resource |
| 48 | // ids the console can PROVE belong to the CARLOS deployment. |
| 49 | // |
| 50 | // It is seeded by the two things the console knows first-hand — the machines |
| 51 | // its own boxes reported (claimed, built in BuildArch from each box's own |
| 52 | // instance-metadata read) and the deployment bucket it is configured with — |
| 53 | // and closed one hop outward along relationships the estate itself carries: |
| 54 | // |
| 55 | // machine -> its disks (disk.attached_to) |
| 56 | // machine -> its addresses (ip.attached_to) |
| 57 | // machine -> its alarms (alarm.target) |
| 58 | // machine -> its security groups (machine.security_groups) |
| 59 | // machine -> its instance role (machine.iam_profile) |
| 60 | // address -> the records naming it, and those records' zone |
| 61 | // |
| 62 | // Every one of those is an id-to-id join except the last two, which are |
| 63 | // described where they are made. |
| 64 | func carlosResources(inv *infraview.Inventory, claimed map[string]string, bucket string) map[string]bool { |
| 65 | in := map[string]bool{} |
| 66 | if inv == nil { |
| 67 | return in |
| 68 | } |
| 69 | |
| 70 | // Seed: the machines the boxes themselves reported. claimed is keyed by |
| 71 | // machine id, so this is an exact join and a box whose machine has since |
| 72 | // been terminated simply seeds nothing. |
| 73 | machines := map[string]bool{} |
| 74 | for _, m := range inv.ByKind(infraview.KindMachine) { |
| 75 | if claimed[m.ID] == "" { |
| 76 | continue |
| 77 | } |
| 78 | machines[m.ID] = true |
| 79 | in[m.ID] = true |
| 80 | } |
| 81 | |
| 82 | // The deployment bucket, matched the way addBoxNodes matches it — by the |
| 83 | // name the console was configured with. An empty bucket claims nothing. |
| 84 | if bucket != "" { |
| 85 | for _, b := range inv.ByKind(infraview.KindBucket) { |
| 86 | if nameOf(b) == bucket { |
| 87 | in[b.ID] = true |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // Security groups and instance roles come off the machine itself, which |
| 93 | // is why they are cheap: ec2:DescribeInstances already returns both, so |
| 94 | // closing this hop needed no new API call and no new IAM action. |
| 95 | groups := map[string]bool{} // security-group id |
| 96 | profiles := map[string]bool{} // instance-profile ARN tail |
| 97 | for _, m := range inv.ByKind(infraview.KindMachine) { |
| 98 | if !machines[m.ID] { |
| 99 | continue |
| 100 | } |
| 101 | for _, g := range strings.Split(m.Attr("security_groups"), ",") { |
| 102 | if g = strings.TrimSpace(g); g != "" { |
| 103 | groups[g] = true |
| 104 | } |
| 105 | } |
| 106 | if tail := arnTail(m.Attr("iam_profile")); tail != "" { |
| 107 | profiles[tail] = true |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | for _, r := range inv.Resources { |
| 112 | switch r.Kind { |
| 113 | case infraview.KindDisk: |
| 114 | if machines[r.Attr("attached_to")] { |
| 115 | in[r.ID] = true |
| 116 | } |
| 117 | case infraview.KindIP: |
| 118 | if machines[r.Attr("attached_to")] { |
| 119 | in[r.ID] = true |
| 120 | } |
| 121 | case infraview.KindAlarm: |
| 122 | if machines[r.Attr("target")] { |
| 123 | in[r.ID] = true |
| 124 | } |
| 125 | case infraview.KindFirewall: |
| 126 | // The firewall's own id, exactly as the machine named it. No |
| 127 | // name matching: DescribeInstances reports group IDs and |
| 128 | // DescribeSecurityGroups keys on the same ids. |
| 129 | if groups[r.ID] { |
| 130 | in[r.ID] = true |
| 131 | } |
| 132 | case infraview.KindIdentity: |
| 133 | // The ONE derived join on the page, and it is worth naming. An |
| 134 | // EC2 instance carries an instance PROFILE, and the profile's |
| 135 | // role is only recoverable through iam:ListInstanceProfiles — |
| 136 | // an action the console's read grant does not hold, and adding |
| 137 | // it would mean a terraform apply across every region box |
| 138 | // before this page could render at all. So the profile ARN and |
| 139 | // the role ARN are matched on their trailing name, within one |
| 140 | // account. Every tool that creates an instance role for us |
| 141 | // creates the pair under one name; EKS's do not match and |
| 142 | // correctly stay out. A wrong match here can only ever move a |
| 143 | // row from one visible band to the other — it can never hide |
| 144 | // one, which is what makes the shortcut affordable. |
| 145 | if tail := arnTail(r.ID); tail != "" && profiles[tail] { |
| 146 | in[r.ID] = true |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // DNS last: a record is CARLOS when the address it resolves to is, and |
| 152 | // a zone is CARLOS when it holds such a record. recordTarget is the |
| 153 | // same resolution addRecordNodes draws with, so a record's estate and |
| 154 | // its arrow can never disagree. |
| 155 | zones := map[string]bool{} |
| 156 | for _, rec := range inv.ByKind(infraview.KindDNSRecord) { |
| 157 | t := recordTarget(inv, rec.Attr("value"), claimed) |
| 158 | switch { |
| 159 | case strings.HasPrefix(t, "box:"): |
| 160 | // targetOf only says "box:" for a machine a box reported, so |
| 161 | // this is CARLOS by construction. |
| 162 | case strings.HasPrefix(t, "ip:") && in[strings.TrimPrefix(t, "ip:")]: |
| 163 | default: |
| 164 | continue |
| 165 | } |
| 166 | in[rec.ID] = true |
| 167 | zones[rec.Attr("zone")] = true |
| 168 | } |
| 169 | for _, z := range inv.ByKind(infraview.KindDNSZone) { |
| 170 | if z.Name != "" && zones[z.Name] { |
| 171 | in[z.ID] = true |
| 172 | } |
| 173 | } |
| 174 | return in |
| 175 | } |
| 176 | |
| 177 | // arnTail is the name at the end of an ARN — "carlos-tito-host-ie" out of |
| 178 | // both arn:aws:iam::…:role/carlos-tito-host-ie and |
| 179 | // arn:aws:iam::…:instance-profile/carlos-tito-host-ie. Empty for anything |
| 180 | // that is not an ARN, which is how a provider that names identities some |
| 181 | // other way declines this join rather than matching something wrong. |
| 182 | func arnTail(arn string) string { |
| 183 | if !strings.HasPrefix(arn, "arn:") { |
| 184 | return "" |
| 185 | } |
| 186 | i := strings.LastIndex(arn, "/") |
| 187 | if i < 0 || i == len(arn)-1 { |
| 188 | return "" |
| 189 | } |
| 190 | return arn[i+1:] |
| 191 | } |
| 192 | |
| 193 | // splitNodes deals the drawn nodes into the two banks. |
| 194 | // |
| 195 | // Most nodes answer directly: a box and the deployment bucket are CARLOS by |
| 196 | // definition; an IP, a record or an unclaimed machine is whatever the |
| 197 | // closure said its resource is. Two kinds straddle, and both do so |
| 198 | // legitimately — a DNS zone that answers for both halves, and an external |
| 199 | // address named by records in both — so a node with no direct answer takes |
| 200 | // the union of its neighbours' banks. That is what keeps an address named |
| 201 | // from both halves in both, without either picture claiming to own it. |
| 202 | // |
| 203 | // A zone is banked here and then not drawn (dropSatellites): it is global, |
| 204 | // so it belongs to no region band. Its bank still decides which side lists |
| 205 | // it in the DNS table, which is the one place it reaches the page. |
| 206 | // |
| 207 | // A node that answers neither directly nor through a neighbour goes in BOTH |
| 208 | // rather than neither: on this page an unplaceable thing is drawn twice, never |
| 209 | // dropped. |
| 210 | func splitNodes(nodes []Node, edges []Edge, in map[string]bool) map[string]int { |
| 211 | bank := make(map[string]int, len(nodes)) |
| 212 | direct := make(map[string]bool, len(nodes)) |
| 213 | for _, n := range nodes { |
| 214 | kind, ref, ok := strings.Cut(n.ID, ":") |
| 215 | if !ok { |
| 216 | continue |
| 217 | } |
| 218 | switch kind { |
| 219 | case "box", "bucket": |
| 220 | bank[n.ID], direct[n.ID] = estateCARLOS, true |
| 221 | case "ip", "record", "machine": |
| 222 | if in[ref] { |
| 223 | bank[n.ID] = estateCARLOS |
| 224 | } else { |
| 225 | bank[n.ID] = estateRest |
| 226 | } |
| 227 | direct[n.ID] = true |
| 228 | } |
| 229 | } |
| 230 | // One propagation pass is enough for the graph this is: every straddling |
| 231 | // node (zone, external) sits directly beside a record, and every record |
| 232 | // was placed above. The banks ACCUMULATE — a zone answering for both |
| 233 | // halves must end up carrying both bits, not whichever edge came first. |
| 234 | for _, e := range edges { |
| 235 | if !direct[e.From] { |
| 236 | bank[e.From] |= bank[e.To] |
| 237 | } |
| 238 | if !direct[e.To] { |
| 239 | bank[e.To] |= bank[e.From] |
| 240 | } |
| 241 | } |
| 242 | for _, n := range nodes { |
| 243 | if bank[n.ID] == 0 { |
| 244 | bank[n.ID] = estateCARLOS | estateRest |
| 245 | } |
| 246 | } |
| 247 | return bank |
| 248 | } |
| 249 | |