Skip to content

Commit b2593d3

Browse files
authored
Retain relevant kubernetes.io labels when syncing namespaces (#5156)
* Retain relevant kubernetes.io labels when syncing namespaces addLabelsFromOptions previously replaced all namespace labels with the options-provided set. Labels managed by the cluster are now preserved during the sync. * Fix namespace label equality check before update Compare the computed desired state against the current namespace labels and annotations instead of comparing raw options directly. The previous reflect.DeepEqual check could never return true because ns.Labels always includes kubernetes.io/metadata.name (and now also preserved pod-security labels), causing an unnecessary updateNamespace call on every reconcile even when no label changes were needed. * Fix nil map panic and update addLabelsFromOptions doc comment addLabelsFromOptions panics when ns.Labels is nil (maps.Clone returns nil) and NamespaceLabels options are non-nil. Add the same nil guard already present for annotations. Update the function doc comment to reflect the actual label retention rules: options labels, kubernetes.io/metadata.name, and existing pod-security.kubernetes.io/* labels are preserved; pod-security labels from options are ignored.
1 parent e23589b commit b2593d3

2 files changed

Lines changed: 80 additions & 16 deletions

File tree

internal/cmd/agent/deployer/deployer.go

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"fmt"
77
"maps"
8-
"reflect"
98
"regexp"
109
"slices"
1110
"strings"
@@ -215,25 +214,28 @@ func (d *Deployer) setNamespaceLabelsAndAnnotations(ctx context.Context, bd *fle
215214
return err
216215
}
217216

218-
if reflect.DeepEqual(bd.Spec.Options.NamespaceLabels, ns.Labels) && reflect.DeepEqual(bd.Spec.Options.NamespaceAnnotations, ns.Annotations) {
219-
return nil
220-
}
221-
217+
desiredLabels := maps.Clone(ns.Labels)
222218
if bd.Spec.Options.NamespaceLabels != nil {
223-
addLabelsFromOptions(ns.Labels, bd.Spec.Options.NamespaceLabels)
219+
if desiredLabels == nil {
220+
desiredLabels = make(map[string]string)
221+
}
222+
addLabelsFromOptions(log.FromContext(ctx), desiredLabels, bd.Spec.Options.NamespaceLabels)
224223
}
224+
desiredAnnotations := maps.Clone(ns.Annotations)
225225
if bd.Spec.Options.NamespaceAnnotations != nil {
226-
if ns.Annotations == nil {
227-
ns.Annotations = map[string]string{}
226+
if desiredAnnotations == nil {
227+
desiredAnnotations = make(map[string]string)
228228
}
229-
addAnnotationsFromOptions(ns.Annotations, bd.Spec.Options.NamespaceAnnotations)
229+
addAnnotationsFromOptions(desiredAnnotations, bd.Spec.Options.NamespaceAnnotations)
230230
}
231-
err = d.updateNamespace(ctx, ns)
232-
if err != nil {
233-
return err
231+
232+
if maps.Equal(desiredLabels, ns.Labels) && maps.Equal(desiredAnnotations, ns.Annotations) {
233+
return nil
234234
}
235235

236-
return nil
236+
ns.Labels = desiredLabels
237+
ns.Annotations = desiredAnnotations
238+
return d.updateNamespace(ctx, ns)
237239
}
238240

239241
// updateNamespace updates a namespace resource in the cluster.
@@ -258,13 +260,28 @@ func (d *Deployer) fetchNamespace(ctx context.Context, releaseID string) (*corev
258260
return ns, nil
259261
}
260262

261-
// addLabelsFromOptions updates nsLabels so that it only contains all labels specified in optLabels, plus the `kubernetes.io/metadata.name` labels added by kubernetes when creating the namespace.
262-
func addLabelsFromOptions(nsLabels map[string]string, optLabels map[string]string) {
263-
maps.Copy(nsLabels, optLabels)
263+
const podSecurityLabelPrefix = "pod-security.kubernetes.io/"
264+
265+
// addLabelsFromOptions updates nsLabels to contain labels from optLabels, while preserving
266+
// the `kubernetes.io/metadata.name` label added by Kubernetes when creating the namespace
267+
// and any existing `pod-security.kubernetes.io/*` labels. Labels with the
268+
// `pod-security.kubernetes.io/` prefix in optLabels are ignored.
269+
func addLabelsFromOptions(logger logr.Logger, nsLabels map[string]string, optLabels map[string]string) {
270+
for k, v := range optLabels {
271+
if strings.HasPrefix(k, podSecurityLabelPrefix) {
272+
logger.V(1).Info("Ignoring label from options", "label", k)
273+
continue
274+
}
275+
nsLabels[k] = v
276+
}
264277

265278
// Delete labels not defined in the options.
266279
// Keep the `kubernetes.io/metadata.name` label as it is added by kubernetes when creating the namespace.
280+
// Keep pod-security.kubernetes.io/ labels as they are managed by cluster administrators.
267281
for k := range nsLabels {
282+
if strings.HasPrefix(k, podSecurityLabelPrefix) {
283+
continue
284+
}
268285
if _, ok := optLabels[k]; k != corev1.LabelMetadataName && !ok {
269286
delete(nsLabels, k)
270287
}

internal/cmd/agent/deployer/deployer_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import (
1414
"k8s.io/apimachinery/pkg/types"
1515
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
1616
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
1718
"sigs.k8s.io/controller-runtime/pkg/client/fake"
19+
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
1820
)
1921

2022
func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
@@ -174,6 +176,51 @@ func TestSetNamespaceLabelsAndAnnotationsError(t *testing.T) {
174176
}
175177
}
176178

179+
// TestSetNamespaceLabelsAndAnnotations_NoUpdateWhenAlreadyCorrect verifies that
180+
// updateNamespace is not called when the namespace already reflects the desired state.
181+
// This guards against the broken reflect.DeepEqual check that compared raw option
182+
// labels to ns.Labels; ns.Labels always includes kubernetes.io/metadata.name and
183+
// may include preserved pod-security labels, so a direct equality check never holds.
184+
func TestSetNamespaceLabelsAndAnnotations_NoUpdateWhenAlreadyCorrect(t *testing.T) {
185+
bd := &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
186+
Options: fleet.BundleDeploymentOptions{
187+
NamespaceLabels: map[string]string{"optLabel": "optValue"},
188+
NamespaceAnnotations: map[string]string{"optAnn": "optValue"},
189+
},
190+
}}
191+
ns := corev1.Namespace{
192+
ObjectMeta: metav1.ObjectMeta{
193+
Name: "namespace",
194+
Labels: map[string]string{"kubernetes.io/metadata.name": "namespace", "optLabel": "optValue"},
195+
Annotations: map[string]string{"optAnn": "optValue"},
196+
},
197+
}
198+
199+
scheme := runtime.NewScheme()
200+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
201+
202+
updateCalled := false
203+
fakeClient := fake.NewClientBuilder().
204+
WithScheme(scheme).
205+
WithObjects(&ns).
206+
WithInterceptorFuncs(interceptor.Funcs{
207+
Update: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.UpdateOption) error {
208+
updateCalled = true
209+
return c.Update(ctx, obj, opts...)
210+
},
211+
}).
212+
Build()
213+
214+
h := Deployer{client: fakeClient}
215+
err := h.setNamespaceLabelsAndAnnotations(context.Background(), bd, "namespace/foo/bar")
216+
if err != nil {
217+
t.Fatalf("unexpected error: %v", err)
218+
}
219+
if updateCalled {
220+
t.Error("updateNamespace was called when namespace was already in the desired state")
221+
}
222+
}
223+
177224
func TestIsStateAccepted(t *testing.T) {
178225
tests := []struct {
179226
name string

0 commit comments

Comments
 (0)