carlos / platform Public

awsinv: read-only client seam and EC2 mappings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6fbb04f97118378e49de1182adf6b3de8f070ff7 parent ee4aaa5
5 files changed, +422 −18
  • go.mod +10 −6
  • go.sum +20 −12
  • internal/awsinv/clients.go +104 −0
  • internal/awsinv/ec2.go +168 −0
  • internal/awsinv/ec2_test.go +120 −0
diff --git a/go.mod b/go.mod
index dd7e91d44..33b1435e2 100644
--- a/go.mod
+++ b/go.mod
@@ -3,16 +3,20 @@ module github.com/carlosframework/platform
go 1.26
require (
- github.com/aws/aws-sdk-go-v2 v1.43.6
+ github.com/aws/aws-sdk-go-v2 v1.44.0
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
+ github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
+ github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
+ github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
+ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
- github.com/aws/smithy-go v1.27.8
+ github.com/aws/smithy-go v1.28.1
github.com/benbjohnson/litestream v0.5.14
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
@@ -23,13 +27,13 @@ require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
diff --git a/go.sum b/go.sum
index d8d482895..9f53be021 100644
--- a/go.sum
+++ b/go.sum
@@ -18,8 +18,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
-github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ=
-github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00=
+github.com/aws/aws-sdk-go-v2 v1.44.0 h1:4IbaHhtzy+4h37z4JQyO9a2QsiCml3CNYHtq5hIHigo=
+github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
@@ -30,26 +30,34 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBU
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 h1:UIXlbijuB2XK1Kr57fo8iIxCuaSHJzwZ1uo+2tbEYIk=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40/go.mod h1:wcEsL6jscjZjVUinb0Q5qD/GXOG1yT3GNfmT9HuDwzU=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 h1:xLQVRDs2NddDmK9BEyh5KSlJ1Gpy5/GIJXrV6WcVGAE=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40/go.mod h1:XRXnpFVFGLaEVK+olDdFIM1vNa04ETW452oFGEPUxAo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4=
+github.com/aws/aws-sdk-go-v2/service/acm v1.45.0 h1:C/LUyd2F3iFtityAYQbAYb56uP0x32HaHSVXfRSImAM=
+github.com/aws/aws-sdk-go-v2/service/acm v1.45.0/go.mod h1:8TFQHTFTgR7OoBHlqJr8gsHv+ePC8z4S1lcrZVPgqbM=
+github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 h1:RdWMeCVaJfu4hCPyRNVQMKMP/Y5J04zB5LF45DmHhRg=
+github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0/go.mod h1:HfsU8PQ6mHewSJNS77IPAFMZZgFaYSHjAhSOAFXdu9M=
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA=
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5/go.mod h1:gy1JJMsby9XLbrwMhJcMu0IuMJF4DvDe4pD1wqMmwkk=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0 h1:A1hZCv8Ev0jc8o7pGqnjprJokn21pGmPWZrzPJ5Sp+E=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0/go.mod h1:6eOsONObU8u+Edct3ZmszxCTACyTeITJG7yPbbqp3L8=
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 h1:/6iRcqrC6k1rMA6uCZMzFE9inOrBpNmhbrZ90X8qH50=
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2/go.mod h1:Wm74PIQWDrV2tGPFYtwLqh8jZ7JU/rnq/leiC6UI4dg=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40 h1:gr3Fw1cxZXNCdeo/lQ7isHEHzvHVM7z75qb2zW9aMjw=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40/go.mod h1:8z/9CmfnQhiuXD7Ykbcg4a/whSWsniE0ODSx9uwVzfk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0 h1:oomcdLWLukXFdmD5sJ5mQIJvbxMpQfXH99M7RDSfQZg=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0/go.mod h1:0tyRZSvxvNjcAtiAz0e+2I8swigSXNL6Gzl9dr5K6E4=
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 h1:qL5ELChQ1o1ZbzHvsl8gzQfAZdbQtrDbUyzJkjkMKcg=
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1/go.mod h1:0hIRXFez1bZsDFMGkLZvNJbByTSVZ4sFZWpxZ39NPuM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
@@ -64,8 +72,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM=
-github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
-github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
+github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/benbjohnson/litestream v0.5.14 h1:pu2RCUfEs2MfAvxSttNg/kVVzJspqg2sX/pPW3Elzxc=
github.com/benbjohnson/litestream v0.5.14/go.mod h1:aN5OtdkcCz07xVjCbmMWOGUSIdPumpqBYtdyrzNMhOI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
diff --git a/internal/awsinv/clients.go b/internal/awsinv/clients.go
new file mode 100644
index 000000000..094e91c56
--- /dev/null
+++ b/internal/awsinv/clients.go
@@ -0,0 +1,104 @@
+// Package awsinv discovers what an AWS account is carrying and reports it
+// as an internal/infraview.Inventory. It is the ONLY package in this repo
+// that imports an AWS SDK for inventory purposes, which is the whole point
+// of the seam: the console page that renders an estate never sees an AWS
+// type, so a second provider is a new package rather than a rewrite.
+//
+// Every call here is read-only. The IAM policy this package needs
+// (infra/modules/region-host/main.tf) contains no write action, so that
+// property is enforced by the credential and not by this comment.
+package awsinv
+
+import (
+ "context"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/acm"
+ "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+// The narrow interfaces exist so the mappings can be tested against fakes
+// with no network and no credential. Each lists exactly the calls this
+// package makes and nothing else — an interface that named the whole
+// client would be an invitation to reach for a write.
+type EC2API interface {
+ DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
+ DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error)
+ DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error)
+ DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error)
+ DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error)
+ DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error)
+ DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error)
+}
+
+type S3API interface {
+ ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error)
+ GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error)
+ GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error)
+ GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error)
+ GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error)
+}
+
+type Route53API interface {
+ ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error)
+ ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error)
+}
+
+type IAMAPI interface {
+ ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error)
+ ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error)
+ ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error)
+}
+
+type CloudWatchAPI interface {
+ DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error)
+}
+
+type ACMAPI interface {
+ ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error)
+}
+
+type TaggingAPI interface {
+ GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error)
+}
+
+// Clients is one region's worth of read-only clients. S3/Route53/IAM are
+// global services; the assembly calls them once, from the home region's
+// Clients, rather than once per region.
+type Clients struct {
+ EC2 EC2API
+ S3 S3API
+ Route53 Route53API
+ IAM IAMAPI
+ CloudWatch CloudWatchAPI
+ ACM ACMAPI
+ Tagging TaggingAPI
+}
+
+// Factory builds the clients for one region. Tests inject a fake; the real
+// one below rebases a single resolved aws.Config onto each region so the
+// credential chain resolves exactly once.
+type Factory func(region string) Clients
+
+func factoryFor(cfg aws.Config) Factory {
+ return func(region string) Clients {
+ c := cfg.Copy()
+ if region != "" {
+ c.Region = region
+ }
+ return Clients{
+ EC2: ec2.NewFromConfig(c),
+ S3: s3.NewFromConfig(c),
+ Route53: route53.NewFromConfig(c),
+ IAM: iam.NewFromConfig(c),
+ CloudWatch: cloudwatch.NewFromConfig(c),
+ ACM: acm.NewFromConfig(c),
+ Tagging: resourcegroupstaggingapi.NewFromConfig(c),
+ }
+ }
+}
diff --git a/internal/awsinv/ec2.go b/internal/awsinv/ec2.go
new file mode 100644
index 000000000..c1a07d092
--- /dev/null
+++ b/internal/awsinv/ec2.go
@@ -0,0 +1,168 @@
+package awsinv
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// res builds one Resource with the fields every mapping sets the same way,
+// so a new mapping cannot forget the provider stamp.
+func res(kind, nativeType, id, name, region string) infraview.Resource {
+ return infraview.Resource{
+ Provider: "aws", Kind: kind, NativeType: nativeType,
+ ID: id, Name: name, Region: region, Attrs: map[string]string{},
+ }
+}
+
+// set writes an attribute only when it has a value: an absent key and an
+// empty string are different claims, and the joins treat "" as absence.
+func set(r *infraview.Resource, key, value string) {
+ if value != "" {
+ r.Attrs[key] = value
+ }
+}
+
+func tagsOf(tags []ec2types.Tag) map[string]string {
+ if len(tags) == 0 {
+ return nil
+ }
+ out := map[string]string{}
+ for _, t := range tags {
+ out[aws.ToString(t.Key)] = aws.ToString(t.Value)
+ }
+ return out
+}
+
+// ec2Resources maps one region's EC2 surface. Each call contributes its own
+// resources and, on failure, one line to partial — never an error: this
+// function's whole contract is that a throttled volumes call costs the
+// volumes and nothing else.
+func ec2Resources(ctx context.Context, api EC2API, region string) (out []infraview.Resource, partial []string) {
+ fail := func(what string, err error) {
+ partial = append(partial, fmt.Sprintf("%s in %s: %v", what, region, err))
+ }
+
+ if o, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}); err != nil {
+ fail("instances", err)
+ } else if o != nil {
+ for _, rv := range o.Reservations {
+ for _, in := range rv.Instances {
+ tags := tagsOf(in.Tags)
+ r := res(infraview.KindMachine, "ec2:instance", aws.ToString(in.InstanceId), tags["Name"], region)
+ r.Tags = tags
+ set(&r, "type", string(in.InstanceType))
+ if in.State != nil {
+ set(&r, "state", string(in.State.Name))
+ }
+ if in.Placement != nil {
+ set(&r, "az", aws.ToString(in.Placement.AvailabilityZone))
+ }
+ set(&r, "public_ip", aws.ToString(in.PublicIpAddress))
+ set(&r, "private_ip", aws.ToString(in.PrivateIpAddress))
+ if in.LaunchTime != nil {
+ set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02"))
+ }
+ out = append(out, r)
+ }
+ }
+ }
+
+ if o, err := api.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{}); err != nil {
+ fail("volumes", err)
+ } else if o != nil {
+ for _, v := range o.Volumes {
+ r := res(infraview.KindDisk, "ec2:volume", aws.ToString(v.VolumeId), tagsOf(v.Tags)["Name"], region)
+ r.Tags = tagsOf(v.Tags)
+ if v.Size != nil {
+ set(&r, "size_gb", strconv.Itoa(int(*v.Size)))
+ }
+ set(&r, "volume_type", string(v.VolumeType))
+ for _, a := range v.Attachments {
+ set(&r, "attached_to", aws.ToString(a.InstanceId))
+ }
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}); err != nil {
+ fail("elastic IPs", err)
+ } else if o != nil {
+ for _, a := range o.Addresses {
+ r := res(infraview.KindIP, "ec2:eip", aws.ToString(a.AllocationId), tagsOf(a.Tags)["Name"], region)
+ r.Tags = tagsOf(a.Tags)
+ set(&r, "address", aws.ToString(a.PublicIp))
+ set(&r, "attached_to", aws.ToString(a.InstanceId))
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{}); err != nil {
+ fail("security groups", err)
+ } else if o != nil {
+ for _, g := range o.SecurityGroups {
+ r := res(infraview.KindFirewall, "ec2:security-group", aws.ToString(g.GroupId), aws.ToString(g.GroupName), region)
+ r.Tags = tagsOf(g.Tags)
+ set(&r, "ingress", summarizePerms(g.IpPermissions))
+ set(&r, "egress", summarizePerms(g.IpPermissionsEgress))
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}); err != nil {
+ fail("VPCs", err)
+ } else if o != nil {
+ for _, v := range o.Vpcs {
+ r := res(infraview.KindNetwork, "ec2:vpc", aws.ToString(v.VpcId), tagsOf(v.Tags)["Name"], region)
+ r.Tags = tagsOf(v.Tags)
+ set(&r, "cidr", aws.ToString(v.CidrBlock))
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}); err != nil {
+ fail("subnets", err)
+ } else if o != nil {
+ for _, s := range o.Subnets {
+ r := res(infraview.KindNetwork, "ec2:subnet", aws.ToString(s.SubnetId), tagsOf(s.Tags)["Name"], region)
+ r.Tags = tagsOf(s.Tags)
+ set(&r, "cidr", aws.ToString(s.CidrBlock))
+ set(&r, "az", aws.ToString(s.AvailabilityZone))
+ set(&r, "vpc", aws.ToString(s.VpcId))
+ out = append(out, r)
+ }
+ }
+ return out, partial
+}
+
+// summarizePerms renders a rule set as "tcp/22 from 10.0.0.0/8 · tcp/443
+// from 0.0.0.0/0" — a sentence an operator reads, never the raw JSON, which
+// would be both unreadable and a wall of detail the page does not want.
+func summarizePerms(perms []ec2types.IpPermission) string {
+ out := ""
+ for _, p := range perms {
+ proto := aws.ToString(p.IpProtocol)
+ if proto == "-1" {
+ proto = "all"
+ }
+ port := "all"
+ if p.FromPort != nil {
+ port = strconv.Itoa(int(*p.FromPort))
+ if p.ToPort != nil && *p.ToPort != *p.FromPort {
+ port += "-" + strconv.Itoa(int(*p.ToPort))
+ }
+ }
+ for _, rng := range p.IpRanges {
+ if out != "" {
+ out += " · "
+ }
+ out += fmt.Sprintf("%s/%s from %s", proto, port, aws.ToString(rng.CidrIp))
+ }
+ }
+ return out
+}
diff --git a/internal/awsinv/ec2_test.go b/internal/awsinv/ec2_test.go
new file mode 100644
index 000000000..cabc8767d
--- /dev/null
+++ b/internal/awsinv/ec2_test.go
@@ -0,0 +1,120 @@
+package awsinv
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// fakeEC2 answers whatever the test hands it; a non-nil err field for a
+// call makes that ONE call fail, which is how the partial-failure rule
+// below is proven.
+type fakeEC2 struct {
+ instances *ec2.DescribeInstancesOutput
+ volumes *ec2.DescribeVolumesOutput
+ addresses *ec2.DescribeAddressesOutput
+ groups *ec2.DescribeSecurityGroupsOutput
+ vpcs *ec2.DescribeVpcsOutput
+ subnets *ec2.DescribeSubnetsOutput
+ regions *ec2.DescribeRegionsOutput
+ errVols error
+}
+
+func (f *fakeEC2) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
+ return f.instances, nil
+}
+func (f *fakeEC2) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) {
+ if f.errVols != nil {
+ return nil, f.errVols
+ }
+ return f.volumes, nil
+}
+func (f *fakeEC2) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) {
+ return f.addresses, nil
+}
+func (f *fakeEC2) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) {
+ return f.groups, nil
+}
+func (f *fakeEC2) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) {
+ return f.vpcs, nil
+}
+func (f *fakeEC2) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) {
+ return f.subnets, nil
+}
+func (f *fakeEC2) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) {
+ return f.regions, nil
+}
+
+func find(rs []infraview.Resource, kind, id string) *infraview.Resource {
+ for i := range rs {
+ if rs[i].Kind == kind && rs[i].ID == id {
+ return &rs[i]
+ }
+ }
+ return nil
+}
+
+func TestEC2InstanceMapping(t *testing.T) {
+ f := &fakeEC2{instances: &ec2.DescribeInstancesOutput{
+ Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
+ InstanceId: aws.String("i-123"),
+ InstanceType: ec2types.InstanceTypeT4gMedium,
+ State: &ec2types.InstanceState{Name: ec2types.InstanceStateNameRunning},
+ Placement: &ec2types.Placement{AvailabilityZone: aws.String("eu-west-1a")},
+ PublicIpAddress: aws.String("52.0.0.1"),
+ PrivateIpAddress: aws.String("10.0.0.5"),
+ Tags: []ec2types.Tag{{Key: aws.String("Name"), Value: aws.String("carlos-eu-west-1")}},
+ }}}},
+ }}
+
+ rs, partial := ec2Resources(context.Background(), f, "eu-west-1")
+ if len(partial) != 0 {
+ t.Fatalf("partial = %v, want none", partial)
+ }
+ m := find(rs, infraview.KindMachine, "i-123")
+ if m == nil {
+ t.Fatalf("no machine in %+v", rs)
+ }
+ if m.Name != "carlos-eu-west-1" {
+ t.Errorf("Name = %q, want the Name tag", m.Name)
+ }
+ if m.Region != "eu-west-1" || m.NativeType != "ec2:instance" {
+ t.Errorf("region/type = %q/%q", m.Region, m.NativeType)
+ }
+ for k, want := range map[string]string{
+ "type": "t4g.medium", "state": "running", "az": "eu-west-1a",
+ "public_ip": "52.0.0.1", "private_ip": "10.0.0.5",
+ } {
+ if got := m.Attr(k); got != want {
+ t.Errorf("attr %s = %q, want %q", k, got, want)
+ }
+ }
+}
+
+// One failing call costs its own resources and nothing else — the rule the
+// whole inventory depends on, since a page that shows eleven of twelve
+// classes is worth far more than one that shows none.
+func TestEC2PartialFailureCostsOnlyItsOwnCall(t *testing.T) {
+ f := &fakeEC2{
+ instances: &ec2.DescribeInstancesOutput{Reservations: []ec2types.Reservation{{
+ Instances: []ec2types.Instance{{InstanceId: aws.String("i-123")}},
+ }}},
+ errVols: errors.New("Throttling: rate exceeded"),
+ }
+ rs, partial := ec2Resources(context.Background(), f, "eu-west-1")
+ if find(rs, infraview.KindMachine, "i-123") == nil {
+ t.Fatalf("a failed volume read must not cost the instances")
+ }
+ if len(partial) != 1 {
+ t.Fatalf("partial = %v, want exactly the volumes line", partial)
+ }
+ if !strings.Contains(partial[0], "volumes") || !strings.Contains(partial[0], "eu-west-1") {
+ t.Errorf("partial line %q must name what failed and where", partial[0])
+ }
+}