awsinv: read-only client seam and EC2 mappings
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+422
−18
go.mod+10 −6go.sum+20 −12internal/awsinv/clients.go+104 −0internal/awsinv/ec2.go+168 −0internal/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]) |
| + } |
| +} |