// Which half of the account a resource belongs to. // // A CARLOS deployment is very often a guest in an account that was already // carrying something else — Tito's own account holds ~70 CARLOS resources // beside ~1,750 belonging to a Kubernetes estate that predates it, and the // two are deliberately kept in ONE account so the platform can reach the // legacy database to sync from. Drawn as one picture that is a hairball; // drawn as two it is two readable pictures. So /fleet/architecture splits // the page in half. // // This file decides the split, and one rule governs it: // // MEMBERSHIP IS ESTABLISHED, NEVER GUESSED. A resource is CARLOS because // the console can trace a path to it from something it knows first-hand — // a machine a box reported, or the deployment bucket it is configured // with — never because its name starts with "carlos-". A naming // convention is a convention: it holds until somebody renames something, // and then the page is confidently wrong with no way to tell. // // The consequence is that the join UNDER-reaches, and that is the honest // trade. Measured against the real Tito estate the closure reaches 44 // resources; eight sibling regional deployment buckets are genuinely // unreachable from what this console knows and land in the other half. That // is not a bug to paper over with a name match — it is the page saying // exactly how much it can prove. // // Nothing is filtered and nothing is hidden: this is the same set of // resources archgraph.go has always drawn, dealt into two banks. A resource // the closure cannot reach is still on the page, still in a table, still // with its every attribute — just under the other heading. package console import ( "strings" "github.com/carlosframework/platform/internal/infraview" ) // Estate bits. A node can carry both: the account's one DNS zone genuinely // serves both halves, and drawing it in only one picture would make the // other picture's records resolve from nowhere. const ( estateCARLOS = 1 << iota estateRest ) // carlosResources is the membership closure: the set of inventory resource // ids the console can PROVE belong to the CARLOS deployment. // // It is seeded by the two things the console knows first-hand — the machines // its own boxes reported (claimed, built in BuildArch from each box's own // instance-metadata read) and the deployment bucket it is configured with — // and closed one hop outward along relationships the estate itself carries: // // machine -> its disks (disk.attached_to) // machine -> its addresses (ip.attached_to) // machine -> its alarms (alarm.target) // machine -> its security groups (machine.security_groups) // machine -> its instance role (machine.iam_profile) // address -> the records naming it, and those records' zone // // Every one of those is an id-to-id join except the last two, which are // described where they are made. func carlosResources(inv *infraview.Inventory, claimed map[string]string, bucket string) map[string]bool { in := map[string]bool{} if inv == nil { return in } // Seed: the machines the boxes themselves reported. claimed is keyed by // machine id, so this is an exact join and a box whose machine has since // been terminated simply seeds nothing. machines := map[string]bool{} for _, m := range inv.ByKind(infraview.KindMachine) { if claimed[m.ID] == "" { continue } machines[m.ID] = true in[m.ID] = true } // The deployment bucket, matched the way addBoxNodes matches it — by the // name the console was configured with. An empty bucket claims nothing. if bucket != "" { for _, b := range inv.ByKind(infraview.KindBucket) { if nameOf(b) == bucket { in[b.ID] = true } } } // Security groups and instance roles come off the machine itself, which // is why they are cheap: ec2:DescribeInstances already returns both, so // closing this hop needed no new API call and no new IAM action. groups := map[string]bool{} // security-group id profiles := map[string]bool{} // instance-profile ARN tail for _, m := range inv.ByKind(infraview.KindMachine) { if !machines[m.ID] { continue } for _, g := range strings.Split(m.Attr("security_groups"), ",") { if g = strings.TrimSpace(g); g != "" { groups[g] = true } } if tail := arnTail(m.Attr("iam_profile")); tail != "" { profiles[tail] = true } } for _, r := range inv.Resources { switch r.Kind { case infraview.KindDisk: if machines[r.Attr("attached_to")] { in[r.ID] = true } case infraview.KindIP: if machines[r.Attr("attached_to")] { in[r.ID] = true } case infraview.KindAlarm: if machines[r.Attr("target")] { in[r.ID] = true } case infraview.KindFirewall: // The firewall's own id, exactly as the machine named it. No // name matching: DescribeInstances reports group IDs and // DescribeSecurityGroups keys on the same ids. if groups[r.ID] { in[r.ID] = true } case infraview.KindIdentity: // The ONE derived join on the page, and it is worth naming. An // EC2 instance carries an instance PROFILE, and the profile's // role is only recoverable through iam:ListInstanceProfiles — // an action the console's read grant does not hold, and adding // it would mean a terraform apply across every region box // before this page could render at all. So the profile ARN and // the role ARN are matched on their trailing name, within one // account. Every tool that creates an instance role for us // creates the pair under one name; EKS's do not match and // correctly stay out. A wrong match here can only ever move a // row from one visible band to the other — it can never hide // one, which is what makes the shortcut affordable. if tail := arnTail(r.ID); tail != "" && profiles[tail] { in[r.ID] = true } } } // DNS last: a record is CARLOS when the address it resolves to is, and // a zone is CARLOS when it holds such a record. recordTarget is the // same resolution addRecordNodes draws with, so a record's estate and // its arrow can never disagree. zones := map[string]bool{} for _, rec := range inv.ByKind(infraview.KindDNSRecord) { t := recordTarget(inv, rec.Attr("value"), claimed) switch { case strings.HasPrefix(t, "box:"): // targetOf only says "box:" for a machine a box reported, so // this is CARLOS by construction. case strings.HasPrefix(t, "ip:") && in[strings.TrimPrefix(t, "ip:")]: default: continue } in[rec.ID] = true zones[rec.Attr("zone")] = true } for _, z := range inv.ByKind(infraview.KindDNSZone) { if z.Name != "" && zones[z.Name] { in[z.ID] = true } } return in } // arnTail is the name at the end of an ARN — "carlos-tito-host-ie" out of // both arn:aws:iam::…:role/carlos-tito-host-ie and // arn:aws:iam::…:instance-profile/carlos-tito-host-ie. Empty for anything // that is not an ARN, which is how a provider that names identities some // other way declines this join rather than matching something wrong. func arnTail(arn string) string { if !strings.HasPrefix(arn, "arn:") { return "" } i := strings.LastIndex(arn, "/") if i < 0 || i == len(arn)-1 { return "" } return arn[i+1:] } // splitNodes deals the drawn nodes into the two banks. // // Most nodes answer directly: a box and the deployment bucket are CARLOS by // definition; an IP, a record or an unclaimed machine is whatever the // closure said its resource is. Two kinds straddle, and both do so // legitimately — a DNS zone that answers for both halves, and an external // address named by records in both — so a node with no direct answer takes // the union of its neighbours' banks. That is what keeps an address named // from both halves in both, without either picture claiming to own it. // // A zone is banked here and then not drawn (dropSatellites): it is global, // so it belongs to no region band. Its bank still decides which side lists // it in the DNS table, which is the one place it reaches the page. // // A node that answers neither directly nor through a neighbour goes in BOTH // rather than neither: on this page an unplaceable thing is drawn twice, never // dropped. func splitNodes(nodes []Node, edges []Edge, in map[string]bool) map[string]int { bank := make(map[string]int, len(nodes)) direct := make(map[string]bool, len(nodes)) for _, n := range nodes { kind, ref, ok := strings.Cut(n.ID, ":") if !ok { continue } switch kind { case "box", "bucket": bank[n.ID], direct[n.ID] = estateCARLOS, true case "ip", "record", "machine": if in[ref] { bank[n.ID] = estateCARLOS } else { bank[n.ID] = estateRest } direct[n.ID] = true } } // One propagation pass is enough for the graph this is: every straddling // node (zone, external) sits directly beside a record, and every record // was placed above. The banks ACCUMULATE — a zone answering for both // halves must end up carrying both bits, not whichever edge came first. for _, e := range edges { if !direct[e.From] { bank[e.From] |= bank[e.To] } if !direct[e.To] { bank[e.To] |= bank[e.From] } } for _, n := range nodes { if bank[n.ID] == 0 { bank[n.ID] = estateCARLOS | estateRest } } return bank }