diff --git a/api/v1beta1/awscluster_conversion.go b/api/v1beta1/awscluster_conversion.go index 1ebb979848..0d64433380 100644 --- a/api/v1beta1/awscluster_conversion.go +++ b/api/v1beta1/awscluster_conversion.go @@ -63,6 +63,7 @@ func (src *AWSCluster) ConvertTo(dstRaw conversion.Hub) error { dst.Status.Bastion.NetworkInterfaceType = restored.Status.Bastion.NetworkInterfaceType dst.Status.Bastion.CapacityReservationID = restored.Status.Bastion.CapacityReservationID dst.Status.Bastion.MarketType = restored.Status.Bastion.MarketType + dst.Status.Bastion.CPUOptions = restored.Status.Bastion.CPUOptions } dst.Spec.Partition = restored.Spec.Partition diff --git a/api/v1beta1/awsmachine_conversion.go b/api/v1beta1/awsmachine_conversion.go index 5f44ccbecd..a909108d45 100644 --- a/api/v1beta1/awsmachine_conversion.go +++ b/api/v1beta1/awsmachine_conversion.go @@ -45,6 +45,7 @@ func (src *AWSMachine) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.CapacityReservationID = restored.Spec.CapacityReservationID dst.Spec.MarketType = restored.Spec.MarketType dst.Spec.NetworkInterfaceType = restored.Spec.NetworkInterfaceType + dst.Spec.CPUOptions = restored.Spec.CPUOptions if restored.Spec.ElasticIPPool != nil { if dst.Spec.ElasticIPPool == nil { dst.Spec.ElasticIPPool = &infrav1.ElasticIPPool{} @@ -109,6 +110,7 @@ func (r *AWSMachineTemplate) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.Template.Spec.CapacityReservationID = restored.Spec.Template.Spec.CapacityReservationID dst.Spec.Template.Spec.MarketType = restored.Spec.Template.Spec.MarketType dst.Spec.Template.Spec.NetworkInterfaceType = restored.Spec.Template.Spec.NetworkInterfaceType + dst.Spec.Template.Spec.CPUOptions = restored.Spec.Template.Spec.CPUOptions if restored.Spec.Template.Spec.ElasticIPPool != nil { if dst.Spec.Template.Spec.ElasticIPPool == nil { dst.Spec.Template.Spec.ElasticIPPool = &infrav1.ElasticIPPool{} diff --git a/api/v1beta1/zz_generated.conversion.go b/api/v1beta1/zz_generated.conversion.go index 8e445247da..a3c743d883 100644 --- a/api/v1beta1/zz_generated.conversion.go +++ b/api/v1beta1/zz_generated.conversion.go @@ -1399,6 +1399,7 @@ func autoConvert_v1beta2_AWSMachineSpec_To_v1beta1_AWSMachineSpec(in *v1beta2.AW out.ImageLookupOrg = in.ImageLookupOrg out.ImageLookupBaseOS = in.ImageLookupBaseOS out.InstanceType = in.InstanceType + // WARNING: in.CPUOptions requires manual conversion: does not exist in peer-type out.AdditionalTags = *(*Tags)(unsafe.Pointer(&in.AdditionalTags)) out.IAMInstanceProfile = in.IAMInstanceProfile out.PublicIP = (*bool)(unsafe.Pointer(in.PublicIP)) @@ -2030,6 +2031,7 @@ func autoConvert_v1beta2_Instance_To_v1beta1_Instance(in *v1beta2.Instance, out out.ID = in.ID out.State = InstanceState(in.State) out.Type = in.Type + // WARNING: in.CPUOptions requires manual conversion: does not exist in peer-type out.SubnetID = in.SubnetID out.ImageID = in.ImageID out.SSHKeyName = (*string)(unsafe.Pointer(in.SSHKeyName)) diff --git a/api/v1beta2/awsmachine_types.go b/api/v1beta2/awsmachine_types.go index 43ac3da2d1..d107707cee 100644 --- a/api/v1beta2/awsmachine_types.go +++ b/api/v1beta2/awsmachine_types.go @@ -73,6 +73,33 @@ const ( NetworkInterfaceTypeEFAWithENAInterface NetworkInterfaceType = NetworkInterfaceType("efa") ) +// AmdSevSnpSpecification defines the different values for AmdSevSnp +type AmdSevSnpSpecification string + +const ( + // AmdSevSnpSpecificationEnabled means AMD SEV SNP is enabled for the instance. + AmdSevSnpSpecificationEnabled AmdSevSnpSpecification = "enabled" + + // AmdSevSnpSpecificationDisabled means AMD SEV SNP is disabled for the instance. + AmdSevSnpSpecificationDisabled AmdSevSnpSpecification = "disabled" +) + +// CPUOptions defines the cpu options for the instance. +type CPUOptions struct { + // amdSevSnp specifies AMD SEV-SNP for the instance. + // +kubebuilder:validation:Enum=enabled;disabled + // +optional + AmdSevSnp AmdSevSnpSpecification `json:"amdSevSnp,omitempty"` +} + +// Confidential computing support depends on the instance type. +// Only certain instance types in M6a, R6a and C6a series support AMD SEV-SNP. Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sev-snp.html +var ( + instanceTypesSupportingAmdSevsnp = []string{"m6a.large", "m6a.xlarge", "m6a.2xlarge", "m6a.4xlarge", "m6a.8xlarge", + "c6a.large", "c6a.xlarge", "c6a.2xlarge", "c6a.4xlarge", "c6a.8xlarge", "c6a.12xlarge", "c6a.16xlarge", + "r6a.large", "r6a.xlarge", "r6a.2xlarge", "r6a.4xlarge"} +) + // AWSMachineSpec defines the desired state of an Amazon EC2 instance. // +kubebuilder:validation:XValidation:rule="!has(self.capacityReservationId) || !has(self.marketType) || self.marketType != 'Spot'",message="capacityReservationId may not be set when marketType is Spot" // +kubebuilder:validation:XValidation:rule="!has(self.capacityReservationId) || !has(self.spotMarketOptions)",message="capacityReservationId cannot be set when spotMarketOptions is specified" @@ -116,6 +143,10 @@ type AWSMachineSpec struct { // +kubebuilder:validation:MinLength:=2 InstanceType string `json:"instanceType"` + // cpuOptions is the set of cpu options for the instance + // +optional + CPUOptions *CPUOptions `json:"cpuOptions,omitempty"` + // AdditionalTags is an optional set of tags to add to an instance, in addition to the ones added by default by the // AWS provider. If both the AWSCluster and the AWSMachine specify the same tag name with different values, the // AWSMachine's value takes precedence. diff --git a/api/v1beta2/awsmachine_webhook.go b/api/v1beta2/awsmachine_webhook.go index 5a119de8db..4941886aad 100644 --- a/api/v1beta2/awsmachine_webhook.go +++ b/api/v1beta2/awsmachine_webhook.go @@ -31,6 +31,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" + "k8s.io/utils/strings/slices" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -78,6 +79,7 @@ func (*awsMachineWebhook) ValidateCreate(_ context.Context, obj runtime.Object) allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) allErrs = append(allErrs, r.validateNetworkElasticIPPool()...) allErrs = append(allErrs, r.validateInstanceMarketType()...) + allErrs = append(allErrs, r.validateInstanceTypeForConfidentialCompute()...) return nil, aggregateObjErrors(r.GroupVersionKind().GroupKind(), r.Name, allErrs) } @@ -417,6 +419,17 @@ func (r *AWSMachine) validateNonRootVolumes() field.ErrorList { return allErrs } +func (r *AWSMachine) validateInstanceTypeForConfidentialCompute() field.ErrorList { + var allErrs field.ErrorList + if r.Spec.CPUOptions != nil { + if r.Spec.CPUOptions.AmdSevSnp == "enabled" && !slices.Contains(instanceTypesSupportingAmdSevsnp, r.Spec.InstanceType) { + allErrs = append(allErrs, field.Required(field.NewPath("spec.InstanceType"), "this instance type doesn't support AMD SEV-SNP")) + } + } + + return allErrs +} + // ValidateDelete implements webhook.Validator so a webhook will be registered for the type. func (*awsMachineWebhook) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { return nil, nil diff --git a/api/v1beta2/awsmachine_webhook_test.go b/api/v1beta2/awsmachine_webhook_test.go index 8dc7ce96e1..5a868bc29e 100644 --- a/api/v1beta2/awsmachine_webhook_test.go +++ b/api/v1beta2/awsmachine_webhook_test.go @@ -279,6 +279,30 @@ func TestAWSMachineCreate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid instance type for AMD SEV-SNP", + machine: &AWSMachine{ + Spec: AWSMachineSpec{ + InstanceType: "test", + CPUOptions: &CPUOptions{ + AmdSevSnp: "enabled", + }, + }, + }, + wantErr: true, + }, + { + name: "valid instance type for AMD SEV-SNP", + machine: &AWSMachine{ + Spec: AWSMachineSpec{ + InstanceType: "m6a.large", + CPUOptions: &CPUOptions{ + AmdSevSnp: "enabled", + }, + }, + }, + wantErr: false, + }, { name: "invalid tags return error", machine: &AWSMachine{ diff --git a/api/v1beta2/types.go b/api/v1beta2/types.go index 9c3c2771c9..128d5fad69 100644 --- a/api/v1beta2/types.go +++ b/api/v1beta2/types.go @@ -172,6 +172,9 @@ type Instance struct { // The instance type. Type string `json:"type,omitempty"` + // The cpu options of the instance. + CPUOptions *CPUOptions `json:"cpuOptions,omitempty"` + // The ID of the subnet of the instance. SubnetID string `json:"subnetId,omitempty"` diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 7d39649cfa..c6c0e24d13 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -687,6 +687,11 @@ func (in *AWSMachineSpec) DeepCopyInto(out *AWSMachineSpec) { **out = **in } in.AMI.DeepCopyInto(&out.AMI) + if in.CPUOptions != nil { + in, out := &in.CPUOptions, &out.CPUOptions + *out = new(CPUOptions) + **out = **in + } if in.AdditionalTags != nil { in, out := &in.AdditionalTags, &out.AdditionalTags *out = make(Tags, len(*in)) @@ -1237,6 +1242,21 @@ func (in *CNISpec) DeepCopy() *CNISpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CPUOptions) DeepCopyInto(out *CPUOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CPUOptions. +func (in *CPUOptions) DeepCopy() *CPUOptions { + if in == nil { + return nil + } + out := new(CPUOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClassicELBAttributes) DeepCopyInto(out *ClassicELBAttributes) { *out = *in @@ -1516,6 +1536,11 @@ func (in IngressRules) DeepCopy() IngressRules { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Instance) DeepCopyInto(out *Instance) { *out = *in + if in.CPUOptions != nil { + in, out := &in.CPUOptions, &out.CPUOptions + *out = new(CPUOptions) + **out = **in + } if in.SSHKeyName != nil { in, out := &in.SSHKeyName, &out.SSHKeyName *out = new(string) diff --git a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml index 29cc567267..4ce81952de 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml @@ -1214,6 +1214,16 @@ spec: description: CapacityReservationID specifies the target Capacity Reservation into which the instance should be launched. type: string + cpuOptions: + description: The cpu options of the instance. + properties: + amdSevSnp: + description: amdSevSnp specifies AMD SEV-SNP for the instance. + enum: + - enabled + - disabled + type: string + type: object ebsOptimized: description: Indicates whether the instance is optimized for Amazon EBS I/O. @@ -3395,6 +3405,16 @@ spec: description: CapacityReservationID specifies the target Capacity Reservation into which the instance should be launched. type: string + cpuOptions: + description: The cpu options of the instance. + properties: + amdSevSnp: + description: amdSevSnp specifies AMD SEV-SNP for the instance. + enum: + - enabled + - disabled + type: string + type: object ebsOptimized: description: Indicates whether the instance is optimized for Amazon EBS I/O. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml index 7ff40608ac..ecb87cfc8e 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml @@ -2197,6 +2197,16 @@ spec: description: CapacityReservationID specifies the target Capacity Reservation into which the instance should be launched. type: string + cpuOptions: + description: The cpu options of the instance. + properties: + amdSevSnp: + description: amdSevSnp specifies AMD SEV-SNP for the instance. + enum: + - enabled + - disabled + type: string + type: object ebsOptimized: description: Indicates whether the instance is optimized for Amazon EBS I/O. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml index d128539e11..6c41f92209 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml @@ -674,6 +674,16 @@ spec: - ssm-parameter-store type: string type: object + cpuOptions: + description: cpuOptions is the set of cpu options for the instance + properties: + amdSevSnp: + description: amdSevSnp specifies AMD SEV-SNP for the instance. + enum: + - enabled + - disabled + type: string + type: object elasticIpPool: description: ElasticIPPool is the configuration to allocate Public IPv4 address (Elastic IP/EIP) from user-defined pool. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml index c95fbb3674..394871bc8d 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml @@ -593,6 +593,17 @@ spec: - ssm-parameter-store type: string type: object + cpuOptions: + description: cpuOptions is the set of cpu options for the + instance + properties: + amdSevSnp: + description: amdSevSnp specifies AMD SEV-SNP for the instance. + enum: + - enabled + - disabled + type: string + type: object elasticIpPool: description: ElasticIPPool is the configuration to allocate Public IPv4 address (Elastic IP/EIP) from user-defined pool. diff --git a/pkg/cloud/services/ec2/instances.go b/pkg/cloud/services/ec2/instances.go index 8245f9a61b..c53954ffac 100644 --- a/pkg/cloud/services/ec2/instances.go +++ b/pkg/cloud/services/ec2/instances.go @@ -258,6 +258,8 @@ func (s *Service) CreateInstance(ctx context.Context, scope *scope.MachineScope, input.MarketType = scope.AWSMachine.Spec.MarketType + input.CPUOptions = scope.AWSMachine.Spec.CPUOptions + s.scope.Debug("Running instance", "machine-role", scope.Role()) s.scope.Debug("Running instance with instance metadata options", "metadata options", input.InstanceMetadataOptions) out, err := s.runInstance(scope.Role(), input) @@ -656,6 +658,7 @@ func (s *Service) runInstance(role string, i *infrav1.Instance) (*infrav1.Instan input.MetadataOptions = getInstanceMetadataOptionsRequest(i.InstanceMetadataOptions) input.PrivateDnsNameOptions = getPrivateDNSNameOptionsRequest(i.PrivateDNSName) input.CapacityReservationSpecification = getCapacityReservationSpecification(i.CapacityReservationID) + input.CpuOptions = getInstanceCPUOptionsRequest(i.CPUOptions) if i.Tenancy != "" { input.Placement = &types.Placement{ @@ -1251,3 +1254,24 @@ func getPrivateDNSNameOptionsRequest(privateDNSName *infrav1.PrivateDNSName) *ty HostnameType: types.HostnameType(aws.ToString(privateDNSName.HostnameType)), } } + +func getInstanceCPUOptionsRequest(cpuOptions *infrav1.CPUOptions) *types.CpuOptionsRequest { + if cpuOptions == nil { + return nil + } + + request := &types.CpuOptionsRequest{} + switch cpuOptions.AmdSevSnp { + case infrav1.AmdSevSnpSpecificationEnabled: + request.AmdSevSnp = types.AmdSevSnpSpecificationEnabled + case infrav1.AmdSevSnpSpecificationDisabled: + request.AmdSevSnp = types.AmdSevSnpSpecificationDisabled + default: + } + + if *request == (types.CpuOptionsRequest{}) { + return nil + } + + return request +} diff --git a/pkg/cloud/services/ec2/instances_test.go b/pkg/cloud/services/ec2/instances_test.go index dfc080d5c0..a2d27dc455 100644 --- a/pkg/cloud/services/ec2/instances_test.go +++ b/pkg/cloud/services/ec2/instances_test.go @@ -5653,6 +5653,130 @@ func TestCreateInstance(t *testing.T) { } }, }, + { + name: "with AMD SEV-SNP enabled", + machine: &clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"set": "node"}, + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To[string]("bootstrap-data"), + }, + }, + }, + machineConfig: &infrav1.AWSMachineSpec{ + AMI: infrav1.AMIReference{ + ID: aws.String("abc"), + }, + InstanceType: "m6a.large", + CPUOptions: &infrav1.CPUOptions{ + AmdSevSnp: "enabled", + }, + }, + awsCluster: &infrav1.AWSCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: infrav1.AWSClusterSpec{ + NetworkSpec: infrav1.NetworkSpec{ + Subnets: infrav1.Subnets{ + infrav1.SubnetSpec{ + ID: "subnet-1", + IsPublic: false, + }, + infrav1.SubnetSpec{ + IsPublic: false, + }, + }, + VPC: infrav1.VPCSpec{ + ID: "vpc-test", + }, + }, + }, + Status: infrav1.AWSClusterStatus{ + Network: infrav1.NetworkStatus{ + SecurityGroups: map[infrav1.SecurityGroupRole]infrav1.SecurityGroup{ + infrav1.SecurityGroupControlPlane: { + ID: "1", + }, + infrav1.SecurityGroupNode: { + ID: "2", + }, + infrav1.SecurityGroupLB: { + ID: "3", + }, + }, + APIServerELB: infrav1.LoadBalancer{ + DNSName: "test-apiserver.us-east-1.aws", + }, + }, + }, + }, + expect: func(m *mocks.MockEC2APIMockRecorder) { + m. + DescribeInstanceTypes(context.TODO(), gomock.Eq(&ec2.DescribeInstanceTypesInput{ + InstanceTypes: []types.InstanceType{ + types.InstanceTypeM6aLarge, + }, + })). + Return(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, + }, + }, nil) + m. // TODO: Restore these parameters, but with the tags as well + RunInstances(context.TODO(), gomock.Any()). + DoAndReturn(func(ctx context.Context, input *ec2.RunInstancesInput, requestOptions ...request.Option) (*ec2.RunInstancesOutput, error) { + if input.CpuOptions == nil || input.CpuOptions.AmdSevSnp != types.AmdSevSnpSpecificationEnabled { + t.Fatalf("expected AMD SEV-SNP to be enabled, but got %+v", input.CpuOptions) + } + return &ec2.RunInstancesOutput{ + Instances: []types.Instance{ + { + State: &types.InstanceState{ + Name: types.InstanceStateNamePending, + }, + IamInstanceProfile: &types.IamInstanceProfile{ + Arn: aws.String("arn:aws:iam::123456789012:instance-profile/foo"), + }, + InstanceId: aws.String("two"), + InstanceType: types.InstanceTypeM5Large, + SubnetId: aws.String("subnet-1"), + ImageId: aws.String("ami-1"), + RootDeviceName: aws.String("device-1"), + BlockDeviceMappings: []types.InstanceBlockDeviceMapping{ + { + DeviceName: aws.String("device-1"), + Ebs: &types.EbsInstanceBlockDevice{ + VolumeId: aws.String("volume-1"), + }, + }, + }, + Placement: &types.Placement{ + AvailabilityZone: &az, + }, + }, + }, + }, nil + }) + m. + DescribeNetworkInterfaces(context.TODO(), gomock.Any()). + Return(&ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: []types.NetworkInterface{}, + NextToken: nil, + }, nil) + }, + check: func(instance *infrav1.Instance, err error) { + if err != nil { + t.Fatalf("did not expect error: %v", err) + } + }, + }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) {