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")) } 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 {