Skip to content

Commit 6bc05a9

Browse files
committed
Add explicit worker labels on worker nodes
1 parent 64bc043 commit 6bc05a9

File tree

4 files changed

+287
-0
lines changed

4 files changed

+287
-0
lines changed

pkg/handlers/generic/mutation/handlers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/kubeproxymode"
2424
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/kubernetesimagerepository"
2525
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/mirrors"
26+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/nodelabels"
2627
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/noderegistration"
2728
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/ntp"
2829
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/generic/mutation/parallelimagepulls"
@@ -76,5 +77,6 @@ func WorkerMetaMutators() []mutation.MetaMutator {
7677
return []mutation.MetaMutator{
7778
taints.NewWorkerPatch(),
7879
noderegistration.NewWorkerPatch(),
80+
nodelabels.NewWorkerPatch(),
7981
}
8082
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package nodelabels
5+
6+
import (
7+
"testing"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
)
12+
13+
func TestNodeLabelsPatch(t *testing.T) {
14+
RegisterFailHandler(Fail)
15+
RunSpecs(t, "NodeLabels patches for Workers suite")
16+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package nodelabels
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"sort"
10+
"strings"
11+
12+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
13+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
14+
bootstrapv1 "sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1beta1"
15+
runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1"
16+
ctrl "sigs.k8s.io/controller-runtime"
17+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
18+
19+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
20+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation"
21+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/patches"
22+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/patches/selectors"
23+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/variables"
24+
)
25+
26+
const (
27+
// VariableName is intentionally empty; this mutation is unconditional for workers.
28+
VariableName = ""
29+
30+
// kubeletArgNodeLabels is the kubelet extra arg key controlling node labels.
31+
kubeletArgNodeLabels = "node-labels"
32+
33+
// workerRoleLabel is the canonical worker role label.
34+
workerRoleLabel = "node-role.kubernetes.io/worker"
35+
)
36+
37+
type workerNodeLabelsPatchHandler struct {
38+
variableName string
39+
variableFieldPath []string
40+
}
41+
42+
// NewWorkerPatch returns a mutator that ensures worker nodes get the worker role label.
43+
func NewWorkerPatch() *workerNodeLabelsPatchHandler {
44+
return &workerNodeLabelsPatchHandler{
45+
variableName: v1alpha1.WorkerConfigVariableName,
46+
variableFieldPath: []string{},
47+
}
48+
}
49+
50+
func (h *workerNodeLabelsPatchHandler) Mutate(
51+
ctx context.Context,
52+
obj *unstructured.Unstructured,
53+
vars map[string]apiextensionsv1.JSON,
54+
holderRef runtimehooksv1.HolderReference,
55+
_ ctrlclient.ObjectKey,
56+
_ mutation.ClusterGetter,
57+
) error {
58+
log := ctrl.LoggerFrom(ctx).WithValues(
59+
"holderRef", holderRef,
60+
)
61+
62+
// Best-effort fetch of worker variable to keep symmetry with other handlers; ignore not found.
63+
if _, err := variables.Get[map[string]any](
64+
vars,
65+
h.variableName,
66+
h.variableFieldPath...,
67+
); err != nil && !variables.IsNotFoundError(err) {
68+
return err
69+
}
70+
71+
return patches.MutateIfApplicable(
72+
obj, vars, &holderRef, selectors.WorkersKubeadmConfigTemplateSelector(), log,
73+
func(obj *bootstrapv1.KubeadmConfigTemplate) error {
74+
log.WithValues(
75+
"patchedObjectKind", obj.GetObjectKind().GroupVersionKind().String(),
76+
"patchedObjectName", ctrlclient.ObjectKeyFromObject(obj),
77+
).Info("ensuring worker node label via kubelet extra args on join")
78+
79+
if obj.Spec.Template.Spec.JoinConfiguration == nil {
80+
obj.Spec.Template.Spec.JoinConfiguration = &bootstrapv1.JoinConfiguration{}
81+
}
82+
83+
args := obj.Spec.Template.Spec.JoinConfiguration.NodeRegistration.KubeletExtraArgs
84+
if args == nil {
85+
args = map[string]string{}
86+
}
87+
88+
updated := ensureWorkerNodeLabel(args)
89+
if updated {
90+
obj.Spec.Template.Spec.JoinConfiguration.NodeRegistration.KubeletExtraArgs = args
91+
}
92+
93+
return nil
94+
},
95+
)
96+
}
97+
98+
// ensureWorkerNodeLabel adds workerRoleLabel to kubeletArgNodeLabels if not already present.
99+
// It returns true if args were updated.
100+
func ensureWorkerNodeLabel(args map[string]string) bool {
101+
// Build set of labels from existing arg, if any.
102+
existing := args[kubeletArgNodeLabels]
103+
if existing == "" {
104+
args[kubeletArgNodeLabels] = workerRoleLabel + "="
105+
return true
106+
}
107+
108+
// Parse comma-separated list of key[=value] entries.
109+
parts := strings.Split(existing, ",")
110+
// Track presence and rebuild normalized list.
111+
hasWorker := false
112+
normalized := make([]string, 0, len(parts)+1)
113+
seen := make(map[string]struct{})
114+
115+
for _, p := range parts {
116+
p = strings.TrimSpace(p)
117+
if p == "" {
118+
continue
119+
}
120+
key := p
121+
if idx := strings.IndexByte(p, '='); idx >= 0 {
122+
key = p[:idx]
123+
}
124+
if key == workerRoleLabel {
125+
hasWorker = true
126+
// Normalize to key=
127+
p = fmt.Sprintf("%s=", workerRoleLabel)
128+
}
129+
if _, ok := seen[p]; ok {
130+
continue
131+
}
132+
seen[p] = struct{}{}
133+
normalized = append(normalized, p)
134+
}
135+
136+
if !hasWorker {
137+
normalized = append(normalized, workerRoleLabel+"=")
138+
}
139+
140+
sort.Strings(normalized)
141+
newVal := strings.Join(normalized, ",")
142+
if newVal == existing {
143+
return false
144+
}
145+
args[kubeletArgNodeLabels] = newVal
146+
return true
147+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package nodelabels
5+
6+
import (
7+
. "github.com/onsi/ginkgo/v2"
8+
"github.com/onsi/gomega"
9+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/types"
12+
bootstrapv1 "sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1beta1"
13+
runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1"
14+
15+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/clustertopology/handlers/mutation"
16+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest"
17+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/testutils/capitest/request"
18+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/test/helpers"
19+
)
20+
21+
var _ = Describe("Generate node-labels patches for Worker", func() {
22+
patchGenerator := func() mutation.GeneratePatches {
23+
return mutation.NewMetaGeneratePatchesHandler("", helpers.TestEnv.Client, NewWorkerPatch()).(mutation.GeneratePatches)
24+
}
25+
26+
// helper: build a KubeadmConfigTemplate request item with a specific initial node-labels value
27+
newKCTWithNodeLabels := func(nodeLabels string) runtimehooksv1.GeneratePatchesRequestItem {
28+
args := map[string]string{
29+
"cloud-provider": "external",
30+
}
31+
if nodeLabels != "" {
32+
args["node-labels"] = nodeLabels
33+
}
34+
35+
return request.NewRequestItem(
36+
&bootstrapv1.KubeadmConfigTemplate{
37+
TypeMeta: metav1.TypeMeta{
38+
APIVersion: bootstrapv1.GroupVersion.String(),
39+
Kind: "KubeadmConfigTemplate",
40+
},
41+
ObjectMeta: metav1.ObjectMeta{
42+
Name: "test-kubeadmconfigtemplate",
43+
Namespace: request.Namespace,
44+
},
45+
Spec: bootstrapv1.KubeadmConfigTemplateSpec{
46+
Template: bootstrapv1.KubeadmConfigTemplateResource{
47+
Spec: bootstrapv1.KubeadmConfigSpec{
48+
JoinConfiguration: &bootstrapv1.JoinConfiguration{
49+
NodeRegistration: bootstrapv1.NodeRegistrationOptions{
50+
KubeletExtraArgs: args,
51+
},
52+
},
53+
},
54+
},
55+
},
56+
},
57+
&runtimehooksv1.HolderReference{
58+
Kind: "MachineDeployment",
59+
FieldPath: "spec.template.spec.infrastructureRef",
60+
},
61+
types.UID(""),
62+
)
63+
}
64+
65+
testDefs := []capitest.PatchTestDef{
66+
{
67+
Name: "adds worker role label when missing",
68+
Vars: []runtimehooksv1.Variable{
69+
capitest.VariableWithValue(
70+
runtimehooksv1.BuiltinsName,
71+
apiextensionsv1.JSON{Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`)},
72+
),
73+
},
74+
RequestItem: request.NewKubeadmConfigTemplateRequestItem(""),
75+
ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{
76+
Operation: "add",
77+
Path: "/spec/template/spec/joinConfiguration/nodeRegistration/kubeletExtraArgs/node-labels",
78+
ValueMatcher: gomega.Equal(
79+
"node-role.kubernetes.io/worker=",
80+
),
81+
}},
82+
},
83+
{
84+
Name: "no patch if worker role label already present",
85+
Vars: []runtimehooksv1.Variable{
86+
capitest.VariableWithValue(
87+
runtimehooksv1.BuiltinsName,
88+
apiextensionsv1.JSON{Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`)},
89+
),
90+
},
91+
RequestItem: newKCTWithNodeLabels("node-role.kubernetes.io/worker="),
92+
},
93+
{
94+
Name: "merge worker role label with existing labels",
95+
Vars: []runtimehooksv1.Variable{
96+
capitest.VariableWithValue(
97+
runtimehooksv1.BuiltinsName,
98+
apiextensionsv1.JSON{Raw: []byte(`{"machineDeployment": {"class": "a-worker"}}`)},
99+
),
100+
},
101+
RequestItem: newKCTWithNodeLabels("env=prod"),
102+
ExpectedPatchMatchers: []capitest.JSONPatchMatcher{{
103+
Operation: "replace",
104+
Path: "/spec/template/spec/joinConfiguration/nodeRegistration/kubeletExtraArgs/node-labels",
105+
ValueMatcher: gomega.SatisfyAll(
106+
gomega.ContainSubstring("env=prod"),
107+
gomega.ContainSubstring("node-role.kubernetes.io/worker="),
108+
),
109+
}},
110+
},
111+
}
112+
113+
for _, tt := range testDefs {
114+
It(tt.Name, func() {
115+
capitest.AssertGeneratePatches(
116+
GinkgoT(),
117+
patchGenerator,
118+
&tt,
119+
)
120+
})
121+
}
122+
})

0 commit comments

Comments
 (0)