From 53ef0ac491921563c043eb4b28cbe7e418d5dfa6 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:03:31 +1000 Subject: [PATCH] feat: implement EC2 DescribeInstanceTypeOfferings (#1025) --- docs/coverage/README.md | 2 +- docs/coverage/ec2/README.md | 4 +- spinifex/gateway/ec2.go | 3 + spinifex/gateway/ec2/authz.go | 1 + .../instance/DescribeInstanceTypeOfferings.go | 149 ++++++++ .../DescribeInstanceTypeOfferings_test.go | 317 ++++++++++++++++++ spinifex/gateway/ec2_emptyresult_test.go | 3 + 7 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go create mode 100644 spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 16c5048b6..b4f79acc6 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -22,7 +22,7 @@ Spinifex serves the AWS APIs below. Every page counts the operations in the pinn | Service | Implemented | Modelled | Coverage | |---|---:|---:|---:| | [ACM](/coverage/acm) | 9 | 15 | 60.0% | -| [EC2](/coverage/ec2) | 121 | 625 | 19.4% | +| [EC2](/coverage/ec2) | 122 | 625 | 19.5% | | [ECR](/coverage/ecr) | 19 | 47 | 40.4% | | [ECS](/coverage/ecs) | 31 | 56 | 55.4% | | [EKS](/coverage/eks) | 34 | 56 | 60.7% | diff --git a/docs/coverage/ec2/README.md b/docs/coverage/ec2/README.md index 9f2115a8e..4b3363aa8 100644 --- a/docs/coverage/ec2/README.md +++ b/docs/coverage/ec2/README.md @@ -18,7 +18,7 @@ tags: ## Overview -Spinifex implements **121 of the 625** operations (**19.4%**) in the EC2 `2016-11-15` API model. +Spinifex implements **122 of the 625** operations (**19.5%**) in the EC2 `2016-11-15` API model. ### Spot Instances Are a Mock @@ -301,7 +301,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ | `DescribeInstanceEventWindows` | ❌ Not implemented | | `DescribeInstanceStatus` | ✅ Implemented | | `DescribeInstanceTopology` | ❌ Not implemented | -| `DescribeInstanceTypeOfferings` | ❌ Not implemented | +| `DescribeInstanceTypeOfferings` | ✅ Implemented | | `DescribeInstanceTypes` | ✅ Implemented | | `DescribeInstances` | ✅ Implemented | | `DescribeInternetGateways` | ✅ Implemented | diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index ada7c0d3c..201206bad 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -209,6 +209,9 @@ var ec2Actions = map[string]ec2Action{ "DescribeInstanceTypes": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypesInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_instance.DescribeInstanceTypes(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID) }), + "DescribeInstanceTypeOfferings": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, gw *GatewayConfig, accountID string) (any, error) { + return gateway_ec2_instance.DescribeInstanceTypeOfferings(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID, gw.Region, gw.AZ) + }), "DescribeInstanceStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceStatusInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_instance.DescribeInstanceStatus(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID, gw.AZ, gw.InstanceStatus) }), diff --git a/spinifex/gateway/ec2/authz.go b/spinifex/gateway/ec2/authz.go index 6b379a2a6..06d3903d1 100644 --- a/spinifex/gateway/ec2/authz.go +++ b/spinifex/gateway/ec2/authz.go @@ -265,6 +265,7 @@ var ec2Scopes = map[string][]*resourceScope{ "DescribeInstanceAttribute": unscoped, "DescribeInstanceCreditSpecifications": unscoped, "DescribeInstanceStatus": unscoped, + "DescribeInstanceTypeOfferings": unscoped, "DescribeInstanceTypes": unscoped, "DescribeInstances": unscoped, "DescribeInternetGateways": unscoped, diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go new file mode 100644 index 000000000..f00e31461 --- /dev/null +++ b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go @@ -0,0 +1,149 @@ +package gateway_ec2_instance + +import ( + "context" + "errors" + "log/slog" + "sort" + "strconv" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/filterutil" + gateway_ec2_zone "github.com/mulgadc/spinifex/spinifex/gateway/ec2/zone" + "github.com/nats-io/nats.go" +) + +// defaultOfferingsPerPage caps a request that names no MaxResults. +const defaultOfferingsPerPage = 1000 + +var describeInstanceTypeOfferingsValidFilters = map[string]bool{ + "instance-type": true, + "location": true, +} + +// DescribeInstanceTypeOfferings projects the supported instance type catalogue +// over the location list. Both inputs are read live on every call, so the answer +// cannot advertise a type the platform is unable to place. +func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, natsConn *nats.Conn, expectedNodes int, accountID, region, az string) (*ec2.DescribeInstanceTypeOfferingsOutput, error) { + if input == nil { + input = &ec2.DescribeInstanceTypeOfferingsInput{} + } + + locationType := aws.StringValue(input.LocationType) + if locationType == "" { + locationType = ec2.LocationTypeRegion + } + + locations, err := offeringLocations(locationType, region, az) + if err != nil { + slog.WarnContext(ctx, "DescribeInstanceTypeOfferings: invalid location type", "location_type", locationType) + return nil, err + } + + parsedFilters, err := filterutil.ParseFilters(input.Filters, describeInstanceTypeOfferingsValidFilters) + if err != nil { + slog.WarnContext(ctx, "DescribeInstanceTypeOfferings: invalid filter", "err", err) + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + + // No capacity filter: an offering is what the platform supports, not what + // has a free slot at this instant. + catalogue, err := DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{}, natsConn, expectedNodes, accountID) + if err != nil { + return nil, err + } + + offerings := make([]*ec2.InstanceTypeOffering, 0, len(catalogue.InstanceTypes)*len(locations)) + for _, instanceType := range catalogue.InstanceTypes { + if instanceType == nil || instanceType.InstanceType == nil { + continue + } + if !filterutil.MatchesAny(parsedFilters["instance-type"], *instanceType.InstanceType) { + continue + } + for _, location := range locations { + if !filterutil.MatchesAny(parsedFilters["location"], location) { + continue + } + offerings = append(offerings, &ec2.InstanceTypeOffering{ + InstanceType: aws.String(*instanceType.InstanceType), + Location: aws.String(location), + LocationType: aws.String(locationType), + }) + } + } + + // Total order so a paged traversal sees every offering exactly once. + sort.Slice(offerings, func(i, j int) bool { + if *offerings[i].InstanceType != *offerings[j].InstanceType { + return *offerings[i].InstanceType < *offerings[j].InstanceType + } + return *offerings[i].Location < *offerings[j].Location + }) + + page, nextToken, err := pageOfferings(offerings, input.NextToken, input.MaxResults) + if err != nil { + slog.WarnContext(ctx, "DescribeInstanceTypeOfferings: invalid NextToken", "next_token", aws.StringValue(input.NextToken)) + return nil, err + } + + slog.InfoContext(ctx, "DescribeInstanceTypeOfferings: derived offerings", "location_type", locationType, "total", len(offerings), "returned", len(page)) + return &ec2.DescribeInstanceTypeOfferingsOutput{InstanceTypeOfferings: page, NextToken: nextToken}, nil +} + +// offeringLocations resolves the locations an offering can be reported at. The +// zone list is read rather than assumed, so the operation follows the zone model +// if it ever widens. +func offeringLocations(locationType, region, az string) ([]string, error) { + switch locationType { + case ec2.LocationTypeRegion: + return []string{region}, nil + case ec2.LocationTypeAvailabilityZone, ec2.LocationTypeAvailabilityZoneId: + default: + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + + zones, err := gateway_ec2_zone.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, region, az) + if err != nil { + return nil, err + } + + locations := make([]string, 0, len(zones.AvailabilityZones)) + for _, zone := range zones.AvailabilityZones { + if locationType == ec2.LocationTypeAvailabilityZoneId { + locations = append(locations, aws.StringValue(zone.ZoneId)) + continue + } + locations = append(locations, aws.StringValue(zone.ZoneName)) + } + return locations, nil +} + +// pageOfferings slices one page out of a sorted offering set, using an opaque +// integer offset as the token. The last page carries a nil token. +func pageOfferings(offerings []*ec2.InstanceTypeOffering, token *string, maxResults *int64) ([]*ec2.InstanceTypeOffering, *string, error) { + start := 0 + if aws.StringValue(token) != "" { + n, err := strconv.Atoi(*token) + if err != nil || n < 0 { + return nil, nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + start = n + } + if start > len(offerings) { + start = len(offerings) + } + + size := defaultOfferingsPerPage + if maxResults != nil && *maxResults > 0 { + size = int(*maxResults) + } + + end := start + size + if end >= len(offerings) { + return offerings[start:], nil, nil + } + return offerings[start:end], aws.String(strconv.Itoa(end)), nil +} diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go new file mode 100644 index 000000000..cf02f8fcc --- /dev/null +++ b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go @@ -0,0 +1,317 @@ +package gateway_ec2_instance + +import ( + "context" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + gateway_ec2_zone "github.com/mulgadc/spinifex/spinifex/gateway/ec2/zone" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + offeringsTestRegion = "ap-southeast-2" + offeringsTestAZ = "ap-southeast-2a" +) + +// serveInstanceTypes stands in for the node catalogue behind DescribeInstanceTypes. +func serveInstanceTypes(t *testing.T, nc *nats.Conn, instanceTypes ...string) { + t.Helper() + + infos := make([]*ec2.InstanceTypeInfo, 0, len(instanceTypes)) + for _, it := range instanceTypes { + infos = append(infos, &ec2.InstanceTypeInfo{InstanceType: aws.String(it)}) + } + + _, err := nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{InstanceTypes: infos}) + msg.Respond(data) + }) + require.NoError(t, err) + require.NoError(t, nc.Flush()) +} + +// TestDescribeInstanceTypeOfferings_AvailabilityZone asserts the result against +// DescribeInstanceTypes and DescribeAvailabilityZones rather than a fixture, so +// the test fails if the answer ever stops being derived from them. +func TestDescribeInstanceTypeOfferings_AvailabilityZone(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro", "m5.large") + + types, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{}, nc, 1, "") + require.NoError(t, err) + zones, err := gateway_ec2_zone.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + var want []*ec2.InstanceTypeOffering + for _, it := range types.InstanceTypes { + for _, zone := range zones.AvailabilityZones { + want = append(want, &ec2.InstanceTypeOffering{ + InstanceType: it.InstanceType, + Location: zone.ZoneName, + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + }) + } + } + + assert.ElementsMatch(t, want, output.InstanceTypeOfferings) + assert.Nil(t, output.NextToken) +} + +func TestDescribeInstanceTypeOfferings_Region(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro", "m5.large") + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeRegion), + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + require.Len(t, output.InstanceTypeOfferings, 2) + for _, offering := range output.InstanceTypeOfferings { + assert.Equal(t, offeringsTestRegion, aws.StringValue(offering.Location)) + assert.Equal(t, ec2.LocationTypeRegion, aws.StringValue(offering.LocationType)) + } +} + +// TestDescribeInstanceTypeOfferings_DefaultLocationTypeIsRegion covers the AWS +// default: an omitted LocationType reports the region, not a zone. +func TestDescribeInstanceTypeOfferings_DefaultLocationTypeIsRegion(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{}, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + require.Len(t, output.InstanceTypeOfferings, 1) + assert.Equal(t, offeringsTestRegion, aws.StringValue(output.InstanceTypeOfferings[0].Location)) + assert.Equal(t, ec2.LocationTypeRegion, aws.StringValue(output.InstanceTypeOfferings[0].LocationType)) +} + +func TestDescribeInstanceTypeOfferings_AvailabilityZoneID(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + zones, err := gateway_ec2_zone.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + zoneID := aws.StringValue(zones.AvailabilityZones[0].ZoneId) + require.NotEqual(t, offeringsTestAZ, zoneID, "zone ID must differ from zone name for this assertion to mean anything") + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZoneId), + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + require.Len(t, output.InstanceTypeOfferings, 1) + assert.Equal(t, zoneID, aws.StringValue(output.InstanceTypeOfferings[0].Location)) +} + +func TestDescribeInstanceTypeOfferings_UnknownLocationType(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + _, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String("outpost"), + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error()) +} + +func TestDescribeInstanceTypeOfferings_Filters(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filters []*ec2.Filter + wantTypes []string + }{ + { + name: "instance-type narrows", + filters: []*ec2.Filter{{Name: aws.String("instance-type"), Values: []*string{aws.String("t3.micro")}}}, + wantTypes: []string{"t3.micro"}, + }, + { + name: "instance-type wildcard narrows", + filters: []*ec2.Filter{{Name: aws.String("instance-type"), Values: []*string{aws.String("t3.*")}}}, + wantTypes: []string{"t3.micro", "t3.small"}, + }, + { + name: "location narrows to the zone", + filters: []*ec2.Filter{{Name: aws.String("location"), Values: []*string{aws.String(offeringsTestAZ)}}}, + wantTypes: []string{"m5.large", "t3.micro", "t3.small"}, + }, + { + name: "location matching nothing is empty, not an error", + filters: []*ec2.Filter{{Name: aws.String("location"), Values: []*string{aws.String("us-east-1a")}}}, + wantTypes: nil, + }, + { + name: "instance-type matching nothing is empty, not an error", + filters: []*ec2.Filter{{Name: aws.String("instance-type"), Values: []*string{aws.String("c9.nonexistent")}}}, + wantTypes: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro", "t3.small", "m5.large") + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + Filters: tc.filters, + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + var got []string + for _, offering := range output.InstanceTypeOfferings { + got = append(got, aws.StringValue(offering.InstanceType)) + } + assert.Equal(t, tc.wantTypes, got) + assert.NotNil(t, output.InstanceTypeOfferings, "empty result must render its container, so the slice must not be nil") + }) + } +} + +func TestDescribeInstanceTypeOfferings_UnknownFilter(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + _, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + Filters: []*ec2.Filter{{Name: aws.String("not-a-filter"), Values: []*string{aws.String("x")}}}, + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error()) +} + +// TestDescribeInstanceTypeOfferings_FullTraversal walks every page with a small +// MaxResults and asserts each offering appears exactly once, so a paging caller +// cannot loop or miss a type. +func TestDescribeInstanceTypeOfferings_FullTraversal(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + catalogue := []string{"c5.large", "m5.large", "m5.xlarge", "t3.micro", "t3.small"} + serveInstanceTypes(t, nc, catalogue...) + + seen := make(map[string]int) + var token *string + pages := 0 + for { + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + MaxResults: aws.Int64(2), + NextToken: token, + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + require.NoError(t, err) + + for _, offering := range output.InstanceTypeOfferings { + seen[aws.StringValue(offering.InstanceType)]++ + } + + pages++ + require.Less(t, pages, 10, "traversal did not terminate") + if output.NextToken == nil { + break + } + assert.Len(t, output.InstanceTypeOfferings, 2, "a non-final page must be full") + token = output.NextToken + } + + assert.Equal(t, 3, pages) + require.Len(t, seen, len(catalogue)) + for _, instanceType := range catalogue { + assert.Equal(t, 1, seen[instanceType], "offering for %s appeared %d times", instanceType, seen[instanceType]) + } +} + +func TestDescribeInstanceTypeOfferings_MalformedNextToken(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + _, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + NextToken: aws.String("not-a-token"), + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error()) +} + +// TestDescribeInstanceTypeOfferings_TokenPastEnd guards the offset token against +// a caller replaying a stale page after the catalogue shrank. +func TestDescribeInstanceTypeOfferings_TokenPastEnd(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + NextToken: aws.String("99"), + }, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + + require.NoError(t, err) + assert.Empty(t, output.InstanceTypeOfferings) + assert.Nil(t, output.NextToken) +} + +func TestDescribeInstanceTypeOfferings_EmptyCatalogue(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + + output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + }, nc, 0, "", offeringsTestRegion, offeringsTestAZ) + + require.NoError(t, err) + require.NotNil(t, output.InstanceTypeOfferings) + assert.Empty(t, output.InstanceTypeOfferings) + assert.Nil(t, output.NextToken) +} + +func TestDescribeInstanceTypeOfferings_CatalogueError(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + + closedNC, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + closedNC.Close() + + _, err = DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{ + LocationType: aws.String(ec2.LocationTypeAvailabilityZone), + }, closedNC, 1, "", offeringsTestRegion, offeringsTestAZ) + + require.Error(t, err) +} + +func TestDescribeInstanceTypeOfferings_NilInput(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + serveInstanceTypes(t, nc, "t3.micro") + + output, err := DescribeInstanceTypeOfferings(context.Background(), nil, nc, 1, "", offeringsTestRegion, offeringsTestAZ) + + require.NoError(t, err) + require.Len(t, output.InstanceTypeOfferings, 1) + assert.Equal(t, offeringsTestRegion, aws.StringValue(output.InstanceTypeOfferings[0].Location)) +} diff --git a/spinifex/gateway/ec2_emptyresult_test.go b/spinifex/gateway/ec2_emptyresult_test.go index 9c8e4970b..e4097b062 100644 --- a/spinifex/gateway/ec2_emptyresult_test.go +++ b/spinifex/gateway/ec2_emptyresult_test.go @@ -60,6 +60,9 @@ func TestEC2Describe_EmptyResult_EmitsContainer(t *testing.T) { {"DescribeImages", "DescribeImages", "imagesSet", func(t *testing.T) string { return render[ec2.DescribeImagesInput](t, "DescribeImages", ec2.DescribeImagesOutput{}) }}, + {"DescribeInstanceTypeOfferings", "DescribeInstanceTypeOfferings", "instanceTypeOfferingSet", func(t *testing.T) string { + return render[ec2.DescribeInstanceTypeOfferingsInput](t, "DescribeInstanceTypeOfferings", ec2.DescribeInstanceTypeOfferingsOutput{}) + }}, } for _, tc := range cases {