diff --git a/pkg/asset/installconfig/aws/images.go b/pkg/asset/installconfig/aws/images.go new file mode 100644 index 00000000000..cf90b4cb790 --- /dev/null +++ b/pkg/asset/installconfig/aws/images.go @@ -0,0 +1,36 @@ +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" +) + +// ImageInfo holds metadata for an AMI. +type ImageInfo struct { + BootMode string +} + +// images retrieves image metadata for the specified AMI ID in the given region. +func images(ctx context.Context, session *session.Session, region string, amiID string) (ImageInfo, error) { + client := ec2.New(session, aws.NewConfig().WithRegion(region)) + + imageOutput, err := client.DescribeImagesWithContext(ctx, &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(amiID)}, + }) + if err != nil { + return ImageInfo{}, fmt.Errorf("fetching images: %w", err) + } + + if len(imageOutput.Images) == 0 { + return ImageInfo{}, fmt.Errorf("AMI %s not found", amiID) + } + + image := imageOutput.Images[0] + return ImageInfo{ + BootMode: aws.StringValue(image.BootMode), + }, nil +} diff --git a/pkg/asset/installconfig/aws/instancetypes.go b/pkg/asset/installconfig/aws/instancetypes.go index 152b3aa7109..ee54ab7f239 100644 --- a/pkg/asset/installconfig/aws/instancetypes.go +++ b/pkg/asset/installconfig/aws/instancetypes.go @@ -14,6 +14,7 @@ type InstanceType struct { DefaultVCpus int64 MemInMiB int64 Arches []string + Features []string } // instanceTypes retrieves a list of instance types for the given region. @@ -29,6 +30,7 @@ func instanceTypes(ctx context.Context, session *session.Session, region string) DefaultVCpus: aws.Int64Value(info.VCpuInfo.DefaultVCpus), MemInMiB: aws.Int64Value(info.MemoryInfo.SizeInMiB), Arches: aws.StringValueSlice(info.ProcessorInfo.SupportedArchitectures), + Features: aws.StringValueSlice(info.ProcessorInfo.SupportedFeatures), } } return !lastPage diff --git a/pkg/asset/installconfig/aws/metadata.go b/pkg/asset/installconfig/aws/metadata.go index f9ca37ad22c..b80ff44e117 100644 --- a/pkg/asset/installconfig/aws/metadata.go +++ b/pkg/asset/installconfig/aws/metadata.go @@ -26,6 +26,7 @@ type Metadata struct { vpcSubnets SubnetGroups vpc VPC instanceTypes map[string]InstanceType + images map[string]ImageInfo Region string `json:"region,omitempty"` ProvidedSubnets []typesaws.Subnet `json:"subnets,omitempty"` @@ -390,3 +391,39 @@ func (m *Metadata) InstanceTypes(ctx context.Context) (map[string]InstanceType, return m.instanceTypes, nil } + +// Images retrieves image metadata for the specified AMI ID. +func (m *Metadata) Images(ctx context.Context, amiID string) (ImageInfo, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if amiID == "" { + return ImageInfo{}, fmt.Errorf("AMI ID cannot be empty") + } + + // Check if AMI is already cached + if imageInfo, ok := m.images[amiID]; ok { + return imageInfo, nil + } + + // Fetch uncached AMI + session, err := m.unlockedSession(ctx) + if err != nil { + return ImageInfo{}, err + } + + imageInfo, err := images(ctx, session, m.Region, amiID) + if err != nil { + return ImageInfo{}, fmt.Errorf("error fetching AMI metadata: %w", err) + } + + // Initialize map if needed + if m.images == nil { + m.images = map[string]ImageInfo{} + } + + // Add newly fetched image to cache + m.images[amiID] = imageInfo + + return imageInfo, nil +} diff --git a/pkg/asset/installconfig/aws/validation.go b/pkg/asset/installconfig/aws/validation.go index 8401bfcf067..f490ba7f7ed 100644 --- a/pkg/asset/installconfig/aws/validation.go +++ b/pkg/asset/installconfig/aws/validation.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/url" + "slices" "sort" ec2v2 "github.com/aws/aws-sdk-go-v2/service/ec2" @@ -453,6 +454,10 @@ func validateMachinePool(ctx context.Context, meta *Metadata, fldPath *field.Pat } } + if pool.CPUOptions != nil { + allErrs = append(allErrs, validateCPUOptions(ctx, meta, fldPath, pool)...) + } + if len(pool.AdditionalSecurityGroupIDs) > 0 { allErrs = append(allErrs, validateSecurityGroupIDs(ctx, meta, fldPath.Child("additionalSecurityGroupIDs"), platform, pool)...) } @@ -964,3 +969,77 @@ func validateInstanceProfile(ctx context.Context, meta *Metadata, fldPath *field return nil } + +func validateCPUOptions(ctx context.Context, meta *Metadata, fldPath *field.Path, pool *awstypes.MachinePool) field.ErrorList { + allErrs := field.ErrorList{} + cpuOpts := pool.CPUOptions + + // Early return if no CPU options specified + if cpuOpts == nil { + return allErrs + } + + // See sev-snp support requirements: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sev-snp.html#snp-requirements + if cpuOpts.ConfidentialCompute != nil && *cpuOpts.ConfidentialCompute == awstypes.ConfidentialComputePolicySEVSNP { + // Validate AMI boot mode for SEV-SNP + allErrs = append(allErrs, validateAMIBootMode(ctx, meta, fldPath, pool)...) + + // Validate instance type for SEV-SNP + allErrs = append(allErrs, validateInstanceTypeForSEVSNP(ctx, meta, fldPath, pool)...) + } + + return allErrs +} + +func validateInstanceTypeForSEVSNP(ctx context.Context, meta *Metadata, fldPath *field.Path, pool *awstypes.MachinePool) field.ErrorList { + allErrs := field.ErrorList{} + + // Warn if using default instance type + if pool.InstanceType == "" { + logrus.Warnf("AMD SEV-SNP confidential computing is enabled for %s but no instance type is specified. The default instance type may not support amd-sev-snp", fldPath) + return allErrs + } + + // Fetch instance types metadata + instanceTypes, err := meta.InstanceTypes(ctx) + if err != nil { + 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, ec2.SupportedAdditionalProcessorFeatureAmdSevSnp) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), pool.InstanceType, "specified instance type in the specified region doesn't support amd-sev-snp")) + } + + return allErrs +} + +func validateAMIBootMode(ctx context.Context, meta *Metadata, fldPath *field.Path, pool *awstypes.MachinePool) field.ErrorList { + allErrs := field.ErrorList{} + + amiID := pool.AMIID + if amiID == "" { + // Warn when using default AMI with SEV-SNP + logrus.Warnf("AMD SEV-SNP confidential computing is enabled for %s but no custom AMI is specified. The default RHCOS AMI may not have UEFI boot mode enabled", fldPath) + return allErrs + } + + // Get image metadata + imageInfo, err := meta.Images(ctx, amiID) + if err != nil { + return append(allErrs, field.InternalError(fldPath.Child("amiID"), fmt.Errorf("unable to retrieve AMI metadata: %w", err))) + } + + // Check if boot mode supports UEFI + if imageInfo.BootMode != ec2.BootModeValuesUefi && imageInfo.BootMode != ec2.BootModeValuesUefiPreferred { + allErrs = append(allErrs, field.Invalid(fldPath.Child("amiID"), amiID, fmt.Sprintf("AMI boot mode must be 'uefi' or 'uefi-preferred' when using AMD SEV-SNP confidential computing, got '%s'", imageInfo.BootMode))) + } + + return allErrs +} diff --git a/pkg/asset/installconfig/aws/validation_test.go b/pkg/asset/installconfig/aws/validation_test.go index 34d155a7534..392b49ba7dd 100644 --- a/pkg/asset/installconfig/aws/validation_test.go +++ b/pkg/asset/installconfig/aws/validation_test.go @@ -74,6 +74,7 @@ func TestValidate(t *testing.T) { subnetsInVPC *SubnetGroups vpcTags Tags instanceTypes map[string]InstanceType + images map[string]ImageInfo proxy string publicOnly bool expectErr string @@ -155,6 +156,206 @@ func TestValidate(t *testing.T) { }, expectErr: `^compute\[1\].architecture: Invalid value: "arm64": all compute machine pools must be of the same architecture$`, }, + { + name: "valid instance type and UEFI AMI with enabling SEV-SNP on default platform", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m6a.xlarge", "m6a.xlarge"), + icBuild.withDefaultMachinePlatformCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }), + icBuild.withDefaultMachinePlatformAMI("ami-uefi"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-uefi": {BootMode: "uefi"}, + }, + }, + { + name: "valid instance type and UEFI AMI with enabling SEV-SNP on control plane", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m6a.xlarge", "m6a.xlarge"), + icBuild.withControlPlaneCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }), + icBuild.withControlPlanePlatformAMI("ami-uefi"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-uefi": {BootMode: "uefi"}, + }, + }, + { + name: "valid instance type and UEFI-preferred AMI with enabling SEV-SNP on compute", + installConfig: icBuild.build( + icBuild.withInstanceType("m5.xlarge", "m5.xlarge", "m6a.xlarge"), + icBuild.withComputeCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }, 0), + icBuild.withComputePlatformAMI("ami-uefi-preferred", 0), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-uefi-preferred": {BootMode: "uefi-preferred"}, + }, + }, + { + name: "invalid instance type with enabling SEV-SNP on default platform", + installConfig: icBuild.build( + icBuild.withInstanceType("m5.xlarge", "m6a.xlarge", "m6a.xlarge"), + icBuild.withDefaultMachinePlatformCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }), + icBuild.withDefaultMachinePlatformAMI("ami-uefi"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-uefi": {BootMode: "uefi"}, + }, + expectErr: `^\Qplatform.aws.defaultMachinePlatform.type: Invalid value: "m5.xlarge": specified instance type in the specified region doesn't support amd-sev-snp\E$`, + }, + { + name: "invalid instance type with enabling SEV-SNP on control plane", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m5.xlarge", "m6a.xlarge"), + icBuild.withControlPlaneCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }), + icBuild.withControlPlanePlatformAMI("ami-uefi"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-uefi": {BootMode: "uefi"}, + }, + expectErr: `^\QcontrolPlane.platform.aws.type: Invalid value: "m5.xlarge": specified instance type in the specified region doesn't support amd-sev-snp\E$`, + }, + { + name: "invalid instance type with enabling SEV-SNP on compute", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m6a.xlarge", "m5.xlarge"), + icBuild.withComputeCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }, 0), + icBuild.withComputePlatformAMI("ami-uefi", 0), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-uefi": {BootMode: "uefi"}, + }, + expectErr: `^\Qcompute[0].platform.aws.type: Invalid value: "m5.xlarge": specified instance type in the specified region doesn't support amd-sev-snp\E$`, + }, + { + name: "valid instance type with no confidential compute settings", + installConfig: icBuild.build( + icBuild.withInstanceType("m5.xlarge", "m5.xlarge", "m5.xlarge"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + }, + { + name: "valid instance type with disabling confidential compute", + installConfig: icBuild.build( + icBuild.withInstanceType("m5.xlarge", "m5.xlarge", "m5.xlarge"), + icBuild.withControlPlaneCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicyDisabled), + }), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + }, + { + name: "invalid legacy-bios AMI with enabling SEV-SNP on default platform", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m6a.xlarge", "m6a.xlarge"), + icBuild.withDefaultMachinePlatformCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }), + icBuild.withDefaultMachinePlatformAMI("ami-legacy"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-legacy": {BootMode: "legacy-bios"}, + }, + expectErr: `^\[platform\.aws\.defaultMachinePlatform\.amiID: Invalid value: "ami-legacy": AMI boot mode must be 'uefi' or 'uefi-preferred' when using AMD SEV-SNP confidential computing, got 'legacy-bios', controlPlane\.platform\.aws\.amiID: Invalid value: "ami-legacy": AMI boot mode must be 'uefi' or 'uefi-preferred' when using AMD SEV-SNP confidential computing, got 'legacy-bios', compute\[0\]\.platform\.aws\.amiID: Invalid value: "ami-legacy": AMI boot mode must be 'uefi' or 'uefi-preferred' when using AMD SEV-SNP confidential computing, got 'legacy-bios'\]$`, + }, + { + name: "invalid legacy-bios AMI with enabling SEV-SNP on control plane", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m6a.xlarge", "m6a.xlarge"), + icBuild.withControlPlaneCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }), + icBuild.withControlPlanePlatformAMI("ami-legacy"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-legacy": {BootMode: "legacy-bios"}, + }, + expectErr: `^\QcontrolPlane.platform.aws.amiID: Invalid value: "ami-legacy": AMI boot mode must be 'uefi' or 'uefi-preferred' when using AMD SEV-SNP confidential computing, got 'legacy-bios'\E$`, + }, + { + name: "invalid legacy-bios AMI with enabling SEV-SNP on compute", + installConfig: icBuild.build( + icBuild.withInstanceType("m6a.xlarge", "m6a.xlarge", "m6a.xlarge"), + icBuild.withComputeCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicySEVSNP), + }, 0), + icBuild.withComputePlatformAMI("ami-legacy", 0), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-legacy": {BootMode: "legacy-bios"}, + }, + expectErr: `^\Qcompute[0].platform.aws.amiID: Invalid value: "ami-legacy": AMI boot mode must be 'uefi' or 'uefi-preferred' when using AMD SEV-SNP confidential computing, got 'legacy-bios'\E$`, + }, + { + name: "valid legacy-bios AMI with disabling confidential compute", + installConfig: icBuild.build( + icBuild.withInstanceType("m5.xlarge", "m5.xlarge", "m5.xlarge"), + icBuild.withControlPlaneCPUOptions(&aws.CPUOptions{ + ConfidentialCompute: ptr.To(aws.ConfidentialComputePolicyDisabled), + }), + icBuild.withControlPlanePlatformAMI("ami-legacy"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-legacy": {BootMode: "legacy-bios"}, + }, + }, + { + name: "valid legacy-bios AMI with no confidential compute settings", + installConfig: icBuild.build( + icBuild.withInstanceType("m5.xlarge", "m5.xlarge", "m5.xlarge"), + icBuild.withControlPlanePlatformAMI("ami-legacy"), + ), + availRegions: validAvailRegions(), + availZones: validAvailZones(), + instanceTypes: validInstanceTypes(), + images: map[string]ImageInfo{ + "ami-legacy": {BootMode: "legacy-bios"}, + }, + }, { name: "invalid edge pool, missing zones", installConfig: icBuild.build( @@ -1232,6 +1433,7 @@ func TestValidate(t *testing.T) { Tags: test.vpcTags, }, instanceTypes: test.instanceTypes, + images: test.images, ProvidedSubnets: test.installConfig.Platform.AWS.VPC.Subnets, } @@ -1252,10 +1454,8 @@ func TestValidate(t *testing.T) { err := Validate(context.TODO(), meta, test.installConfig) if test.expectErr == "" { assert.NoError(t, err) - } else { - if assert.Error(t, err) { - assert.Regexp(t, test.expectErr, err.Error()) - } + } else if assert.Error(t, err) { + assert.Regexp(t, test.expectErr, err.Error()) } }) } @@ -1383,10 +1583,8 @@ func TestValidateForProvisioning(t *testing.T) { err := ValidateForProvisioning(route53Client, ic, meta) if test.expectedErr == "" { assert.NoError(t, err) - } else { - if assert.Error(t, err) { - assert.Regexp(t, test.expectedErr, err.Error()) - } + } else if assert.Error(t, err) { + assert.Regexp(t, test.expectedErr, err.Error()) } }) } @@ -1426,7 +1624,6 @@ func TestGetSubDomainDNSRecords(t *testing.T) { route53Client := mock.NewMockAPI(mockCtrl) for _, test := range cases { - t.Run(test.name, func(t *testing.T) { ic := icBuild.build(icBuild.withBaseDomain(test.baseDomain)) if test.expectedErr != "" { @@ -1451,10 +1648,8 @@ func TestGetSubDomainDNSRecords(t *testing.T) { _, err := route53Client.GetSubDomainDNSRecords(&validDomainOutput, ic, nil) if test.expectedErr == "" { assert.NoError(t, err) - } else { - if assert.Error(t, err) { - assert.Regexp(t, test.expectedErr, err.Error()) - } + } else if assert.Error(t, err) { + assert.Regexp(t, test.expectedErr, err.Error()) } }) } @@ -1777,6 +1972,12 @@ func validInstanceTypes() map[string]InstanceType { MemInMiB: 16384, Arches: []string{ec2.ArchitectureTypeArm64}, }, + "m6a.xlarge": { + DefaultVCpus: 4, + MemInMiB: 16384, + Arches: []string{ec2.ArchitectureTypeX8664}, + Features: []string{"amd-sev-snp"}, + }, } } @@ -2025,3 +2226,33 @@ func (icBuild icBuildForAWS) withPublicIPv4Pool(publicIPv4Pool string) icOption ic.Platform.AWS.PublicIpv4Pool = publicIPv4Pool } } + +func (icBuild icBuildForAWS) withDefaultMachinePlatformCPUOptions(cpuOptions *aws.CPUOptions) icOption { + return func(ic *types.InstallConfig) { + if ic.Platform.AWS.DefaultMachinePlatform == nil { + icBuild.withDefaultPlatformMachine(aws.MachinePool{})(ic) + } + ic.Platform.AWS.DefaultMachinePlatform.CPUOptions = cpuOptions + } +} + +func (icBuild icBuildForAWS) withDefaultMachinePlatformAMI(amiID string) icOption { + return func(ic *types.InstallConfig) { + if ic.Platform.AWS.DefaultMachinePlatform == nil { + icBuild.withDefaultPlatformMachine(aws.MachinePool{})(ic) + } + ic.Platform.AWS.DefaultMachinePlatform.AMIID = amiID + } +} + +func (icBuild icBuildForAWS) withControlPlaneCPUOptions(cpuOptions *aws.CPUOptions) icOption { + return func(ic *types.InstallConfig) { + ic.ControlPlane.Platform.AWS.CPUOptions = cpuOptions + } +} + +func (icBuild icBuildForAWS) withComputeCPUOptions(cpuOptions *aws.CPUOptions, index int) icOption { + return func(ic *types.InstallConfig) { + ic.Compute[index].Platform.AWS.CPUOptions = cpuOptions + } +}