feat: implement EC2 DescribeInstanceTypeOfferings (#1025)

This commit is contained in:
Julian Sommer
2026-09-14 16:03:31 +10:00
committed by GitHub
parent 0984eb31fb
commit 53ef0ac491
7 changed files with 476 additions and 3 deletions
+1 -1
View File
@@ -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% |
+2 -2
View File
@@ -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 |
+3
View File
@@ -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)
}),
+1
View File
@@ -265,6 +265,7 @@ var ec2Scopes = map[string][]*resourceScope{
"DescribeInstanceAttribute": unscoped,
"DescribeInstanceCreditSpecifications": unscoped,
"DescribeInstanceStatus": unscoped,
"DescribeInstanceTypeOfferings": unscoped,
"DescribeInstanceTypes": unscoped,
"DescribeInstances": unscoped,
"DescribeInternetGateways": unscoped,
@@ -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
}
@@ -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))
}
+3
View File
@@ -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 {