Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/handlers/generic/mutation/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/kubeproxymode"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/kubernetesimagerepository"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/mirrors"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/nodelabels"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/noderegistration"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/ntp"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/parallelimagepulls"
Expand Down Expand Up @@ -76,5 +77,6 @@ func WorkerMetaMutators() []mutation.MetaMutator {
return []mutation.MetaMutator{
taints.NewWorkerPatch(),
noderegistration.NewWorkerPatch(),
nodelabels.NewWorkerPatch(),
}
}
16 changes: 16 additions & 0 deletions pkg/handlers/generic/mutation/nodelabels/inject_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2025 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package nodelabels

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestNodeLabelsPatch(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "NodeLabels patches for Workers suite")
}
147 changes: 147 additions & 0 deletions pkg/handlers/generic/mutation/nodelabels/inject_worker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright 2025 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package nodelabels

import (
"context"
"fmt"
"sort"
"strings"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
bootstrapv1 "sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1beta1"
runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1"
ctrl "sigs.k8s.io/controller-runtime"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"

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

const (
// VariableName is intentionally empty; this mutation is unconditional for workers.
VariableName = ""

// kubeletArgNodeLabels is the kubelet extra arg key controlling node labels.
kubeletArgNodeLabels = "node-labels"

// workerRoleLabel is the canonical worker role label.
workerRoleLabel = "node-role.kubernetes.io/worker"
)

type workerNodeLabelsPatchHandler struct {
variableName string
variableFieldPath []string
}

// NewWorkerPatch returns a mutator that ensures worker nodes get the worker role label.
func NewWorkerPatch() *workerNodeLabelsPatchHandler {
return &workerNodeLabelsPatchHandler{
variableName: v1alpha1.WorkerConfigVariableName,
variableFieldPath: []string{},
}
}

func (h *workerNodeLabelsPatchHandler) Mutate(
ctx context.Context,
obj *unstructured.Unstructured,
vars map[string]apiextensionsv1.JSON,
holderRef runtimehooksv1.HolderReference,
_ ctrlclient.ObjectKey,
_ mutation.ClusterGetter,
) error {
log := ctrl.LoggerFrom(ctx).WithValues(
"holderRef", holderRef,
)

// Best-effort fetch of worker variable to keep symmetry with other handlers; ignore not found.
if _, err := variables.Get[map[string]any](
vars,
h.variableName,
h.variableFieldPath...,
); err != nil && !variables.IsNotFoundError(err) {
return err
}

return patches.MutateIfApplicable(
obj, vars, &holderRef, selectors.WorkersKubeadmConfigTemplateSelector(), log,
func(obj *bootstrapv1.KubeadmConfigTemplate) error {
log.WithValues(
"patchedObjectKind", obj.GetObjectKind().GroupVersionKind().String(),
"patchedObjectName", ctrlclient.ObjectKeyFromObject(obj),
).Info("ensuring worker node label via kubelet extra args on join")

if obj.Spec.Template.Spec.JoinConfiguration == nil {
obj.Spec.Template.Spec.JoinConfiguration = &bootstrapv1.JoinConfiguration{}
}

args := obj.Spec.Template.Spec.JoinConfiguration.NodeRegistration.KubeletExtraArgs
if args == nil {
args = map[string]string{}
}

updated := ensureWorkerNodeLabel(args)
if updated {
obj.Spec.Template.Spec.JoinConfiguration.NodeRegistration.KubeletExtraArgs = args
}

return nil
},
)
}

// ensureWorkerNodeLabel adds workerRoleLabel to kubeletArgNodeLabels if not already present.
// It returns true if args were updated.
func ensureWorkerNodeLabel(args map[string]string) bool {
// Build set of labels from existing arg, if any.
existing := args[kubeletArgNodeLabels]
if existing == "" {
args[kubeletArgNodeLabels] = workerRoleLabel + "="
return true
}

// Parse comma-separated list of key[=value] entries.
parts := strings.Split(existing, ",")
// Track presence and rebuild normalized list.
hasWorker := false
normalized := make([]string, 0, len(parts)+1)
seen := make(map[string]struct{})

for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
key := p
if idx := strings.IndexByte(p, '='); idx >= 0 {
key = p[:idx]
}
if key == workerRoleLabel {
hasWorker = true
// Normalize to key=
p = fmt.Sprintf("%s=", workerRoleLabel)
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
normalized = append(normalized, p)
}

if !hasWorker {
normalized = append(normalized, workerRoleLabel+"=")
}

sort.Strings(normalized)
newVal := strings.Join(normalized, ",")
if newVal == existing {
return false
}
args[kubeletArgNodeLabels] = newVal
return true
}
122 changes: 122 additions & 0 deletions pkg/handlers/generic/mutation/nodelabels/inject_worker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2025 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package nodelabels

import (
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
bootstrapv1 "sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1beta1"
runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/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/common/pkg/testutils/capitest/request"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/test/helpers"
)

var _ = Describe("Generate node-labels patches for Worker", func() {
patchGenerator := func() mutation.GeneratePatches {
return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewWorkerPatch()).(mutation.GeneratePatches)
}

// helper: build a KubeadmConfigTemplate request item with a specific initial node-labels value
newKCTWithNodeLabels := func(nodeLabels string) runtimehooksv1.GeneratePatchesRequestItem {
args := map[string]string{
"cloud-provider": "external",
}
if nodeLabels != "" {
args["node-labels"] = nodeLabels
}

return request.NewRequestItem(
&bootstrapv1.KubeadmConfigTemplate{
TypeMeta: metav1.TypeMeta{
APIVersion: bootstrapv1.GroupVersion.String(),
Kind: "KubeadmConfigTemplate",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-kubeadmconfigtemplate",
Namespace: request.Namespace,
},
Spec: bootstrapv1.KubeadmConfigTemplateSpec{
Template: bootstrapv1.KubeadmConfigTemplateResource{
Spec: bootstrapv1.KubeadmConfigSpec{
JoinConfiguration: &bootstrapv1.JoinConfiguration{
NodeRegistration: bootstrapv1.NodeRegistrationOptions{
KubeletExtraArgs: args,
},
},
},
},
},
},
&runtimehooksv1.HolderReference{
Kind: "MachineDeployment",
FieldPath: "spec.template.spec.infrastructureRef",
},
types.UID(""),
)
}

testDefs := []capitest.PatchTestDef{
{
Name: "adds worker role label when missing",
Vars: []runtimehooksv1.Variable{
capitest.VariableWithValue(
runtimehooksv1.BuiltinsName,
apiextensionsv1.JSON{Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`)},
),
},
RequestItem: request.NewKubeadmConfigTemplateRequestItem(""),
ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{
Operation: "add",
Path: "/spec/template/spec/joinConfiguration/nodeRegistration/kubeletExtraArgs/node-labels",
ValueMatcher: gomega.Equal(
"node-role.kubernetes.io/worker=",
),
}},
},
{
Name: "no patch if worker role label already present",
Vars: []runtimehooksv1.Variable{
capitest.VariableWithValue(
runtimehooksv1.BuiltinsName,
apiextensionsv1.JSON{Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`)},
),
},
RequestItem: newKCTWithNodeLabels("node-role.kubernetes.io/worker="),
},
{
Name: "merge worker role label with existing labels",
Vars: []runtimehooksv1.Variable{
capitest.VariableWithValue(
runtimehooksv1.BuiltinsName,
apiextensionsv1.JSON{Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`)},
),
},
RequestItem: newKCTWithNodeLabels("env=prod"),
ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{
Operation: "replace",
Path: "/spec/template/spec/joinConfiguration/nodeRegistration/kubeletExtraArgs/node-labels",
ValueMatcher: gomega.SatisfyAll(
gomega.ContainSubstring("env=prod"),
gomega.ContainSubstring("node-role.kubernetes.io/worker="),
),
}},
},
}

for _, tt := range testDefs {
It(tt.Name, func() {
capitest.AssertGeneratePatches(
GinkgoT(),
patchGenerator,
&tt,
)
})
}
})
2 changes: 2 additions & 0 deletions pkg/handlers/v3/generic/mutation/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/imageregistries/credentials"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/kubernetesimagerepository"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/mirrors"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/nodelabels"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/noderegistration"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/taints"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/users"
Expand Down Expand Up @@ -68,5 +69,6 @@ func WorkerMetaMutators() []mutation.MetaMutator {
return []mutation.MetaMutator{
taints.NewWorkerPatch(),
noderegistration.NewWorkerPatch(),
nodelabels.NewWorkerPatch(),
}
}
Loading