carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package awsinv
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/aws/aws-sdk-go-v2/aws"
12 "github.com/aws/aws-sdk-go-v2/service/acm"
13 acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types"
14 "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
15 cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types"
16 "github.com/aws/aws-sdk-go-v2/service/ec2"
17 "github.com/aws/aws-sdk-go-v2/service/iam"
18 iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
19 "github.com/aws/aws-sdk-go-v2/service/route53"
20 r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
21 "github.com/aws/aws-sdk-go-v2/service/s3"
22 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
23 "github.com/aws/smithy-go"
24 "github.com/carlosframework/platform/internal/infraview"
25)
26
27type fakeAPIErr struct{ code string }
28
29func (e *fakeAPIErr) Error() string { return e.code }
30func (e *fakeAPIErr) ErrorCode() string { return e.code }
31func (e *fakeAPIErr) ErrorMessage() string { return e.code }
32func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient }
33
34// fakeEC2Failing fails every call with the same error.
35type fakeEC2Failing struct{ err error }
36
37func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
38 return nil, f.err
39}
40func (f *fakeEC2Failing) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) {
41 return nil, f.err
42}
43func (f *fakeEC2Failing) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) {
44 return nil, f.err
45}
46func (f *fakeEC2Failing) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) {
47 return nil, f.err
48}
49func (f *fakeEC2Failing) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) {
50 return nil, f.err
51}
52func (f *fakeEC2Failing) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) {
53 return nil, f.err
54}
55func (f *fakeEC2Failing) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) {
56 return nil, f.err
57}
58
59type fakeS3 struct {
60 buckets *s3.ListBucketsOutput
61 vers *s3.GetBucketVersioningOutput
62}
63
64func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) {
65 return f.buckets, nil
66}
67func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) {
68 return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil
69}
70func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) {
71 return f.vers, nil
72}
73func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) {
74 return nil, errors.New("no encryption configured")
75}
76func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) {
77 return nil, errors.New("no public access block")
78}
79
80// A bucket whose posture reads fail still appears — with the attribute
81// ABSENT, never a false "off". "Off" is a claim this read did not earn.
82func TestS3BucketMapping(t *testing.T) {
83 f := &fakeS3{
84 buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}},
85 vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled},
86 }
87 rs, partial := s3Resources(context.Background(), f)
88 if len(partial) != 0 || len(rs) != 1 {
89 t.Fatalf("rs=%+v partial=%v", rs, partial)
90 }
91 b := rs[0]
92 if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" {
93 t.Errorf("bucket = %+v", b)
94 }
95 if b.Attr("versioning") != "enabled" {
96 t.Errorf("versioning = %q, want enabled", b.Attr("versioning"))
97 }
98 if _, ok := b.Attrs["encryption"]; ok {
99 t.Errorf("a failed encryption read must leave the attribute absent, not claim a value")
100 }
101}
102
103type fakeRoute53 struct {
104 zones *route53.ListHostedZonesOutput
105 recs *route53.ListResourceRecordSetsOutput
106}
107
108func (f *fakeRoute53) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) {
109 return f.zones, nil
110}
111func (f *fakeRoute53) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
112 return f.recs, nil
113}
114
115// Only record types that can participate in the DNS -> IP -> machine join
116// are materialised as their own Resource; everything else just bumps the
117// zone's record_count, because a zone can hold thousands of records.
118func TestRoute53RecordsMaterialiseOnlyJoinable(t *testing.T) {
119 f := &fakeRoute53{
120 zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{
121 {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")},
122 }},
123 recs: &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []r53types.ResourceRecordSet{
124 {
125 Name: aws.String("carlosframework.com."), Type: r53types.RRTypeA,
126 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.1")}},
127 },
128 {
129 Name: aws.String("carlosframework.com."), Type: r53types.RRTypeTxt,
130 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("\"v=spf1 -all\"")}},
131 },
132 }},
133 }
134 rs, partial := route53Resources(context.Background(), f)
135 if len(partial) != 0 {
136 t.Fatalf("partial = %v, want none", partial)
137 }
138 dnsRecords := 0
139 var zone *infraview.Resource
140 for i := range rs {
141 switch rs[i].Kind {
142 case infraview.KindDNSRecord:
143 dnsRecords++
144 if rs[i].Attr("type") != "A" {
145 t.Errorf("only the A record should have been materialised, got type %q", rs[i].Attr("type"))
146 }
147 case infraview.KindDNSZone:
148 zone = &rs[i]
149 }
150 }
151 if dnsRecords != 1 {
152 t.Fatalf("dns_record resources = %d, want exactly 1 (the A record, not the TXT)", dnsRecords)
153 }
154 if zone == nil {
155 t.Fatal("no dns_zone resource produced")
156 }
157 if zone.Attr("record_count") != "2" {
158 t.Errorf("record_count = %q, want 2 (both records counted)", zone.Attr("record_count"))
159 }
160}
161
162// fakeRoute53Stuck always claims IsTruncated with the same next cursor, no
163// matter what StartRecordName/Type it was called with — simulating a
164// pagination cursor that never advances (a single record name owning a
165// whole page is the real-world case). route53Resources must stop rather
166// than spin forever against a live API on an hourly pass.
167type fakeRoute53Stuck struct {
168 zones *route53.ListHostedZonesOutput
169 calls int
170}
171
172func (f *fakeRoute53Stuck) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) {
173 return f.zones, nil
174}
175
176func (f *fakeRoute53Stuck) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
177 f.calls++
178 return &route53.ListResourceRecordSetsOutput{
179 ResourceRecordSets: []r53types.ResourceRecordSet{
180 {
181 Name: aws.String("stuck.carlosframework.com."), Type: r53types.RRTypeA,
182 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.9")}},
183 },
184 },
185 IsTruncated: true,
186 NextRecordName: aws.String("stuck.carlosframework.com."),
187 NextRecordType: r53types.RRTypeA,
188 }, nil
189}
190
191func TestRoute53PaginationStopsWhenCursorDoesNotAdvance(t *testing.T) {
192 f := &fakeRoute53Stuck{
193 zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{
194 {Id: aws.String("/hostedzone/Z2"), Name: aws.String("carlosframework.com.")},
195 }},
196 }
197
198 done := make(chan struct{})
199 var rs []infraview.Resource
200 var partial []string
201 go func() {
202 rs, partial = route53Resources(context.Background(), f)
203 close(done)
204 }()
205 select {
206 case <-done:
207 case <-time.After(2 * time.Second):
208 t.Fatal("route53Resources did not return — a non-advancing cursor spun forever")
209 }
210
211 if f.calls > 3 {
212 t.Fatalf("ListResourceRecordSets called %d times for a cursor that never advances, want it to stop quickly", f.calls)
213 }
214 if len(partial) == 0 {
215 t.Fatal("a stalled cursor must be named in partial, not silently truncate the zone")
216 }
217 zone := find(rs, infraview.KindDNSZone, "Z2")
218 if zone == nil {
219 t.Fatal("the zone itself must still be returned even though pagination stalled")
220 }
221}
222
223// fakeRoute53AdvancingIdentifier simulates a weighted/latency/geolocation/
224// failover record set spanning THREE pages, where the second-to-third page
225// boundary repeats the SAME name and type and only NextRecordIdentifier
226// moves. Two pages alone can't distinguish the fix (the second page can
227// coincidentally end the response too), so this needs a run where the
228// name+type also repeats mid-stream, which is exactly what a stall check
229// blind to the identifier misreads as "the cursor didn't move".
230type fakeRoute53AdvancingIdentifier struct {
231 zones *route53.ListHostedZonesOutput
232 calls int
233}
234
235func (f *fakeRoute53AdvancingIdentifier) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) {
236 return f.zones, nil
237}
238
239func (f *fakeRoute53AdvancingIdentifier) ListResourceRecordSets(_ context.Context, in *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
240 f.calls++
241 const name = "weighted.carlosframework.com."
242 switch f.calls {
243 case 1:
244 return &route53.ListResourceRecordSetsOutput{
245 ResourceRecordSets: []r53types.ResourceRecordSet{
246 {
247 Name: aws.String(name), Type: r53types.RRTypeA,
248 SetIdentifier: aws.String("primary"),
249 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.10")}},
250 },
251 },
252 IsTruncated: true,
253 NextRecordName: aws.String(name),
254 NextRecordType: r53types.RRTypeA,
255 NextRecordIdentifier: aws.String("secondary"),
256 }, nil
257 case 2:
258 if aws.ToString(in.StartRecordIdentifier) != "secondary" {
259 return nil, fmt.Errorf("want StartRecordIdentifier=secondary, got %q", aws.ToString(in.StartRecordIdentifier))
260 }
261 // SAME name and type as the cursor we were just handed — only the
262 // identifier is different. A stall check that ignores the
263 // identifier sees name+type match and stops here, one page short.
264 return &route53.ListResourceRecordSetsOutput{
265 ResourceRecordSets: []r53types.ResourceRecordSet{
266 {
267 Name: aws.String(name), Type: r53types.RRTypeA,
268 SetIdentifier: aws.String("secondary"),
269 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.11")}},
270 },
271 },
272 IsTruncated: true,
273 NextRecordName: aws.String(name),
274 NextRecordType: r53types.RRTypeA,
275 NextRecordIdentifier: aws.String("tertiary"),
276 }, nil
277 case 3:
278 if aws.ToString(in.StartRecordIdentifier) != "tertiary" {
279 return nil, fmt.Errorf("want StartRecordIdentifier=tertiary, got %q", aws.ToString(in.StartRecordIdentifier))
280 }
281 return &route53.ListResourceRecordSetsOutput{
282 ResourceRecordSets: []r53types.ResourceRecordSet{
283 {
284 Name: aws.String(name), Type: r53types.RRTypeA,
285 SetIdentifier: aws.String("tertiary"),
286 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.12")}},
287 },
288 },
289 IsTruncated: false,
290 }, nil
291 default:
292 return nil, fmt.Errorf("unexpected extra call %d", f.calls)
293 }
294}
295
296// The identical-name-and-type case at a page boundary must keep paginating
297// (and then terminate normally) rather than being misclassified as a
298// stall — the name/type/identifier triple only counts as stalled when ALL
299// THREE are unchanged from the request just made.
300func TestRoute53PaginationAdvancesOnIdentifierAlone(t *testing.T) {
301 f := &fakeRoute53AdvancingIdentifier{
302 zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{
303 {Id: aws.String("/hostedzone/Z3"), Name: aws.String("carlosframework.com.")},
304 }},
305 }
306 rs, partial := route53Resources(context.Background(), f)
307 if len(partial) != 0 {
308 t.Fatalf("partial = %v, want none (an identifier-only advance must not be treated as a stall)", partial)
309 }
310 if f.calls != 3 {
311 t.Fatalf("ListResourceRecordSets calls = %d, want 3 (all three pages fetched)", f.calls)
312 }
313 dnsRecords := 0
314 for _, r := range rs {
315 if r.Kind == infraview.KindDNSRecord {
316 dnsRecords++
317 }
318 }
319 if dnsRecords != 3 {
320 t.Fatalf("dns_record resources = %d, want 3 (one per weighted record set member across all three pages)", dnsRecords)
321 }
322 zone := find(rs, infraview.KindDNSZone, "Z3")
323 if zone == nil {
324 t.Fatal("no zone produced")
325 }
326 if zone.Attr("record_count") != "3" {
327 t.Errorf("record_count = %q, want 3", zone.Attr("record_count"))
328 }
329}
330
331type fakeIAM struct {
332 roles *iam.ListRolesOutput
333 users *iam.ListUsersOutput
334 attached *iam.ListAttachedRolePoliciesOutput
335}
336
337func (f *fakeIAM) ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error) {
338 return f.roles, nil
339}
340func (f *fakeIAM) ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error) {
341 return f.users, nil
342}
343func (f *fakeIAM) ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) {
344 return f.attached, nil
345}
346
347// Identities carry a name, a kind, and a COUNT of attached policies —
348// nothing more. No code path here ever reads a policy document, so no
349// attribute value can ever contain one.
350func TestIAMNeverCarriesPolicyDocuments(t *testing.T) {
351 f := &fakeIAM{
352 roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{
353 {RoleName: aws.String("region-host"), Arn: aws.String("arn:aws:iam::1234:role/region-host")},
354 }},
355 users: &iam.ListUsersOutput{Users: []iamtypes.User{
356 {UserName: aws.String("deploy-bot"), Arn: aws.String("arn:aws:iam::1234:user/deploy-bot")},
357 }},
358 attached: &iam.ListAttachedRolePoliciesOutput{AttachedPolicies: []iamtypes.AttachedPolicy{
359 {PolicyName: aws.String("AdministratorAccess")},
360 {PolicyName: aws.String("ReadOnlyAccess")},
361 }},
362 }
363 rs, partial := iamResources(context.Background(), f)
364 if len(partial) != 0 || len(rs) != 2 {
365 t.Fatalf("rs=%+v partial=%v", rs, partial)
366 }
367 role := find(rs, infraview.KindIdentity, "arn:aws:iam::1234:role/region-host")
368 if role == nil {
369 t.Fatalf("no role in %+v", rs)
370 }
371 if role.Attr("attached_policies") != "2" {
372 t.Errorf("attached_policies = %q, want 2", role.Attr("attached_policies"))
373 }
374 for _, r := range rs {
375 for k, v := range r.Attrs {
376 if k == "policy" || k == "policy_document" {
377 t.Errorf("resource %+v carries a policy document field %q", r, k)
378 }
379 if strings.Contains(v, "Statement") {
380 t.Errorf("attribute %s = %q looks like a policy document", k, v)
381 }
382 }
383 }
384}
385
386type fakeCloudWatch struct {
387 alarms *cloudwatch.DescribeAlarmsOutput
388}
389
390func (f *fakeCloudWatch) DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) {
391 return f.alarms, nil
392}
393
394// An alarm on the InstanceId dimension names its target machine, which is
395// what lets the page draw the alarm next to the instance it watches.
396func TestAlarmTargetsInstanceDimension(t *testing.T) {
397 f := &fakeCloudWatch{alarms: &cloudwatch.DescribeAlarmsOutput{MetricAlarms: []cwtypes.MetricAlarm{
398 {
399 AlarmArn: aws.String("arn:aws:cloudwatch:eu-west-1:1234:alarm:cpu-high"), AlarmName: aws.String("cpu-high"),
400 StateValue: cwtypes.StateValueAlarm, MetricName: aws.String("CPUUtilization"),
401 Dimensions: []cwtypes.Dimension{{Name: aws.String("InstanceId"), Value: aws.String("i-123")}},
402 },
403 }}}
404 rs, partial := alarmResources(context.Background(), f, "eu-west-1")
405 if len(partial) != 0 || len(rs) != 1 {
406 t.Fatalf("rs=%+v partial=%v", rs, partial)
407 }
408 a := rs[0]
409 if a.Kind != infraview.KindAlarm || a.Name != "cpu-high" || a.Region != "eu-west-1" {
410 t.Errorf("alarm = %+v", a)
411 }
412 if a.Attr("state") != "ALARM" {
413 t.Errorf("state = %q, want ALARM", a.Attr("state"))
414 }
415 if a.Attr("target") != "i-123" {
416 t.Errorf("target = %q, want i-123", a.Attr("target"))
417 }
418}
419
420type fakeACM struct {
421 certs *acm.ListCertificatesOutput
422}
423
424func (f *fakeACM) ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
425 return f.certs, nil
426}
427
428func TestCertMapping(t *testing.T) {
429 notAfter, err := time.Parse("2006-01-02", "2027-01-15")
430 if err != nil {
431 t.Fatalf("parse notAfter: %v", err)
432 }
433 f := &fakeACM{certs: &acm.ListCertificatesOutput{CertificateSummaryList: []acmtypes.CertificateSummary{
434 {
435 CertificateArn: aws.String("arn:aws:acm:eu-west-1:1234:certificate/abc"),
436 DomainName: aws.String("carloku.com"),
437 Status: acmtypes.CertificateStatusIssued,
438 NotAfter: &notAfter,
439 },
440 }}}
441 rs, partial := certResources(context.Background(), f, "eu-west-1")
442 if len(partial) != 0 || len(rs) != 1 {
443 t.Fatalf("rs=%+v partial=%v", rs, partial)
444 }
445 c := rs[0]
446 if c.Kind != infraview.KindCertificate || c.Name != "carloku.com" || c.Region != "eu-west-1" {
447 t.Errorf("cert = %+v", c)
448 }
449 if c.Attr("status") != "ISSUED" {
450 t.Errorf("status = %q, want ISSUED", c.Attr("status"))
451 }
452 if c.Attr("expires") != "2027-01-15" {
453 t.Errorf("expires = %q, want 2027-01-15", c.Attr("expires"))
454 }
455}
456
457func TestParseARN(t *testing.T) {
458 cases := []struct {
459 arn, service, id string
460 }{
461 {"arn:aws:ec2:eu-west-1:1234:instance/i-123", "ec2", "i-123"},
462 {"arn:aws:s3:::carlos-data-ie", "s3", "carlos-data-ie"},
463 {"arn:aws:sqs:eu-west-1:1234:some-queue", "sqs", "some-queue"},
464 {"arn:aws:iam::1234:role/region-host", "iam", "region-host"},
465 {"not-an-arn", "", ""},
466 {"", "", ""},
467 }
468 for _, c := range cases {
469 service, id := parseARN(c.arn)
470 if service != c.service || id != c.id {
471 t.Errorf("parseARN(%q) = (%q, %q), want (%q, %q)", c.arn, service, id, c.service, c.id)
472 }
473 }
474}
475
476// Route53 returns record and zone names octal-escaped, so the wildcard every
477// CARLOS deployment leans on arrives as `\052.woodstar.app`. It reached the
478// console's architecture page in that form and was read there by a person
479// before anyone noticed — decode it at the boundary that produces it.
480func TestDNSNamesAreUnescapedAtTheBoundary(t *testing.T) {
481 for _, c := range []struct{ in, want string }{
482 {`\052.woodstar.app`, "*.woodstar.app"},
483 {"plain.example.com", "plain.example.com"},
484 {`a\055b.example.com`, "a-b.example.com"},
485 // Not printable ASCII: left exactly as the API sent it, because a
486 // half-decoded name is worse than an escaped one.
487 {`\200.example.com`, `\200.example.com`},
488 // A trailing partial escape must not run off the end.
489 {`weird\05`, `weird\05`},
490 } {
491 if got := unescapeDNSName(c.in); got != c.want {
492 t.Errorf("unescapeDNSName(%q) = %q, want %q", c.in, got, c.want)
493 }
494 }
495}
496