carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
31package console
32
33import (
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.
42const (
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.
64func 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.
182func 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.
210func 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