diff --git a/api/v1alpha1/aws_node_types.go b/api/v1alpha1/aws_node_types.go index 03774adc5..30348478a 100644 --- a/api/v1alpha1/aws_node_types.go +++ b/api/v1alpha1/aws_node_types.go @@ -3,6 +3,10 @@ package v1alpha1 +import ( + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" +) + type AWSControlPlaneNodeSpec struct { // The IAM instance profile to use for the cluster Machines. // +kubebuilder:validation:Optional @@ -50,6 +54,10 @@ type AWSGenericNodeSpec struct { // PlacementGroup specifies the placement group in which to launch the instance. // +kubebuilder:validation:Optional PlacementGroup *PlacementGroup `json:"placementGroup,omitempty"` + + // Configuration options for the root and additional storage volume. + // +kubebuilder:validation:Optional + Volumes *AWSVolumes `json:"volumes,omitempty"` } // +kubebuilder:validation:MaxItems=32 @@ -105,3 +113,47 @@ type AMILookup struct { // +kubebuilder:validation:MaxLength=32 BaseOS string `json:"baseOS,omitempty"` } + +type AWSVolumes struct { + // Configuration options for the root storage volume. + // +kubebuilder:validation:Optional + Root *AWSVolume `json:"root,omitempty"` + + // Configuration options for non-root storage volumes. + // +kubebuilder:validation:Optional + NonRoot []AWSVolume `json:"nonroot,omitempty"` +} + +type AWSVolume struct { + // Device name + // +kubebuilder:validation:Optional + DeviceName string `json:"deviceName,omitempty"` + + // Size specifies size (in Gi) of the storage device. + // Must be greater than the image snapshot size or 8 (whichever is greater). + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Minimum=8 + Size int64 `json:"size,omitempty"` + + // Type is the type of the volume (e.g. gp2, io1, etc...). + // +kubebuilder:validation:Optional + Type capav1.VolumeType `json:"type,omitempty"` + + // IOPS is the number of IOPS requested for the disk. Not applicable to all types. + // +kubebuilder:validation:Optional + IOPS int64 `json:"iops,omitempty"` + + // Throughput to provision in MiB/s supported for the volume type. Not applicable to all types. + // +kubebuilder:validation:Optional + Throughput int64 `json:"throughput,omitempty"` + + // Encrypted is whether the volume should be encrypted or not. + // +kubebuilder:validation:Optional + Encrypted bool `json:"encrypted,omitempty"` + + // EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + // If Encrypted is set and this is omitted, the default AWS key will be used. + // The key must already exist and be accessible by the controller. + // +kubebuilder:validation:Optional + EncryptionKey string `json:"encryptionKey,omitempty"` +} diff --git a/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml index 963f15199..2d76c1361 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml @@ -424,6 +424,80 @@ spec: required: - name type: object + volumes: + description: Configuration options for the root and additional storage volume. + properties: + nonroot: + description: Configuration options for non-root storage volumes. + items: + properties: + deviceName: + description: Device name + type: string + encrypted: + description: Encrypted is whether the volume should be encrypted or not. + type: boolean + encryptionKey: + description: |- + EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + If Encrypted is set and this is omitted, the default AWS key will be used. + The key must already exist and be accessible by the controller. + type: string + iops: + description: IOPS is the number of IOPS requested for the disk. Not applicable to all types. + format: int64 + type: integer + size: + description: |- + Size specifies size (in Gi) of the storage device. + Must be greater than the image snapshot size or 8 (whichever is greater). + format: int64 + minimum: 8 + type: integer + throughput: + description: Throughput to provision in MiB/s supported for the volume type. Not applicable to all types. + format: int64 + type: integer + type: + description: Type is the type of the volume (e.g. gp2, io1, etc...). + type: string + type: object + type: array + root: + description: Configuration options for the root storage volume. + properties: + deviceName: + description: Device name + type: string + encrypted: + description: Encrypted is whether the volume should be encrypted or not. + type: boolean + encryptionKey: + description: |- + EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + If Encrypted is set and this is omitted, the default AWS key will be used. + The key must already exist and be accessible by the controller. + type: string + iops: + description: IOPS is the number of IOPS requested for the disk. Not applicable to all types. + format: int64 + type: integer + size: + description: |- + Size specifies size (in Gi) of the storage device. + Must be greater than the image snapshot size or 8 (whichever is greater). + format: int64 + minimum: 8 + type: integer + throughput: + description: Throughput to provision in MiB/s supported for the volume type. Not applicable to all types. + format: int64 + type: integer + type: + description: Type is the type of the volume (e.g. gp2, io1, etc...). + type: string + type: object + type: object type: object nodeRegistration: default: {} diff --git a/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml index bd71a432e..f216f1a90 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml @@ -117,6 +117,89 @@ spec: required: - name type: object + volumes: + description: Configuration options for the root and additional + storage volume. + properties: + nonroot: + description: Configuration options for non-root storage volumes. + items: + properties: + deviceName: + description: Device name + type: string + encrypted: + description: Encrypted is whether the volume should + be encrypted or not. + type: boolean + encryptionKey: + description: |- + EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + If Encrypted is set and this is omitted, the default AWS key will be used. + The key must already exist and be accessible by the controller. + type: string + iops: + description: IOPS is the number of IOPS requested for + the disk. Not applicable to all types. + format: int64 + type: integer + size: + description: |- + Size specifies size (in Gi) of the storage device. + Must be greater than the image snapshot size or 8 (whichever is greater). + format: int64 + minimum: 8 + type: integer + throughput: + description: Throughput to provision in MiB/s supported + for the volume type. Not applicable to all types. + format: int64 + type: integer + type: + description: Type is the type of the volume (e.g. gp2, + io1, etc...). + type: string + type: object + type: array + root: + description: Configuration options for the root storage volume. + properties: + deviceName: + description: Device name + type: string + encrypted: + description: Encrypted is whether the volume should be + encrypted or not. + type: boolean + encryptionKey: + description: |- + EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + If Encrypted is set and this is omitted, the default AWS key will be used. + The key must already exist and be accessible by the controller. + type: string + iops: + description: IOPS is the number of IOPS requested for + the disk. Not applicable to all types. + format: int64 + type: integer + size: + description: |- + Size specifies size (in Gi) of the storage device. + Must be greater than the image snapshot size or 8 (whichever is greater). + format: int64 + minimum: 8 + type: integer + throughput: + description: Throughput to provision in MiB/s supported + for the volume type. Not applicable to all types. + format: int64 + type: integer + type: + description: Type is the type of the volume (e.g. gp2, + io1, etc...). + type: string + type: object + type: object type: object nodeRegistration: default: {} diff --git a/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml index bb1e0306a..7040ffd9a 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml @@ -117,6 +117,89 @@ spec: required: - name type: object + volumes: + description: Configuration options for the root and additional + storage volume. + properties: + nonroot: + description: Configuration options for non-root storage volumes. + items: + properties: + deviceName: + description: Device name + type: string + encrypted: + description: Encrypted is whether the volume should + be encrypted or not. + type: boolean + encryptionKey: + description: |- + EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + If Encrypted is set and this is omitted, the default AWS key will be used. + The key must already exist and be accessible by the controller. + type: string + iops: + description: IOPS is the number of IOPS requested for + the disk. Not applicable to all types. + format: int64 + type: integer + size: + description: |- + Size specifies size (in Gi) of the storage device. + Must be greater than the image snapshot size or 8 (whichever is greater). + format: int64 + minimum: 8 + type: integer + throughput: + description: Throughput to provision in MiB/s supported + for the volume type. Not applicable to all types. + format: int64 + type: integer + type: + description: Type is the type of the volume (e.g. gp2, + io1, etc...). + type: string + type: object + type: array + root: + description: Configuration options for the root storage volume. + properties: + deviceName: + description: Device name + type: string + encrypted: + description: Encrypted is whether the volume should be + encrypted or not. + type: boolean + encryptionKey: + description: |- + EncryptionKey is the KMS key to use to encrypt the volume. Can be either a KMS key ID or ARN. + If Encrypted is set and this is omitted, the default AWS key will be used. + The key must already exist and be accessible by the controller. + type: string + iops: + description: IOPS is the number of IOPS requested for + the disk. Not applicable to all types. + format: int64 + type: integer + size: + description: |- + Size specifies size (in Gi) of the storage device. + Must be greater than the image snapshot size or 8 (whichever is greater). + format: int64 + minimum: 8 + type: integer + throughput: + description: Throughput to provision in MiB/s supported + for the volume type. Not applicable to all types. + format: int64 + type: integer + type: + description: Type is the type of the volume (e.g. gp2, + io1, etc...). + type: string + type: object + type: object type: object taints: description: Taints specifies the taints the Node API object should diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b5dfcbe9e..890c581ea 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -243,6 +243,11 @@ func (in *AWSGenericNodeSpec) DeepCopyInto(out *AWSGenericNodeSpec) { *out = new(PlacementGroup) **out = **in } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(AWSVolumes) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSGenericNodeSpec. @@ -330,6 +335,46 @@ func (in *AWSSpec) DeepCopy() *AWSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AWSVolume) DeepCopyInto(out *AWSVolume) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSVolume. +func (in *AWSVolume) DeepCopy() *AWSVolume { + if in == nil { + return nil + } + out := new(AWSVolume) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AWSVolumes) DeepCopyInto(out *AWSVolumes) { + *out = *in + if in.Root != nil { + in, out := &in.Root, &out.Root + *out = new(AWSVolume) + **out = **in + } + if in.NonRoot != nil { + in, out := &in.NonRoot, &out.NonRoot + *out = make([]AWSVolume, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSVolumes. +func (in *AWSVolumes) DeepCopy() *AWSVolumes { + if in == nil { + return nil + } + out := new(AWSVolumes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSWorkerNodeConfig) DeepCopyInto(out *AWSWorkerNodeConfig) { *out = *in diff --git a/docs/content/customization/aws/volumes.md b/docs/content/customization/aws/volumes.md new file mode 100644 index 000000000..466bb4522 --- /dev/null +++ b/docs/content/customization/aws/volumes.md @@ -0,0 +1,284 @@ ++++ +title = "AWS Volumes Configuration" ++++ + +The AWS volumes customization allows the user to specify configuration for both root and non-root storage volumes for AWS machines. +The volumes customization can be applied to both control plane and worker machines. +This customization will be available when the +[provider-specific cluster configuration patch]({{< ref "..">}}) is included in the `ClusterClass`. + +## Configuration Options + +The volumes configuration supports two types of volumes: + +- **Root Volume**: The primary storage volume for the instance (typically `/dev/sda1`) +- **Non-Root Volumes**: Additional storage volumes that can be attached to the instance + +### Volume Configuration Fields + +Each volume can be configured with the following fields: + +| Field | Type | Required | Description | Default | +|-------|------|----------|-------------|---------| +| `deviceName` | string | No | Device name for the volume (e.g., `/dev/sda1`, `/dev/sdf`) | - | +| `size` | int64 | No | Size in GiB (minimum 8) | Based on AMI, usually 20GiB | +| `type` | string | No | EBS volume type (`gp2`, `gp3`, `io1`, `io2`) | - | +| `iops` | int64 | No | IOPS for provisioned volumes (io1, io2, gp3) | - | +| `throughput` | int64 | No | Throughput in MiB/s (gp3 only) | - | +| `encrypted` | bool | No | Whether the volume should be encrypted | false | +| `encryptionKey` | string | No | KMS key ID or ARN for encryption | AWS default key | + +### Supported Volume Types + +- **gp2**: General Purpose SSD (up to 16,000 IOPS) +- **gp3**: General Purpose SSD with configurable IOPS and throughput +- **io1**: Provisioned IOPS SSD (up to 64,000 IOPS) +- **io2**: Provisioned IOPS SSD with higher durability (up to 64,000 IOPS) + +## Examples + +### Root Volume Only + +To specify only a root volume configuration: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: +spec: + topology: + variables: + - name: clusterConfig + value: + controlPlane: + aws: + volumes: + root: + deviceName: "/dev/sda1" + size: 100 + type: "gp3" + iops: 3000 + throughput: 125 + encrypted: true + encryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012" + - name: workerConfig + value: + aws: + volumes: + root: + size: 200 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true +``` + +### Non-Root Volumes Only + +To specify only additional non-root volumes: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: +spec: + topology: + variables: + - name: clusterConfig + value: + controlPlane: + aws: + volumes: + nonroot: + - deviceName: "/dev/sdf" + size: 500 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true + - deviceName: "/dev/sdg" + size: 1000 + type: "gp2" + encrypted: false + - name: workerConfig + value: + aws: + volumes: + nonroot: + - deviceName: "/dev/sdf" + size: 200 + type: "io1" + iops: 10000 + encrypted: true +``` + +### Both Root and Non-Root Volumes + +To specify both root and non-root volumes: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: +spec: + topology: + variables: + - name: clusterConfig + value: + controlPlane: + aws: + volumes: + root: + size: 100 + type: "gp3" + iops: 3000 + throughput: 125 + encrypted: true + nonroot: + - deviceName: "/dev/sdf" + size: 500 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true + - deviceName: "/dev/sdg" + size: 1000 + type: "gp2" + encrypted: false + - name: workerConfig + value: + aws: + volumes: + root: + size: 200 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true + nonroot: + - deviceName: "/dev/sdf" + size: 100 + type: "io1" + iops: 10000 + encrypted: true +``` + +### MachineDeployment Overrides + +You can customize individual MachineDeployments by using the overrides field: + +```yaml +spec: + topology: + # ... + workers: + machineDeployments: + - class: default-worker + name: md-0 + variables: + overrides: + - name: workerConfig + value: + aws: + volumes: + root: + size: 500 + type: "gp3" + iops: 10000 + throughput: 500 + encrypted: true + nonroot: + - deviceName: "/dev/sdf" + size: 1000 + type: "io2" + iops: 20000 + encrypted: true +``` + +## Resulting CAPA Configuration + +Applying the volumes configuration will result in the following values being set in the `AWSMachineTemplate`: + +### Root Volume Configuration + +When a root volume is specified, it will be set in the `rootVolume` field: + +```yaml +spec: + template: + spec: + rootVolume: + deviceName: "/dev/sda1" + size: 100 + type: "gp3" + iops: 3000 + throughput: 125 + encrypted: true + encryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012" +``` + +### Non-Root Volumes Configuration + +When non-root volumes are specified, they will be set in the `nonRootVolumes` field: + +```yaml +spec: + template: + spec: + nonRootVolumes: + - deviceName: "/dev/sdf" + size: 500 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true + - deviceName: "/dev/sdg" + size: 1000 + type: "gp2" + encrypted: false +``` + +## EKS Configuration + +For EKS clusters, the volumes configuration follows the same structure but is specified under the EKS worker configuration: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: +spec: + topology: + variables: + - name: workerConfig + value: + eks: + volumes: + root: + size: 200 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true + nonroot: + - deviceName: "/dev/sdf" + size: 500 + type: "gp3" + iops: 4000 + throughput: 250 + encrypted: true +``` + +## Best Practices + +1. **Root Volume**: Always specify a root volume for consistent boot disk configuration +2. **Encryption**: Enable encryption for sensitive workloads using either AWS default keys or customer-managed KMS keys +3. **IOPS and Throughput**: Use gp3 volumes for better price/performance ratio with configurable IOPS and throughput +4. **Device Names**: Use standard device naming conventions (`/dev/sda1` for root, `/dev/sdf` onwards for additional volumes) +5. **Size Planning**: Consider future growth when sizing volumes, as resizing EBS volumes requires downtime +6. **Volume Types**: Choose appropriate volume types based on workload requirements: + - **gp2/gp3**: General purpose workloads + - **io1/io2**: High-performance database workloads requiring consistent IOPS diff --git a/pkg/handlers/aws/mutation/metapatch_handler.go b/pkg/handlers/aws/mutation/metapatch_handler.go index c2991664a..43e6599e4 100644 --- a/pkg/handlers/aws/mutation/metapatch_handler.go +++ b/pkg/handlers/aws/mutation/metapatch_handler.go @@ -17,6 +17,7 @@ import ( "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/mutation/placementgroup" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/mutation/region" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/mutation/securitygroups" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/mutation/volumes" genericmutation "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation" ) @@ -31,6 +32,7 @@ func MetaPatchHandler(mgr manager.Manager) handlers.Named { instancetype.NewControlPlanePatch(), ami.NewControlPlanePatch(), securitygroups.NewControlPlanePatch(), + volumes.NewControlPlanePatch(), placementgroup.NewControlPlanePatch(), } patchHandlers = append(patchHandlers, genericmutation.MetaMutators(mgr)...) @@ -50,6 +52,7 @@ func MetaWorkerPatchHandler(mgr manager.Manager) handlers.Named { instancetype.NewWorkerPatch(), ami.NewWorkerPatch(), securitygroups.NewWorkerPatch(), + volumes.NewWorkerPatch(), placementgroup.NewWorkerPatch(), } patchHandlers = append(patchHandlers, genericmutation.WorkerMetaMutators()...) diff --git a/pkg/handlers/aws/mutation/volumes/inject.go b/pkg/handlers/aws/mutation/volumes/inject.go new file mode 100644 index 000000000..8f64ae9a3 --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/inject.go @@ -0,0 +1,133 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/patches" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/variables" +) + +const ( + // VariableName is the external patch variable name. + VariableName = "volumes" +) + +type awsVolumesSpecPatchHandler struct { + metaVariableName string + variableFieldPath []string + patchSelector clusterv1.PatchSelector +} + +func NewAWSVolumesSpecPatchHandler( + metaVariableName string, + variableFieldPath []string, + patchSelector clusterv1.PatchSelector, +) *awsVolumesSpecPatchHandler { + return &awsVolumesSpecPatchHandler{ + metaVariableName: metaVariableName, + variableFieldPath: variableFieldPath, + patchSelector: patchSelector, + } +} + +func (h *awsVolumesSpecPatchHandler) Mutate( + ctx context.Context, + obj *unstructured.Unstructured, + vars map[string]apiextensionsv1.JSON, + holderRef runtimehooksv1.HolderReference, + _ client.ObjectKey, + _ mutation.ClusterGetter, +) error { + log := ctrl.LoggerFrom(ctx).WithValues( + "holderRef", holderRef, + ) + volumesVar, err := variables.Get[v1alpha1.AWSVolumes]( + vars, + h.metaVariableName, + h.variableFieldPath..., + ) + if err != nil { + if variables.IsNotFoundError(err) { + log.V(5). + Info("No volumes configuration provided. Skipping.") + return nil + } + return err + } + + log = log.WithValues( + "variableName", + h.metaVariableName, + "variableFieldPath", + h.variableFieldPath, + "variableValue", + volumesVar, + ) + + return patches.MutateIfApplicable( + obj, + vars, + &holderRef, + h.patchSelector, + log, + func(obj *capav1.AWSMachineTemplate) error { + log.WithValues( + "patchedObjectKind", obj.GetObjectKind().GroupVersionKind().String(), + "patchedObjectName", client.ObjectKeyFromObject(obj), + ).Info("setting volumes configuration") + + // Handle root volume + if volumesVar.Root != nil { + rootVolume := h.toCAPAVolume(volumesVar.Root) + obj.Spec.Template.Spec.RootVolume = rootVolume + } + + // Handle non-root volumes + if len(volumesVar.NonRoot) > 0 { + nonRootVolumes := make([]capav1.Volume, 0, len(volumesVar.NonRoot)) + for n := range volumesVar.NonRoot { + vol := &volumesVar.NonRoot[n] + nonRootVolumes = append(nonRootVolumes, *h.toCAPAVolume(vol)) + } + obj.Spec.Template.Spec.NonRootVolumes = nonRootVolumes + } + + return nil + }, + ) +} + +// toCAPAVolume converts v1alpha1.AWSVolume to capav1.Volume. +func (h *awsVolumesSpecPatchHandler) toCAPAVolume(vol *v1alpha1.AWSVolume) *capav1.Volume { + capav1Volume := &capav1.Volume{ + DeviceName: vol.DeviceName, + Size: vol.Size, + Type: vol.Type, + IOPS: vol.IOPS, + EncryptionKey: vol.EncryptionKey, + } + + // Handle pointer fields - convert non-pointer v1alpha1 fields to pointer capav1 fields + if vol.Throughput != 0 { + capav1Volume.Throughput = ptr.To(vol.Throughput) + } + if vol.Encrypted { + capav1Volume.Encrypted = ptr.To(vol.Encrypted) + } + + return capav1Volume +} diff --git a/pkg/handlers/aws/mutation/volumes/inject_control_plane.go b/pkg/handlers/aws/mutation/volumes/inject_control_plane.go new file mode 100644 index 000000000..e1c64ae18 --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/inject_control_plane.go @@ -0,0 +1,25 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/patches/selectors" +) + +func NewControlPlanePatch() *awsVolumesSpecPatchHandler { + return NewAWSVolumesSpecPatchHandler( + v1alpha1.ClusterConfigVariableName, + []string{ + v1alpha1.ControlPlaneConfigVariableName, + v1alpha1.AWSVariableName, + VariableName, + }, + selectors.InfrastructureControlPlaneMachines( + capav1.GroupVersion.Version, + "AWSMachineTemplate", + ), + ) +} diff --git a/pkg/handlers/aws/mutation/volumes/inject_control_plane_test.go b/pkg/handlers/aws/mutation/volumes/inject_control_plane_test.go new file mode 100644 index 000000000..fd3d2bbd1 --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/inject_control_plane_test.go @@ -0,0 +1,185 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/internal/test/request" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/test/helpers" +) + +var _ = Describe("Generate Volumes patches for ControlPlane", func() { + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler( + "", + helpers.TestEnv.Client, + NewControlPlanePatch(), + ).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "Root volume for controlplane set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + DeviceName: "/dev/sda1", + Size: 100, + Type: capav1.VolumeTypeGP3, + IOPS: 3000, + Throughput: 125, + Encrypted: true, + EncryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + }, + v1alpha1.ControlPlaneConfigVariableName, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewCPAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/rootVolume", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sda1"), + gomega.HaveKeyWithValue("size", float64(100)), + gomega.HaveKeyWithValue("type", "gp3"), + gomega.HaveKeyWithValue("iops", float64(3000)), + gomega.HaveKeyWithValue("throughput", float64(125)), + gomega.HaveKeyWithValue("encrypted", true), + gomega.HaveKeyWithValue( + "encryptionKey", + "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + ), + ), + }, + }, + }, + { + Name: "Non-root volumes for controlplane set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + v1alpha1.AWSVolumes{ + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 200, + Type: capav1.VolumeTypeGP3, + IOPS: 4000, + Throughput: 250, + Encrypted: true, + EncryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + { + DeviceName: "/dev/sdg", + Size: 500, + Type: capav1.VolumeTypeGP2, + Encrypted: false, + }, + }, + }, + v1alpha1.ControlPlaneConfigVariableName, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewCPAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/nonRootVolumes", + ValueMatcher: gomega.And( + gomega.HaveLen(2), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdf"), + gomega.HaveKeyWithValue("size", float64(200)), + gomega.HaveKeyWithValue("type", "gp3"), + gomega.HaveKeyWithValue("iops", float64(4000)), + gomega.HaveKeyWithValue("throughput", float64(250)), + gomega.HaveKeyWithValue("encrypted", true), + )), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdg"), + gomega.HaveKeyWithValue("size", float64(500)), + gomega.HaveKeyWithValue("type", "gp2"), + )), + ), + }, + }, + }, + { + Name: "Both root and non-root volumes for controlplane set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + Size: 50, + Type: capav1.VolumeTypeGP2, + }, + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 100, + Type: capav1.VolumeTypeGP3, + }, + }, + }, + v1alpha1.ControlPlaneConfigVariableName, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewCPAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/rootVolume", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("size", float64(50)), + gomega.HaveKeyWithValue("type", "gp2"), + ), + }, + { + Operation: "add", + Path: "/spec/template/spec/nonRootVolumes", + ValueMatcher: gomega.And( + gomega.HaveLen(1), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdf"), + gomega.HaveKeyWithValue("size", float64(100)), + gomega.HaveKeyWithValue("type", "gp3"), + )), + ), + }, + }, + }, + } + + // create test node for each case + for _, tt := range testDefs { + It(tt.Name, func() { + capitest.AssertGeneratePatches( + GinkgoT(), + patchGenerator, + &tt, + ) + }) + } +}) diff --git a/pkg/handlers/aws/mutation/volumes/inject_suite_test.go b/pkg/handlers/aws/mutation/volumes/inject_suite_test.go new file mode 100644 index 000000000..f15e567e9 --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/inject_suite_test.go @@ -0,0 +1,16 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRootVolumePatch(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AWS root volume patches for ControlPlane and Workers suite") +} diff --git a/pkg/handlers/aws/mutation/volumes/inject_worker.go b/pkg/handlers/aws/mutation/volumes/inject_worker.go new file mode 100644 index 000000000..146bd988e --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/inject_worker.go @@ -0,0 +1,24 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/patches/selectors" +) + +func NewWorkerPatch() *awsVolumesSpecPatchHandler { + return NewAWSVolumesSpecPatchHandler( + v1alpha1.WorkerConfigVariableName, + []string{ + v1alpha1.AWSVariableName, + VariableName, + }, + selectors.InfrastructureWorkerMachineTemplates( + capav1.GroupVersion.Version, + "AWSMachineTemplate", + ), + ) +} diff --git a/pkg/handlers/aws/mutation/volumes/inject_worker_test.go b/pkg/handlers/aws/mutation/volumes/inject_worker_test.go new file mode 100644 index 000000000..8ecc4a23c --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/inject_worker_test.go @@ -0,0 +1,200 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/internal/test/request" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/test/helpers" +) + +var _ = Describe("Generate Volumes patches for Worker", func() { + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler( + "", + helpers.TestEnv.Client, + NewWorkerPatch(), + ).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "Root volume for worker set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + DeviceName: "/dev/sda1", + Size: 200, + Type: capav1.VolumeTypeGP3, + IOPS: 4000, + Throughput: 250, + Encrypted: true, + EncryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + }, + v1alpha1.AWSVariableName, + VariableName, + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/rootVolume", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sda1"), + gomega.HaveKeyWithValue("size", float64(200)), + gomega.HaveKeyWithValue("type", "gp3"), + gomega.HaveKeyWithValue("iops", float64(4000)), + gomega.HaveKeyWithValue("throughput", float64(250)), + gomega.HaveKeyWithValue("encrypted", true), + gomega.HaveKeyWithValue( + "encryptionKey", + "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + ), + ), + }, + }, + }, + { + Name: "Non-root volumes for worker set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + v1alpha1.AWSVolumes{ + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 100, + Type: capav1.VolumeTypeGP3, + IOPS: 3000, + Throughput: 125, + Encrypted: true, + }, + { + DeviceName: "/dev/sdg", + Size: 200, + Type: capav1.VolumeTypeGP2, + Encrypted: false, + }, + }, + }, + v1alpha1.AWSVariableName, + VariableName, + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/nonRootVolumes", + ValueMatcher: gomega.And( + gomega.HaveLen(2), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdf"), + gomega.HaveKeyWithValue("size", float64(100)), + gomega.HaveKeyWithValue("type", "gp3"), + gomega.HaveKeyWithValue("iops", float64(3000)), + gomega.HaveKeyWithValue("throughput", float64(125)), + gomega.HaveKeyWithValue("encrypted", true), + )), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdg"), + gomega.HaveKeyWithValue("size", float64(200)), + gomega.HaveKeyWithValue("type", "gp2"), + )), + ), + }, + }, + }, + { + Name: "Both root and non-root volumes for worker set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + Size: 80, + Type: capav1.VolumeTypeGP2, + }, + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 100, + Type: capav1.VolumeTypeGP3, + }, + }, + }, + v1alpha1.AWSVariableName, + VariableName, + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/rootVolume", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("size", float64(80)), + gomega.HaveKeyWithValue("type", "gp2"), + ), + }, + { + Operation: "add", + Path: "/spec/template/spec/nonRootVolumes", + ValueMatcher: gomega.And( + gomega.HaveLen(1), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdf"), + gomega.HaveKeyWithValue("size", float64(100)), + gomega.HaveKeyWithValue("type", "gp3"), + )), + ), + }, + }, + }, + } + + // create test node for each case + for _, tt := range testDefs { + It(tt.Name, func() { + capitest.AssertGeneratePatches( + GinkgoT(), + patchGenerator, + &tt, + ) + }) + } +}) diff --git a/pkg/handlers/aws/mutation/volumes/variables_test.go b/pkg/handlers/aws/mutation/volumes/variables_test.go new file mode 100644 index 000000000..a5c819457 --- /dev/null +++ b/pkg/handlers/aws/mutation/volumes/variables_test.go @@ -0,0 +1,57 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + "testing" + + "k8s.io/utils/ptr" + + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest" + awsclusterconfig "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/clusterconfig" +) + +func TestVariableValidation(t *testing.T) { + capitest.ValidateDiscoverVariables( + t, + v1alpha1.ClusterConfigVariableName, + ptr.To(v1alpha1.AWSClusterConfig{}.VariableSchema()), + true, + awsclusterconfig.NewVariable, + capitest.VariableTestDef{ + Name: "Volumes Specification", + Vals: v1alpha1.AWSClusterConfigSpec{ + ControlPlane: &v1alpha1.AWSControlPlaneSpec{ + AWS: &v1alpha1.AWSControlPlaneNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + Volumes: &v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + DeviceName: "/dev/sda1", + Size: 100, + Type: capav1.VolumeTypeGP3, + IOPS: 3000, + Throughput: 125, + Encrypted: true, + EncryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 200, + Type: capav1.VolumeTypeGP3, + IOPS: 4000, + Throughput: 250, + Encrypted: true, + }, + }, + }, + }, + }, + }, + }, + }, + ) +} diff --git a/pkg/handlers/eks/mutation/metapatch_handler.go b/pkg/handlers/eks/mutation/metapatch_handler.go index aaa3dbeba..b42182c07 100644 --- a/pkg/handlers/eks/mutation/metapatch_handler.go +++ b/pkg/handlers/eks/mutation/metapatch_handler.go @@ -15,6 +15,7 @@ import ( "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/eks/mutation/placementgroup" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/eks/mutation/region" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/eks/mutation/securitygroups" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/eks/mutation/volumes" ) // MetaPatchHandler returns a meta patch handler for mutating CAPA clusters. @@ -39,6 +40,7 @@ func MetaWorkerPatchHandler(mgr manager.Manager) handlers.Named { instancetype.NewWorkerPatch(), ami.NewWorkerPatch(), securitygroups.NewWorkerPatch(), + volumes.NewWorkerPatch(), placementgroup.NewWorkerPatch(), } patchHandlers = append(patchHandlers, workerMetaMutators()...) diff --git a/pkg/handlers/eks/mutation/volumes/inject_suite_test.go b/pkg/handlers/eks/mutation/volumes/inject_suite_test.go new file mode 100644 index 000000000..2bf66b153 --- /dev/null +++ b/pkg/handlers/eks/mutation/volumes/inject_suite_test.go @@ -0,0 +1,16 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRootVolumePatch(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EKS root volume patches for Workers suite") +} diff --git a/pkg/handlers/eks/mutation/volumes/inject_worker.go b/pkg/handlers/eks/mutation/volumes/inject_worker.go new file mode 100644 index 000000000..f39fdb5d8 --- /dev/null +++ b/pkg/handlers/eks/mutation/volumes/inject_worker.go @@ -0,0 +1,26 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/patches/selectors" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/mutation/volumes" +) + +func NewWorkerPatch() mutation.MetaMutator { + return volumes.NewAWSVolumesSpecPatchHandler( + v1alpha1.WorkerConfigVariableName, + []string{ + v1alpha1.EKSVariableName, + volumes.VariableName, + }, + selectors.InfrastructureWorkerMachineTemplates( + capav1.GroupVersion.Version, + "AWSMachineTemplate", + ), + ) +} diff --git a/pkg/handlers/eks/mutation/volumes/inject_worker_test.go b/pkg/handlers/eks/mutation/volumes/inject_worker_test.go new file mode 100644 index 000000000..fdca625b4 --- /dev/null +++ b/pkg/handlers/eks/mutation/volumes/inject_worker_test.go @@ -0,0 +1,200 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/internal/test/request" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/test/helpers" +) + +var _ = Describe("Generate Volumes patches for EKS Worker", func() { + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler( + "", + helpers.TestEnv.Client, + NewWorkerPatch(), + ).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "Root volume for EKS worker set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + DeviceName: "/dev/sda1", + Size: 200, + Type: capav1.VolumeTypeGP3, + IOPS: 4000, + Throughput: 250, + Encrypted: true, + EncryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + }, + v1alpha1.EKSVariableName, + "volumes", + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/rootVolume", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sda1"), + gomega.HaveKeyWithValue("size", float64(200)), + gomega.HaveKeyWithValue("type", "gp3"), + gomega.HaveKeyWithValue("iops", float64(4000)), + gomega.HaveKeyWithValue("throughput", float64(250)), + gomega.HaveKeyWithValue("encrypted", true), + gomega.HaveKeyWithValue( + "encryptionKey", + "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + ), + ), + }, + }, + }, + { + Name: "Non-root volumes for EKS worker set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + v1alpha1.AWSVolumes{ + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 100, + Type: capav1.VolumeTypeGP3, + IOPS: 3000, + Throughput: 125, + Encrypted: true, + }, + { + DeviceName: "/dev/sdg", + Size: 200, + Type: capav1.VolumeTypeGP2, + Encrypted: false, + }, + }, + }, + v1alpha1.EKSVariableName, + "volumes", + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/nonRootVolumes", + ValueMatcher: gomega.And( + gomega.HaveLen(2), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdf"), + gomega.HaveKeyWithValue("size", float64(100)), + gomega.HaveKeyWithValue("type", "gp3"), + gomega.HaveKeyWithValue("iops", float64(3000)), + gomega.HaveKeyWithValue("throughput", float64(125)), + gomega.HaveKeyWithValue("encrypted", true), + )), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdg"), + gomega.HaveKeyWithValue("size", float64(200)), + gomega.HaveKeyWithValue("type", "gp2"), + )), + ), + }, + }, + }, + { + Name: "Both root and non-root volumes for EKS worker set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + Size: 80, + Type: capav1.VolumeTypeGP2, + }, + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 100, + Type: capav1.VolumeTypeGP3, + }, + }, + }, + v1alpha1.EKSVariableName, + "volumes", + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{ + { + Operation: "add", + Path: "/spec/template/spec/rootVolume", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("size", float64(80)), + gomega.HaveKeyWithValue("type", "gp2"), + ), + }, + { + Operation: "add", + Path: "/spec/template/spec/nonRootVolumes", + ValueMatcher: gomega.And( + gomega.HaveLen(1), + gomega.ContainElement(gomega.And( + gomega.HaveKeyWithValue("deviceName", "/dev/sdf"), + gomega.HaveKeyWithValue("size", float64(100)), + gomega.HaveKeyWithValue("type", "gp3"), + )), + ), + }, + }, + }, + } + + // create test node for each case + for _, tt := range testDefs { + It(tt.Name, func() { + capitest.AssertGeneratePatches( + GinkgoT(), + patchGenerator, + &tt, + ) + }) + } +}) diff --git a/pkg/handlers/eks/mutation/volumes/variables_test.go b/pkg/handlers/eks/mutation/volumes/variables_test.go new file mode 100644 index 000000000..f71f9968e --- /dev/null +++ b/pkg/handlers/eks/mutation/volumes/variables_test.go @@ -0,0 +1,55 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volumes + +import ( + "testing" + + "k8s.io/utils/ptr" + + capav1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest" + eksworkerconfig "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/eks/workerconfig" +) + +func TestVariableValidation(t *testing.T) { + capitest.ValidateDiscoverVariables( + t, + v1alpha1.WorkerConfigVariableName, + ptr.To(v1alpha1.EKSWorkerNodeConfig{}.VariableSchema()), + false, + eksworkerconfig.NewVariable, + capitest.VariableTestDef{ + Name: "Volumes Specification for EKS Worker", + Vals: v1alpha1.EKSWorkerNodeConfigSpec{ + EKS: &v1alpha1.AWSWorkerNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + Volumes: &v1alpha1.AWSVolumes{ + Root: &v1alpha1.AWSVolume{ + DeviceName: "/dev/sda1", + Size: 100, + Type: capav1.VolumeTypeGP3, + IOPS: 3000, + Throughput: 125, + Encrypted: true, + EncryptionKey: "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012", + }, + NonRoot: []v1alpha1.AWSVolume{ + { + DeviceName: "/dev/sdf", + Size: 200, + Type: capav1.VolumeTypeGP3, + IOPS: 4000, + Throughput: 250, + Encrypted: true, + }, + }, + }, + }, + }, + }, + }, + ) +}