Skip to content
Open
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
62 changes: 43 additions & 19 deletions pkg/operator/nodekubeconfigcontroller/nodekubeconfigcontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
"reflect"
"strings"
"time"

Expand All @@ -20,12 +21,17 @@ import (
"github.com/openshift/library-go/pkg/operator/resource/resourceread"
"github.com/openshift/library-go/pkg/operator/v1helpers"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/kubernetes"
coreclientv1 "k8s.io/client-go/kubernetes/typed/core/v1"
corev1listers "k8s.io/client-go/listers/core/v1"
)

const workQueueKey = "key"
const (
workQueueKey = "key"
kubeApiserverServerCA = "kube-apiserver-server-ca"
nodeSystemAdminClient = "node-system-admin-client"
)

type NodeKubeconfigController struct {
operatorClient v1helpers.StaticPodOperatorClient
Expand All @@ -40,24 +46,37 @@ func NewNodeKubeconfigController(
operatorClient v1helpers.StaticPodOperatorClient,
kubeInformersForNamespaces v1helpers.KubeInformersForNamespaces,
kubeClient kubernetes.Interface,
infrastuctureInformer configv1informers.InfrastructureInformer,
infrastructureInformer configv1informers.InfrastructureInformer,
eventRecorder events.Recorder,
) factory.Controller {
c := &NodeKubeconfigController{
operatorClient: operatorClient,
kubeClient: kubeClient,
configMapLister: kubeInformersForNamespaces.ConfigMapLister(),
secretLister: kubeInformersForNamespaces.SecretLister(),
infrastructureLister: infrastuctureInformer.Lister(),
}

return factory.New().WithInformers(
infrastructureLister: infrastructureInformer.Lister(),
}

return factory.New().WithFilteredEventsInformers(
func(obj interface{}) bool {
if cm, ok := obj.(*corev1.ConfigMap); ok {
if cm.Namespace == operatorclient.TargetNamespace && cm.Name == kubeApiserverServerCA {
return true
}
return false
}
if secret, ok := obj.(*corev1.Secret); ok {
if secret.Namespace == operatorclient.OperatorNamespace && secret.Name == nodeSystemAdminClient {
return true
}
return false
}
return true
},
operatorClient.Informer(),
kubeInformersForNamespaces.InformersFor(operatorclient.OperatorNamespace).Core().V1().ConfigMaps().Informer(),
kubeInformersForNamespaces.InformersFor(operatorclient.TargetNamespace).Core().V1().ConfigMaps().Informer(),
kubeInformersForNamespaces.InformersFor(operatorclient.OperatorNamespace).Core().V1().Secrets().Informer(),
kubeInformersForNamespaces.InformersFor(operatorclient.TargetNamespace).Core().V1().Secrets().Informer(),
infrastuctureInformer.Informer(),
infrastructureInformer.Informer(),
).WithSync(c.sync).WithSyncDegradedOnError(c.operatorClient).ResyncEvery(5*time.Minute).ToController("NodeKubeconfigController", eventRecorder.WithComponentSuffix("node-kubeconfig-controller"))
}

Expand Down Expand Up @@ -99,27 +118,27 @@ func (c NodeKubeconfigController) sync(ctx context.Context, syncContext factory.
func ensureNodeKubeconfigs(ctx context.Context, client coreclientv1.CoreV1Interface, secretLister corev1listers.SecretLister, configmapLister corev1listers.ConfigMapLister, infrastructureLister configv1listers.InfrastructureLister, recorder events.Recorder) error {
requiredSecret := resourceread.ReadSecretV1OrDie(bindata.MustAsset("assets/kube-apiserver/node-kubeconfigs.yaml"))

systemAdminCredsSecret, err := secretLister.Secrets(operatorclient.OperatorNamespace).Get("node-system-admin-client")
systemAdminCredsSecret, err := secretLister.Secrets(operatorclient.OperatorNamespace).Get(nodeSystemAdminClient)
if err != nil {
return err
}

systemAdminClientCert := systemAdminCredsSecret.Data[corev1.TLSCertKey]
if len(systemAdminClientCert) == 0 {
return fmt.Errorf("system:admin client certificate missing from secret %s/node-system-admin-client", operatorclient.OperatorNamespace)
return fmt.Errorf("system:admin client certificate missing from secret %s/%s", operatorclient.OperatorNamespace, nodeSystemAdminClient)
}
systemAdminClientKey := systemAdminCredsSecret.Data[corev1.TLSPrivateKeyKey]
if len(systemAdminClientKey) == 0 {
return fmt.Errorf("system:admin client private key missing from secret %s/node-system-admin-client", operatorclient.OperatorNamespace)
return fmt.Errorf("system:admin client private key missing from secret %s/%s", operatorclient.OperatorNamespace, nodeSystemAdminClient)
}

servingCABundleCM, err := configmapLister.ConfigMaps(operatorclient.TargetNamespace).Get("kube-apiserver-server-ca")
servingCABundleCM, err := configmapLister.ConfigMaps(operatorclient.TargetNamespace).Get(kubeApiserverServerCA)
if err != nil {
return err
}
servingCABundleData := servingCABundleCM.Data["ca-bundle.crt"]
if len(servingCABundleData) == 0 {
return fmt.Errorf("serving CA bundle missing from configmap %s/kube-apiserver-server-ca", operatorclient.TargetNamespace)
return fmt.Errorf("serving CA bundle missing from configmap %s/%s", operatorclient.TargetNamespace, kubeApiserverServerCA)
}

infrastructure, err := infrastructureLister.Get("cluster")
Expand Down Expand Up @@ -161,10 +180,15 @@ func ensureNodeKubeconfigs(ctx context.Context, client coreclientv1.CoreV1Interf
requiredSecret.Annotations[certrotation.CertificateNotAfterAnnotation] = systemAdminCredsSecret.Annotations[certrotation.CertificateNotAfterAnnotation]
}

_, _, err = resourceapply.ApplySecret(ctx, client, recorder, requiredSecret)
if err != nil {
return err
actualSecret, err := secretLister.Secrets(requiredSecret.Namespace).Get(requiredSecret.Name)
if !apierrors.IsNotFound(err) {
if err != nil {
return err
}
if reflect.DeepEqual(actualSecret.Data, requiredSecret.Data) && reflect.DeepEqual(actualSecret.Annotations, requiredSecret.Annotations) {
return nil
}
}

return nil
_, _, err = resourceapply.ApplySecret(ctx, client, recorder, requiredSecret)
return err
}
101 changes: 101 additions & 0 deletions pkg/operator/nodekubeconfigcontroller/nodekubeconfigcontroller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,107 @@ func TestEnsureNodeKubeconfigs(t *testing.T) {
},
},
},
{
name: "annotations only update",
existingObjects: []runtime.Object{
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Namespace: "openshift-kube-apiserver",
Name: "kube-apiserver-server-ca",
},
Data: map[string]string{
"ca-bundle.crt": "kube-apiserver-server-ca certificate",
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "openshift-kube-apiserver-operator",
Name: "node-system-admin-client",
Annotations: map[string]string{
certrotation.CertificateNotBeforeAnnotation: certNotBefore,
certrotation.CertificateNotAfterAnnotation: certNotAfter,
},
},
Data: map[string][]byte{
"tls.crt": []byte(publicKey),
"tls.key": []byte(privateKey),
},
},
&corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Namespace: "openshift-kube-apiserver",
Name: "node-kubeconfigs",
Annotations: map[string]string{
annotations.OpenShiftComponent: "kube-apiserver",
certrotation.CertificateNotBeforeAnnotation: "some-old-not-before",
certrotation.CertificateNotAfterAnnotation: "some-old-not-after",
},
},
Data: map[string][]byte{
"localhost.kubeconfig": generateKubeConfig("localhost", "https://localhost:6443"),
"localhost-recovery.kubeconfig": generateKubeConfig("localhost-recovery", "https://localhost:6443"),
"lb-ext.kubeconfig": generateKubeConfig("lb-ext", lbExtServer),
"lb-int.kubeconfig": generateKubeConfig("lb-int", lbIntServer),
},
},
},
infrastructure: &configv1.Infrastructure{
ObjectMeta: metav1.ObjectMeta{
Namespace: "",
Name: "cluster",
},
Status: configv1.InfrastructureStatus{
APIServerURL: lbExtServer,
APIServerInternalURL: lbIntServer,
},
},
expectedErr: nil,
expectedActions: []clienttesting.Action{
clienttesting.DeleteActionImpl{
ActionImpl: clienttesting.ActionImpl{
Namespace: "openshift-kube-apiserver",
Verb: "delete",
Resource: corev1.SchemeGroupVersion.WithResource("secrets"),
},
Name: "node-kubeconfigs",
},
clienttesting.CreateActionImpl{
ActionImpl: clienttesting.ActionImpl{
Namespace: "openshift-kube-apiserver",
Verb: "create",
Resource: corev1.SchemeGroupVersion.WithResource("secrets"),
},
Object: &corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Namespace: "openshift-kube-apiserver",
Name: "node-kubeconfigs",
Labels: map[string]string{},
OwnerReferences: []metav1.OwnerReference{},
Annotations: map[string]string{
annotations.OpenShiftComponent: "kube-apiserver",
certrotation.CertificateNotBeforeAnnotation: certNotBefore,
certrotation.CertificateNotAfterAnnotation: certNotAfter,
},
},
Data: map[string][]byte{
"localhost.kubeconfig": generateKubeConfig("localhost", "https://localhost:6443"),
"localhost-recovery.kubeconfig": generateKubeConfig("localhost-recovery", "https://localhost:6443"),
"lb-ext.kubeconfig": generateKubeConfig("lb-ext", lbExtServer),
"lb-int.kubeconfig": generateKubeConfig("lb-int", lbIntServer),
},
Type: corev1.SecretTypeOpaque,
},
},
},
},
Comment on lines +368 to +468
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Good test coverage for annotation updates, but missing no-op test case.

