diff --git a/api/v1alpha1/aws_clusterconfig_types.go b/api/v1alpha1/aws_clusterconfig_types.go index 9b938f665..aae596208 100644 --- a/api/v1alpha1/aws_clusterconfig_types.go +++ b/api/v1alpha1/aws_clusterconfig_types.go @@ -8,6 +8,11 @@ import ( ) type AWSSpec struct { + // AdditionalTags is an optional set of tags to add to an instance, + // in addition to the ones added by default by the AWS provider. + // +optional + AdditionalTags capav1.Tags `json:"additionalTags,omitempty"` + // IdentityRef is a reference to an identity to be used when reconciling the managed control plane. // If no identity is specified, the default identity for this controller will be used. // +kubebuilder:validation:Optional diff --git a/api/v1alpha1/aws_node_types.go b/api/v1alpha1/aws_node_types.go index 30348478a..0fbfbef6f 100644 --- a/api/v1alpha1/aws_node_types.go +++ b/api/v1alpha1/aws_node_types.go @@ -43,6 +43,11 @@ type AWSWorkerNodeSpec struct { } type AWSGenericNodeSpec struct { + // AdditionalTags is an optional set of tags to add to an instance, + // in addition to the ones added by default by the AWS provider. + // +optional + AdditionalTags capav1.Tags `json:"additionalTags,omitempty"` + // AMI or AMI Lookup arguments for machine image of a AWS machine. // If both AMI ID and AMI lookup arguments are provided then AMI ID takes precedence // +kubebuilder:validation:Optional diff --git a/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml index b36a86f6b..3cab85c8d 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_awsclusterconfigs.yaml @@ -290,6 +290,13 @@ spec: aws: description: AWS cluster configuration. properties: + additionalTags: + additionalProperties: + type: string + description: |- + AdditionalTags is an optional set of tags to add to an instance, + in addition to the ones added by default by the AWS provider. + type: object controlPlaneLoadBalancer: description: AWSLoadBalancerSpec configures an AWS control-plane LoadBalancer. properties: @@ -388,6 +395,13 @@ spec: type: object maxItems: 32 type: array + additionalTags: + additionalProperties: + type: string + description: |- + AdditionalTags is an optional set of tags to add to an instance, + in addition to the ones added by default by the AWS provider. + type: object ami: description: |- AMI or AMI Lookup arguments for machine image of a AWS machine. diff --git a/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml index 0280a85a1..9dc56f148 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_awsworkernodeconfigs.yaml @@ -57,6 +57,13 @@ spec: type: object maxItems: 32 type: array + additionalTags: + additionalProperties: + type: string + description: |- + AdditionalTags is an optional set of tags to add to an instance, + in addition to the ones added by default by the AWS provider. + type: object ami: description: |- AMI or AMI Lookup arguments for machine image of a AWS machine. diff --git a/api/v1alpha1/crds/caren.nutanix.com_eksclusterconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_eksclusterconfigs.yaml index 16f1a3b3f..ab9ae4c2d 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_eksclusterconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_eksclusterconfigs.yaml @@ -292,6 +292,13 @@ spec: eks: description: EKS cluster configuration. properties: + additionalTags: + additionalProperties: + type: string + description: |- + AdditionalTags is an optional set of tags to add to an instance, + in addition to the ones added by default by the AWS provider. + type: object identityRef: description: |- IdentityRef is a reference to an identity to be used when reconciling the managed control plane. diff --git a/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml b/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml index d430e14ba..239da5f76 100644 --- a/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml +++ b/api/v1alpha1/crds/caren.nutanix.com_eksworkernodeconfigs.yaml @@ -57,6 +57,13 @@ spec: type: object maxItems: 32 type: array + additionalTags: + additionalProperties: + type: string + description: |- + AdditionalTags is an optional set of tags to add to an instance, + in addition to the ones added by default by the AWS provider. + type: object ami: description: |- AMI or AMI Lookup arguments for machine image of a AWS machine. diff --git a/api/v1alpha1/eks_clusterconfig_types.go b/api/v1alpha1/eks_clusterconfig_types.go index c8297a8b1..f68774789 100644 --- a/api/v1alpha1/eks_clusterconfig_types.go +++ b/api/v1alpha1/eks_clusterconfig_types.go @@ -8,6 +8,11 @@ import ( ) type EKSSpec struct { + // AdditionalTags is an optional set of tags to add to an instance, + // in addition to the ones added by default by the AWS provider. + // +optional + AdditionalTags capav1.Tags `json:"additionalTags,omitempty"` + // IdentityRef is a reference to an identity to be used when reconciling the managed control plane. // If no identity is specified, the default identity for this controller will be used. // +kubebuilder:validation:Optional diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8aad3e929..134fcf24f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -233,6 +233,13 @@ func (in *AWSControlPlaneSpec) DeepCopy() *AWSControlPlaneSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSGenericNodeSpec) DeepCopyInto(out *AWSGenericNodeSpec) { *out = *in + if in.AdditionalTags != nil { + in, out := &in.AdditionalTags, &out.AdditionalTags + *out = make(v1beta2.Tags, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.AMISpec != nil { in, out := &in.AMISpec, &out.AMISpec *out = new(AMISpec) @@ -313,6 +320,13 @@ func (in *AWSNetwork) DeepCopy() *AWSNetwork { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSSpec) DeepCopyInto(out *AWSSpec) { *out = *in + if in.AdditionalTags != nil { + in, out := &in.AdditionalTags, &out.AdditionalTags + *out = make(v1beta2.Tags, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.IdentityRef != nil { in, out := &in.IdentityRef, &out.IdentityRef *out = new(v1beta2.AWSIdentityReference) @@ -1089,6 +1103,13 @@ func (in *EKSNodeSpec) DeepCopy() *EKSNodeSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EKSSpec) DeepCopyInto(out *EKSSpec) { *out = *in + if in.AdditionalTags != nil { + in, out := &in.AdditionalTags, &out.AdditionalTags + *out = make(v1beta2.Tags, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.IdentityRef != nil { in, out := &in.IdentityRef, &out.IdentityRef *out = new(v1beta2.AWSIdentityReference) diff --git a/docs/content/customization/aws/tags.md b/docs/content/customization/aws/tags.md new file mode 100644 index 000000000..5b7425eff --- /dev/null +++ b/docs/content/customization/aws/tags.md @@ -0,0 +1,110 @@ ++++ +title = "AWS Additional Tags" ++++ + +The AWS additional tags customization allows the user to specify custom tags to be applied to AWS resources created by the cluster. +The customization can be applied at the cluster level, control plane level, and worker node level. +This customization will be available when the +[provider-specific cluster configuration patch]({{< ref "..">}}) is included in the `ClusterClass`. + +## Example + +To specify additional tags for all AWS resources, use the following configuration: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: +spec: + topology: + variables: + - name: clusterConfig + value: + aws: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" + controlPlane: + aws: + additionalTags: + NodeType: control-plane + - name: workerConfig + value: + aws: + additionalTags: + NodeType: worker + Workload: general +``` + +We can further customize individual MachineDeployments by using the overrides field with the following configuration: + +```yaml +spec: + topology: + # ... + workers: + machineDeployments: + - class: default-worker + name: md-0 + variables: + overrides: + - name: workerConfig + value: + aws: + additionalTags: + NodeType: worker + Workload: database + Environment: production +``` + +## Tag Precedence + +When tags are specified at multiple levels, the following precedence applies (higher precedence overrides lower): + +1. **Worker level tags** and **Control plane level tags** (highest precedence) +1. **Cluster level tags** (lowest precedence) + +This means that if the same tag key is specified at multiple levels, the worker and contorl-plane level values will take precedence over the cluster level values. + +## Applying this configuration will result in the following values being set + +- `AWSCluster`: + + - ```yaml + spec: + template: + spec: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" + ``` + +- control-plane `AWSMachineTemplate`: + + - ```yaml + spec: + template: + spec: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" + NodeType: control-plane + ``` + +- worker `AWSMachineTemplate`: + + - ```yaml + spec: + template: + spec: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" + NodeType: worker + Workload: general + ``` diff --git a/docs/content/customization/eks/tags.md b/docs/content/customization/eks/tags.md new file mode 100644 index 000000000..b8ab8da35 --- /dev/null +++ b/docs/content/customization/eks/tags.md @@ -0,0 +1,87 @@ ++++ +title = "EKS Additional Tags" ++++ + +The EKS additional tags customization allows the user to specify custom tags to be applied to AWS resources created by the EKS cluster. +The customization can be applied at the cluster level and worker node level. +This customization will be available when the +[provider-specific cluster configuration patch]({{< ref "..">}}) is included in the `ClusterClass`. + +## Example + +To specify additional tags for EKS resources, use the following configuration: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: +spec: + topology: + variables: + - name: clusterConfig + value: + eks: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" +``` + +We can further customize individual MachineDeployments by using the overrides field with the following configuration: + +```yaml +spec: + topology: + # ... + workers: + machineDeployments: + - class: default-worker + name: md-0 + variables: + overrides: + - name: workerConfig + value: + eks: + additionalTags: + NodeType: worker + Workload: database + Environment: production +``` + +## Tag Precedence + +When tags are specified at multiple levels, the following precedence applies (higher precedence overrides lower): + +1. **Worker level tags** (highest precedence) +2. **Cluster level tags** (lowest precedence) + +This means that if the same tag key is specified at multiple levels, the worker level values will take precedence over the cluster level values. + +## Applying this configuration will result in the following values being set + +- `AWSManagedControlPlane`: + + - ```yaml + spec: + template: + spec: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" + ``` + +- worker `AWSMachineTemplate`: + + - ```yaml + spec: + template: + spec: + additionalTags: + Environment: production + Team: platform + CostCenter: "12345" + NodeType: worker + Workload: general + ``` diff --git a/pkg/handlers/aws/mutation/metapatch_handler.go b/pkg/handlers/aws/mutation/metapatch_handler.go index 74ba20ae8..6c3145ed0 100644 --- a/pkg/handlers/aws/mutation/metapatch_handler.go +++ b/pkg/handlers/aws/mutation/metapatch_handler.go @@ -18,6 +18,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/tags" "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" ) @@ -26,9 +27,11 @@ import ( func MetaPatchHandler(mgr manager.Manager) handlers.Named { patchHandlers := []mutation.MetaMutator{ calico.NewPatch(), + tags.NewClusterPatch(), region.NewPatch(), network.NewPatch(), controlplaneloadbalancer.NewPatch(), + tags.NewControlPlanePatch(), identityref.NewPatch(), iaminstanceprofile.NewControlPlanePatch(), instancetype.NewControlPlanePatch(), @@ -50,6 +53,7 @@ func MetaPatchHandler(mgr manager.Manager) handlers.Named { // MetaWorkerPatchHandler returns a meta patch handler for mutating CAPA workers. func MetaWorkerPatchHandler(mgr manager.Manager) handlers.Named { patchHandlers := []mutation.MetaMutator{ + tags.NewWorkerPatch(), iaminstanceprofile.NewWorkerPatch(), instancetype.NewWorkerPatch(), ami.NewWorkerPatch(), diff --git a/pkg/handlers/aws/mutation/tags/inject.go b/pkg/handlers/aws/mutation/tags/inject.go new file mode 100644 index 000000000..c3f2df157 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject.go @@ -0,0 +1,96 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + 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/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 = "additionalTags" +) + +type awsTagsPatchHandler struct { + metaVariableName string + variableFieldPath []string + patchSelector clusterv1.PatchSelector +} + +func NewAWSTagsPatchHandler( + metaVariableName string, + variableFieldPath []string, + patchSelector clusterv1.PatchSelector, +) *awsTagsPatchHandler { + return &awsTagsPatchHandler{ + metaVariableName: metaVariableName, + variableFieldPath: variableFieldPath, + patchSelector: patchSelector, + } +} + +func (h *awsTagsPatchHandler) 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, + ) + + additionalTagsVar, err := variables.Get[capav1.Tags]( + vars, + h.metaVariableName, + h.variableFieldPath..., + ) + if err != nil { + if variables.IsNotFoundError(err) { + log.V(5).Info("AWS additionalTags variable not defined") + return nil + } + return err + } + + log = log.WithValues( + "variableName", + h.metaVariableName, + "variableFieldPath", + h.variableFieldPath, + "variableValue", + additionalTagsVar, + ) + + 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 additionalTags in AWSMachineTemplate spec") + + obj.Spec.Template.Spec.AdditionalTags = additionalTagsVar + + return nil + }, + ) +} diff --git a/pkg/handlers/aws/mutation/tags/inject_cluster.go b/pkg/handlers/aws/mutation/tags/inject_cluster.go new file mode 100644 index 000000000..5a20c0283 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_cluster.go @@ -0,0 +1,104 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + 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/patches/selectors" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/variables" +) + +type awsTagsClusterPatchHandler struct { + metaVariableName string + variableFieldPath []string + patchSelector clusterv1.PatchSelector +} + +func NewAWSTagsClusterPatchHandler( + metaVariableName string, + variableFieldPath []string, + patchSelector clusterv1.PatchSelector, +) *awsTagsClusterPatchHandler { + return &awsTagsClusterPatchHandler{ + metaVariableName: metaVariableName, + variableFieldPath: variableFieldPath, + patchSelector: patchSelector, + } +} + +func (h *awsTagsClusterPatchHandler) 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, + ) + + additionalTagsVar, err := variables.Get[capav1.Tags]( + vars, + h.metaVariableName, + h.variableFieldPath..., + ) + if err != nil { + if variables.IsNotFoundError(err) { + log.V(5).Info("AWS additionalTags variable for cluster not defined") + return nil + } + return err + } + + log = log.WithValues( + "variableName", + h.metaVariableName, + "variableFieldPath", + h.variableFieldPath, + "variableValue", + additionalTagsVar, + ) + + return patches.MutateIfApplicable( + obj, + vars, + &holderRef, + h.patchSelector, + log, + func(obj *capav1.AWSClusterTemplate) error { + log.WithValues( + "patchedObjectKind", obj.GetObjectKind().GroupVersionKind().String(), + "patchedObjectName", client.ObjectKeyFromObject(obj), + ).Info("setting additionalTags in AWSClusterTemplate spec") + + obj.Spec.Template.Spec.AdditionalTags = additionalTagsVar + + return nil + }, + ) +} + +func NewClusterPatch() *awsTagsClusterPatchHandler { + return NewAWSTagsClusterPatchHandler( + v1alpha1.ClusterConfigVariableName, + []string{ + v1alpha1.AWSVariableName, + VariableName, + }, + selectors.InfrastructureCluster(capav1.GroupVersion.Version, "AWSClusterTemplate"), + ) +} diff --git a/pkg/handlers/aws/mutation/tags/inject_cluster_test.go b/pkg/handlers/aws/mutation/tags/inject_cluster_test.go new file mode 100644 index 000000000..62dbc93e7 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_cluster_test.go @@ -0,0 +1,104 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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 AWS Tags patches for Cluster", func() { + // only add aws tags patch + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewClusterPatch()).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "additionalTags set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewAWSClusterTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.Equal(map[string]interface{}{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }), + }}, + }, + { + Name: "additionalTags with empty map", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{}, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewAWSClusterTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{}, + }, + { + Name: "additionalTags with special characters", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster", + "Environment": "dev", + }, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewAWSClusterTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.Equal(map[string]interface{}{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster", + "Environment": "dev", + }), + }}, + }, + } + + // 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/tags/inject_control_plane.go b/pkg/handlers/aws/mutation/tags/inject_control_plane.go new file mode 100644 index 000000000..4892e3608 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_control_plane.go @@ -0,0 +1,24 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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() *awsTagsPatchHandler { + return NewAWSTagsPatchHandler( + v1alpha1.ClusterConfigVariableName, + []string{ + v1alpha1.AWSVariableName, + VariableName, + }, + selectors.InfrastructureControlPlaneMachines( + capav1.GroupVersion.Version, + "AWSMachineTemplate", + ), + ) +} diff --git a/pkg/handlers/aws/mutation/tags/inject_control_plane_test.go b/pkg/handlers/aws/mutation/tags/inject_control_plane_test.go new file mode 100644 index 000000000..e82471376 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_control_plane_test.go @@ -0,0 +1,104 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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 AWS Tags patches for ControlPlane", func() { + // only add aws tags patch + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewControlPlanePatch()).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "additionalTags set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewCPAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.Equal(map[string]interface{}{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }), + }}, + }, + { + Name: "additionalTags with empty map", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{}, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewCPAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{}, + }, + { + Name: "additionalTags with special characters", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster-control-plane", + "Environment": "dev", + }, + v1alpha1.AWSVariableName, + VariableName, + ), + }, + RequestItem: request.NewCPAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.Equal(map[string]interface{}{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster-control-plane", + "Environment": "dev", + }), + }}, + }, + } + + // 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/tags/inject_suite_test.go b/pkg/handlers/aws/mutation/tags/inject_suite_test.go new file mode 100644 index 000000000..7f0ac4724 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_suite_test.go @@ -0,0 +1,16 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestTagsPatch(t *testing.T) { + gomega.RegisterFailHandler(Fail) + RunSpecs(t, "AWS Tags mutator suite") +} diff --git a/pkg/handlers/aws/mutation/tags/inject_worker.go b/pkg/handlers/aws/mutation/tags/inject_worker.go new file mode 100644 index 000000000..4e64b69b5 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_worker.go @@ -0,0 +1,24 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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() *awsTagsPatchHandler { + return NewAWSTagsPatchHandler( + v1alpha1.WorkerConfigVariableName, + []string{ + v1alpha1.AWSVariableName, + VariableName, + }, + selectors.InfrastructureWorkerMachineTemplates( + capav1.GroupVersion.Version, + "AWSMachineTemplate", + ), + ) +} diff --git a/pkg/handlers/aws/mutation/tags/inject_worker_test.go b/pkg/handlers/aws/mutation/tags/inject_worker_test.go new file mode 100644 index 000000000..5f7cd3228 --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/inject_worker_test.go @@ -0,0 +1,124 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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 AWS Tags patches for Worker", func() { + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewWorkerPatch()).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "additionalTags for workers set", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + capav1.Tags{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }, + 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/additionalTags", + ValueMatcher: gomega.Equal(map[string]interface{}{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }), + }}, + }, + { + Name: "additionalTags with empty map for workers", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + capav1.Tags{}, + v1alpha1.AWSVariableName, + VariableName, + ), + capitest.VariableWithValue( + runtimehooksv1.BuiltinsName, + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{}, + }, + { + Name: "additionalTags with special characters for workers", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster-worker", + "Environment": "dev", + "NodeType": "worker", + }, + 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/additionalTags", + ValueMatcher: gomega.Equal(map[string]interface{}{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster-worker", + "Environment": "dev", + "NodeType": "worker", + }), + }}, + }, + } + + // 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/tags/variables_test.go b/pkg/handlers/aws/mutation/tags/variables_test.go new file mode 100644 index 000000000..7d26eeb8c --- /dev/null +++ b/pkg/handlers/aws/mutation/tags/variables_test.go @@ -0,0 +1,180 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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" + awsworkerconfig "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/aws/workerconfig" +) + +func TestVariableValidation(t *testing.T) { + capitest.ValidateDiscoverVariables( + t, + v1alpha1.ClusterConfigVariableName, + ptr.To(v1alpha1.AWSClusterConfig{}.VariableSchema()), + true, + awsclusterconfig.NewVariable, + capitest.VariableTestDef{ + Name: "AdditionalTags at cluster level", + Vals: v1alpha1.AWSClusterConfigSpec{ + AWS: &v1alpha1.AWSSpec{ + AdditionalTags: capav1.Tags{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "AdditionalTags at control plane level", + Vals: v1alpha1.AWSClusterConfigSpec{ + ControlPlane: &v1alpha1.AWSControlPlaneSpec{ + AWS: &v1alpha1.AWSControlPlaneNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{ + "NodeType": "control-plane", + "Environment": "production", + "Team": "platform", + }, + }, + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "AdditionalTags at both cluster and control plane levels", + Vals: v1alpha1.AWSClusterConfigSpec{ + AWS: &v1alpha1.AWSSpec{ + AdditionalTags: capav1.Tags{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + }, + }, + ControlPlane: &v1alpha1.AWSControlPlaneSpec{ + AWS: &v1alpha1.AWSControlPlaneNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{ + "NodeType": "control-plane", + "Environment": "production", + "Team": "platform", + }, + }, + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "AdditionalTags with special characters", + Vals: v1alpha1.AWSClusterConfigSpec{ + AWS: &v1alpha1.AWSSpec{ + AdditionalTags: capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster", + "Environment": "dev", + "Team": "platform", + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "Empty AdditionalTags", + Vals: v1alpha1.AWSClusterConfigSpec{ + AWS: &v1alpha1.AWSSpec{ + AdditionalTags: capav1.Tags{}, + }, + }, + }, + ) +} + +func TestWorkerVariableValidation(t *testing.T) { + capitest.ValidateDiscoverVariables( + t, + v1alpha1.WorkerConfigVariableName, + ptr.To(v1alpha1.AWSWorkerNodeConfig{}.VariableSchema()), + false, + awsworkerconfig.NewVariable, + capitest.VariableTestDef{ + Name: "AdditionalTags for workers", + Vals: v1alpha1.AWSWorkerNodeConfigSpec{ + AWS: &v1alpha1.AWSWorkerNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{ + "Environment": "production", + "Team": "platform", + "CostCenter": "12345", + "NodeType": "worker", + }, + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "AdditionalTags with special characters for workers", + Vals: v1alpha1.AWSWorkerNodeConfigSpec{ + AWS: &v1alpha1.AWSWorkerNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Name": "test-cluster-worker", + "Environment": "dev", + "NodeType": "worker", + "Team": "platform", + }, + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "Empty AdditionalTags for workers", + Vals: v1alpha1.AWSWorkerNodeConfigSpec{ + AWS: &v1alpha1.AWSWorkerNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{}, + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "AdditionalTags with AWS resource naming for workers", + Vals: v1alpha1.AWSWorkerNodeConfigSpec{ + AWS: &v1alpha1.AWSWorkerNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{ + "aws:autoscaling:groupName": "test-cluster-worker-asg", + "aws:ec2:instanceType": "m5.large", + "Environment": "production", + "Team": "platform", + }, + }, + }, + }, + }, + capitest.VariableTestDef{ + Name: "AdditionalTags with cost allocation for workers", + Vals: v1alpha1.AWSWorkerNodeConfigSpec{ + AWS: &v1alpha1.AWSWorkerNodeSpec{ + AWSGenericNodeSpec: v1alpha1.AWSGenericNodeSpec{ + AdditionalTags: capav1.Tags{ + "CostCenter": "12345", + "Project": "kubernetes-cluster", + "Owner": "platform-team", + "Environment": "production", + "NodeType": "worker", + }, + }, + }, + }, + }, + ) +} diff --git a/pkg/handlers/eks/mutation/metapatch_handler.go b/pkg/handlers/eks/mutation/metapatch_handler.go index dafd600d6..1461dcc62 100644 --- a/pkg/handlers/eks/mutation/metapatch_handler.go +++ b/pkg/handlers/eks/mutation/metapatch_handler.go @@ -16,6 +16,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/tags" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/eks/mutation/volumes" ) @@ -25,6 +26,7 @@ func MetaPatchHandler(mgr manager.Manager) handlers.Named { region.NewPatch(), network.NewPatch(), identityref.NewPatch(), + tags.NewClusterPatch(), } patchHandlers = append(patchHandlers, metaMutators()...) @@ -44,6 +46,7 @@ func MetaWorkerPatchHandler(mgr manager.Manager) handlers.Named { securitygroups.NewWorkerPatch(), volumes.NewWorkerPatch(), placementgroup.NewWorkerPatch(), + tags.NewWorkerPatch(), } patchHandlers = append(patchHandlers, workerMetaMutators()...) diff --git a/pkg/handlers/eks/mutation/tags/inject_cluster.go b/pkg/handlers/eks/mutation/tags/inject_cluster.go new file mode 100644 index 000000000..0d1bc28cb --- /dev/null +++ b/pkg/handlers/eks/mutation/tags/inject_cluster.go @@ -0,0 +1,115 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + 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" + eksv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/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 = "additionalTags" +) + +type eksTagsClusterPatchHandler struct { + metaVariableName string + variableFieldPath []string + patchSelector clusterv1.PatchSelector +} + +func newEKSClusterPatchHandler( + metaVariableName string, + variableFieldPath []string, + patchSelector clusterv1.PatchSelector, +) *eksTagsClusterPatchHandler { + return &eksTagsClusterPatchHandler{ + metaVariableName: metaVariableName, + variableFieldPath: variableFieldPath, + patchSelector: patchSelector, + } +} + +func (h *eksTagsClusterPatchHandler) 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, + ) + + additionalTagsVar, err := variables.Get[capav1.Tags]( + vars, + h.metaVariableName, + h.variableFieldPath..., + ) + if err != nil { + if variables.IsNotFoundError(err) { + log.V(5).Info("EKS additionalTags variable for control plane not defined") + return nil + } + return err + } + + log = log.WithValues( + "variableName", + h.metaVariableName, + "variableFieldPath", + h.variableFieldPath, + "variableValue", + additionalTagsVar, + ) + + return patches.MutateIfApplicable( + obj, + vars, + &holderRef, + h.patchSelector, + log, + func(obj *eksv1.AWSManagedControlPlaneTemplate) error { + log.WithValues( + "patchedObjectKind", obj.GetObjectKind().GroupVersionKind().String(), + "patchedObjectName", client.ObjectKeyFromObject(obj), + ).Info("setting additionalTags in AWSManagedControlPlaneTemplate spec") + + obj.Spec.Template.Spec.AdditionalTags = additionalTagsVar + + return nil + }, + ) +} + +func NewClusterPatch() *eksTagsClusterPatchHandler { + return newEKSClusterPatchHandler( + v1alpha1.ClusterConfigVariableName, + []string{ + v1alpha1.EKSVariableName, + VariableName, + }, + clusterv1.PatchSelector{ + APIVersion: eksv1.GroupVersion.String(), + Kind: "AWSManagedControlPlaneTemplate", + MatchResources: clusterv1.PatchSelectorMatch{ + ControlPlane: true, + }, + }, + ) +} diff --git a/pkg/handlers/eks/mutation/tags/inject_cluster_test.go b/pkg/handlers/eks/mutation/tags/inject_cluster_test.go new file mode 100644 index 000000000..61a7b4380 --- /dev/null +++ b/pkg/handlers/eks/mutation/tags/inject_cluster_test.go @@ -0,0 +1,101 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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/pkg/handlers/eks/mutation/testutils" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/test/helpers" +) + +var _ = Describe("Generate EKS Tags patches for managed control plane", func() { + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewClusterPatch()).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "additionalTags set for managed control plane", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{ + "Environment": "production", + "Team": "platform", + }, + v1alpha1.EKSVariableName, + VariableName, + ), + }, + RequestItem: testutils.NewEKSControlPlaneRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("Environment", "production"), + gomega.HaveKeyWithValue("Team", "platform"), + ), + }}, + }, + { + Name: "empty additionalTags for managed control plane", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{}, + v1alpha1.EKSVariableName, + VariableName, + ), + }, + RequestItem: testutils.NewEKSControlPlaneRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{}, + }, + { + Name: "additionalTags with special characters for managed control plane", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.ClusterConfigVariableName, + capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Cost-Center": "12345", + "Environment": "dev-test", + }, + v1alpha1.EKSVariableName, + VariableName, + ), + }, + RequestItem: testutils.NewEKSControlPlaneRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("kubernetes.io/cluster/test-cluster", "owned"), + gomega.HaveKeyWithValue("Cost-Center", "12345"), + gomega.HaveKeyWithValue("Environment", "dev-test"), + ), + }}, + }, + } + + // 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/tags/inject_suite_test.go b/pkg/handlers/eks/mutation/tags/inject_suite_test.go new file mode 100644 index 000000000..592054d57 --- /dev/null +++ b/pkg/handlers/eks/mutation/tags/inject_suite_test.go @@ -0,0 +1,16 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestEKSTagsPatch(t *testing.T) { + gomega.RegisterFailHandler(Fail) + RunSpecs(t, "EKS Tags mutator suite") +} diff --git a/pkg/handlers/eks/mutation/tags/inject_worker.go b/pkg/handlers/eks/mutation/tags/inject_worker.go new file mode 100644 index 000000000..61c934c98 --- /dev/null +++ b/pkg/handlers/eks/mutation/tags/inject_worker.go @@ -0,0 +1,26 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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/tags" +) + +func NewWorkerPatch() mutation.MetaMutator { + return tags.NewAWSTagsPatchHandler( + v1alpha1.WorkerConfigVariableName, + []string{ + v1alpha1.EKSVariableName, + tags.VariableName, + }, + selectors.InfrastructureWorkerMachineTemplates( + capav1.GroupVersion.Version, + "AWSMachineTemplate", + ), + ) +} diff --git a/pkg/handlers/eks/mutation/tags/inject_worker_test.go b/pkg/handlers/eks/mutation/tags/inject_worker_test.go new file mode 100644 index 000000000..03c652370 --- /dev/null +++ b/pkg/handlers/eks/mutation/tags/inject_worker_test.go @@ -0,0 +1,124 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tags + +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 EKS Tags patches for workers", func() { + patchGenerator := func() mutation.GeneratePatches { + return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewWorkerPatch()).(mutation.GeneratePatches) + } + + testDefs := []capitest.PatchTestDef{ + { + Name: "unset variable", + }, + { + Name: "additionalTags set for workers", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + capav1.Tags{ + "Environment": "production", + "Team": "platform", + "NodeType": "worker", + }, + v1alpha1.EKSVariableName, + VariableName, + ), + capitest.VariableWithValue( + "builtin", + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("Environment", "production"), + gomega.HaveKeyWithValue("Team", "platform"), + gomega.HaveKeyWithValue("NodeType", "worker"), + ), + }}, + }, + { + Name: "empty additionalTags for workers", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + capav1.Tags{}, + v1alpha1.EKSVariableName, + VariableName, + ), + capitest.VariableWithValue( + "builtin", + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{}, + }, + { + Name: "additionalTags with special characters for workers", + Vars: []runtimehooksv1.Variable{ + capitest.VariableWithValue( + v1alpha1.WorkerConfigVariableName, + capav1.Tags{ + "kubernetes.io/cluster/test-cluster": "owned", + "Cost-Center": "12345", + "Environment": "dev-test", + "NodeType": "worker", + }, + v1alpha1.EKSVariableName, + VariableName, + ), + capitest.VariableWithValue( + "builtin", + apiextensionsv1.JSON{ + Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`), + }, + ), + }, + RequestItem: request.NewWorkerAWSMachineTemplateRequestItem("1234"), + ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{ + Operation: "add", + Path: "/spec/template/spec/additionalTags", + ValueMatcher: gomega.And( + gomega.HaveKeyWithValue("kubernetes.io/cluster/test-cluster", "owned"), + gomega.HaveKeyWithValue("Cost-Center", "12345"), + gomega.HaveKeyWithValue("Environment", "dev-test"), + gomega.HaveKeyWithValue("NodeType", "worker"), + ), + }}, + }, + } + + // create test node for each case + for _, tt := range testDefs { + It(tt.Name, func() { + capitest.AssertGeneratePatches( + GinkgoT(), + patchGenerator, + &tt, + ) + }) + } +})