From 8721013ea6a6a81a71e988cdeaad21bb63d788b5 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 2 Sep 2026 13:25:45 -0700 Subject: [PATCH 1/2] aws: fetch instance types on demand instead of listing all Look up only the instance types referenced by the install config and cache the results, rather than paginating over every type in the region. --- pkg/asset/installconfig/aws/awserrors.go | 14 +++++ pkg/asset/installconfig/aws/instancetypes.go | 64 +++++++++++--------- pkg/asset/installconfig/aws/metadata.go | 31 ++++++---- pkg/asset/installconfig/aws/validation.go | 31 +++++----- 4 files changed, 81 insertions(+), 59 deletions(-) diff --git a/pkg/asset/installconfig/aws/awserrors.go b/pkg/asset/installconfig/aws/awserrors.go index f9754cacd7d..24a0e890407 100644 --- a/pkg/asset/installconfig/aws/awserrors.go +++ b/pkg/asset/installconfig/aws/awserrors.go @@ -12,6 +12,7 @@ import ( const ( AccessDeniedException = "AccessDeniedException" NoSuchResourceException = "NoSuchResourceException" + InvalidInstanceType = "InvalidInstanceType" ) // IsUnauthorized checks if the error is due to lacking permissions. @@ -29,6 +30,19 @@ func IsUnauthorized(err error) bool { return false } +// IsInvalidInstanceType returns true if the error is an AWS InvalidInstanceType error, +// indicating the requested instance type does not exist in the region. +func IsInvalidInstanceType(err error) bool { + if err == nil { + return false + } + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + return apiErr.ErrorCode() == InvalidInstanceType + } + return false +} + // IsHTTPForbidden returns true if and only if the error is an HTTP // 403 error from the AWS API. func IsHTTPForbidden(err error) bool { diff --git a/pkg/asset/installconfig/aws/instancetypes.go b/pkg/asset/installconfig/aws/instancetypes.go index ded14210634..0a9e2a177aa 100644 --- a/pkg/asset/installconfig/aws/instancetypes.go +++ b/pkg/asset/installconfig/aws/instancetypes.go @@ -6,6 +6,7 @@ import ( "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" ) // Networking describes the network settings for an instance type. @@ -24,41 +25,44 @@ type InstanceType struct { Features []string } -// instanceTypes retrieves a list of instance types for the given region. -func instanceTypes(ctx context.Context, client *ec2.Client) (map[string]InstanceType, error) { - types := map[string]InstanceType{} - - paginator := ec2.NewDescribeInstanceTypesPaginator(client, &ec2.DescribeInstanceTypesInput{}) - for paginator.HasMorePages() { - page, err := paginator.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list instance types: %w", err) - } - - for _, sdkTypeInfo := range page.InstanceTypes { - typeInfo := InstanceType{ - DefaultVCpus: int64(aws.ToInt32(sdkTypeInfo.VCpuInfo.DefaultVCpus)), - MemInMiB: aws.ToInt64(sdkTypeInfo.MemoryInfo.SizeInMiB), - Hypervisor: string(sdkTypeInfo.Hypervisor), - } +// getInstanceType returns metadata for the named instance type. +func getInstanceType(ctx context.Context, client *ec2.Client, instanceType string) (InstanceType, error) { + out, err := client.DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{ + InstanceTypes: []ec2types.InstanceType{ec2types.InstanceType(instanceType)}, + }) + if err != nil { + return InstanceType{}, fmt.Errorf("failed to get instance type %s details: %w", instanceType, err) + } - for _, arch := range sdkTypeInfo.ProcessorInfo.SupportedArchitectures { - typeInfo.Arches = append(typeInfo.Arches, string(arch)) - } + // A nonexistent type is reported as an InvalidInstanceType error above, so an + // empty result here is an unexpected API response rather than a missing type. + if len(out.InstanceTypes) == 0 { + return InstanceType{}, fmt.Errorf("unexpected empty response describing instance type %s", instanceType) + } - if netInfo := sdkTypeInfo.NetworkInfo; netInfo != nil { - typeInfo.Networking = Networking{ - IPv6Supported: aws.ToBool(netInfo.Ipv6Supported), - } - } + sdkTypeInfo := out.InstanceTypes[0] + if sdkTypeInfo.VCpuInfo == nil || sdkTypeInfo.MemoryInfo == nil || sdkTypeInfo.ProcessorInfo == nil { + return InstanceType{}, fmt.Errorf("incomplete metadata describing instance type %s", instanceType) + } + typeInfo := InstanceType{ + DefaultVCpus: int64(aws.ToInt32(sdkTypeInfo.VCpuInfo.DefaultVCpus)), + MemInMiB: aws.ToInt64(sdkTypeInfo.MemoryInfo.SizeInMiB), + Hypervisor: string(sdkTypeInfo.Hypervisor), + } - for _, features := range sdkTypeInfo.ProcessorInfo.SupportedFeatures { - typeInfo.Features = append(typeInfo.Features, string(features)) - } + for _, arch := range sdkTypeInfo.ProcessorInfo.SupportedArchitectures { + typeInfo.Arches = append(typeInfo.Arches, string(arch)) + } - types[string(sdkTypeInfo.InstanceType)] = typeInfo + if netInfo := sdkTypeInfo.NetworkInfo; netInfo != nil { + typeInfo.Networking = Networking{ + IPv6Supported: aws.ToBool(netInfo.Ipv6Supported), } } - return types, nil + for _, features := range sdkTypeInfo.ProcessorInfo.SupportedFeatures { + typeInfo.Features = append(typeInfo.Features, string(features)) + } + + return typeInfo, nil } diff --git a/pkg/asset/installconfig/aws/metadata.go b/pkg/asset/installconfig/aws/metadata.go index 7d3827bea95..23c6f17c515 100644 --- a/pkg/asset/installconfig/aws/metadata.go +++ b/pkg/asset/installconfig/aws/metadata.go @@ -352,24 +352,31 @@ func (m *Metadata) populateVPC(ctx context.Context) error { return err } -// InstanceTypes retrieves instance type metadata indexed by InstanceType for the configured region. -func (m *Metadata) InstanceTypes(ctx context.Context) (map[string]InstanceType, error) { +// InstanceType returns metadata for the named instance type. +func (m *Metadata) InstanceType(ctx context.Context, instanceType string) (InstanceType, error) { m.mutex.Lock() defer m.mutex.Unlock() - if len(m.instanceTypes) == 0 { - client, err := m.EC2Client(ctx) - if err != nil { - return nil, err - } + if t, ok := m.instanceTypes[instanceType]; ok { + return t, nil + } - m.instanceTypes, err = instanceTypes(ctx, client) - if err != nil { - return nil, fmt.Errorf("error listing instance types: %w", err) - } + client, err := m.EC2Client(ctx) + if err != nil { + return InstanceType{}, err + } + + t, err := getInstanceType(ctx, client, instanceType) + if err != nil { + return InstanceType{}, err + } + + if m.instanceTypes == nil { + m.instanceTypes = map[string]InstanceType{} } + m.instanceTypes[instanceType] = t - return m.instanceTypes, nil + return t, nil } // Images retrieves image metadata for the specified AMI ID. diff --git a/pkg/asset/installconfig/aws/validation.go b/pkg/asset/installconfig/aws/validation.go index e8a13245fa5..ef9909490c8 100644 --- a/pkg/asset/installconfig/aws/validation.go +++ b/pkg/asset/installconfig/aws/validation.go @@ -456,11 +456,14 @@ func validateMachinePool(ctx context.Context, meta *Metadata, fldPath *field.Pat } } if pool.InstanceType != "" { - instanceTypes, err := meta.InstanceTypes(ctx) - if err != nil { - return append(allErrs, field.InternalError(fldPath, err)) - } - if typeMeta, ok := instanceTypes[pool.InstanceType]; ok { + typeMeta, err := meta.InstanceType(ctx, pool.InstanceType) + switch { + case IsInvalidInstanceType(err): + errMsg := fmt.Sprintf("instance type %s not found", pool.InstanceType) + allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), pool.InstanceType, errMsg)) + case err != nil: + allErrs = append(allErrs, field.InternalError(fldPath, err)) + default: if typeMeta.DefaultVCpus < req.minimumVCpus { errMsg := fmt.Sprintf("instance type does not meet minimum resource requirements of %d vCPUs", req.minimumVCpus) allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), pool.InstanceType, errMsg)) @@ -489,9 +492,6 @@ func validateMachinePool(ctx context.Context, meta *Metadata, fldPath *field.Pat allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), pool.InstanceType, errMsg)) } } - } else { - errMsg := fmt.Sprintf("instance type %s not found", pool.InstanceType) - allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), pool.InstanceType, errMsg)) } } @@ -1173,19 +1173,16 @@ func validateInstanceTypeForSEVSNP(ctx context.Context, meta *Metadata, fldPath return allErrs } - // Fetch instance types metadata - instanceTypes, err := meta.InstanceTypes(ctx) + // Fetch instance type metadata + typeMeta, err := meta.InstanceType(ctx, pool.InstanceType) if err != nil { + // The instance type is not found; already caught in validateMachinePool. + if IsInvalidInstanceType(err) { + return allErrs + } return append(allErrs, field.InternalError(fldPath, err)) } - // Validate the specified instance type supports SEV-SNP - // If the instance type is not found, it's already caught in validateMachinePool - typeMeta, ok := instanceTypes[pool.InstanceType] - if !ok { - return allErrs - } - if !slices.Contains(typeMeta.Features, string(ec2types.SupportedAdditionalProcessorFeatureAmdSevSnp)) { allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), pool.InstanceType, "specified instance type in the specified region doesn't support amd-sev-snp")) } From de300d5ae9060743f1367e0cce9f538c4b90a803 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 2 Sep 2026 13:45:48 -0700 Subject: [PATCH 2/2] tests: mock EC2 for on-demand instance-type lookups Provide an httpmock-backed client returning an empty DescribeInstanceTypes response so unknown types resolve to not-found instead of attempting a live EC2 call. --- .../installconfig/aws/validation_test.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pkg/asset/installconfig/aws/validation_test.go b/pkg/asset/installconfig/aws/validation_test.go index c085b53c459..fa20f73bad0 100644 --- a/pkg/asset/installconfig/aws/validation_test.go +++ b/pkg/asset/installconfig/aws/validation_test.go @@ -10,6 +10,9 @@ import ( "strings" "testing" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" @@ -1803,8 +1806,38 @@ func TestValidate(t *testing.T) { }) } + // Provide a mock EC2 client to avoid a live API call when looking up an instance type. + // The EC2 query API POSTs to the root path, so the request URL carries a trailing slash. + // httpmock matches responders by exact URL, so the endpoint includes the slash to match what the SDK sends. + // + // Instance types referenced by the tests are pre-seeded in Metadata.instanceTypes, so only + // unknown types reach the API. DescribeInstanceTypes returns an InvalidInstanceType error for a + // type that does not exist. + const mockEC2Endpoint = "https://ec2.mock.local/" + httpmock.RegisterResponder(http.MethodPost, mockEC2Endpoint, func(r *http.Request) (*http.Response, error) { + const invalidInstanceTypeResp = ` + + + + InvalidInstanceType + The following supplied instance types do not exist + + + req-mock +` + return httpmock.NewStringResponse(http.StatusBadRequest, invalidInstanceTypeResp), nil + }) + for _, test := range tests { t.Run(test.name, func(t *testing.T) { + ec2Mock := ec2.New(ec2.Options{ + Region: test.installConfig.Platform.AWS.Region, + Credentials: credentials.NewStaticCredentialsProvider("id", "secret", "token"), + BaseEndpoint: awssdk.String(mockEC2Endpoint), + // An HTTP client must be defined so that the SDK doesn't build + // its own transport, which escapes the mock. + HTTPClient: &http.Client{}, + }) meta := &Metadata{ availabilityZones: test.availZones, availableRegions: test.availRegions, @@ -1821,6 +1854,7 @@ func TestValidate(t *testing.T) { Hosts: test.hosts, Region: test.installConfig.Platform.AWS.Region, ProvidedSubnets: test.installConfig.Platform.AWS.VPC.Subnets, + ec2Client: ec2Mock, } if test.subnetsInVPC != nil {