The new "annotations only update" test validates that annotation changes trigger a delete+recreate, which is correct.

However, to properly validate the pre-apply optimization introduced in this PR (lines 183-191 in nodekubeconfigcontroller.go), you should add a test case where the existing secret matches the desired secret exactly (same Data AND Annotations). In this case, the expected actions should be empty (no delete, no create, no update).

Add this test case after the current one:

{
	name: "no-op when secret matches exactly",
	existingObjects: []runtime.Object{
		&corev1.ConfigMap{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "openshift-kube-apiserver",
				Name:      "kube-apiserver-server-ca",
			},
			Data: map[string]string{
				"ca-bundle.crt": "kube-apiserver-server-ca certificate",
			},
		},
		&corev1.Secret{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "openshift-kube-apiserver-operator",
				Name:      "node-system-admin-client",
				Annotations: map[string]string{
					certrotation.CertificateNotBeforeAnnotation: certNotBefore,
					certrotation.CertificateNotAfterAnnotation:  certNotAfter,
				},
			},
			Data: map[string][]byte{
				"tls.crt": []byte(publicKey),
				"tls.key": []byte(privateKey),
			},
		},
		&corev1.Secret{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "openshift-kube-apiserver",
				Name:      "node-kubeconfigs",
				Annotations: map[string]string{
					annotations.OpenShiftComponent:              "kube-apiserver",
					certrotation.CertificateNotBeforeAnnotation: certNotBefore,
					certrotation.CertificateNotAfterAnnotation:  certNotAfter,
				},
			},
			Data: map[string][]byte{
				"localhost.kubeconfig":          generateKubeConfig("localhost", "https://localhost:6443"),
				"localhost-recovery.kubeconfig": generateKubeConfig("localhost-recovery", "https://localhost:6443"),
				"lb-ext.kubeconfig":             generateKubeConfig("lb-ext", lbExtServer),
				"lb-int.kubeconfig":             generateKubeConfig("lb-int", lbIntServer),
			},
		},
	},
	infrastructure: &configv1.Infrastructure{
		ObjectMeta: metav1.ObjectMeta{
			Name: "cluster",
		},
		Status: configv1.InfrastructureStatus{
			APIServerURL:         lbExtServer,
			APIServerInternalURL: lbIntServer,
		},
	},
	expectedErr:     nil,
	expectedActions: []clienttesting.Action{}, // No actions expected
},
🤖 Prompt for AI Agents
In pkg/operator/nodekubeconfigcontroller/nodekubeconfigcontroller_test.go around
lines 368 to 468, add a new table-driven test case immediately after the
"annotations only update" case named "no-op when secret matches exactly" that
supplies existingObjects containing the same ConfigMap, the same
node-system-admin-client Secret, and a node-kubeconfigs Secret whose Data and
Annotations exactly match the desired output (use certNotBefore/certNotAfter for
annotations and the same generateKubeConfig outputs and lbExt/lbInt values),
with the same infrastructure object as the other tests; set expectedErr to nil
and expectedActions to an empty slice so the test asserts no
delete/create/update occurs.

}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
Expand Down