From 411629ac4187e670f572bc6de8b71db469a219bc Mon Sep 17 00:00:00 2001 From: Ankita Thomas Date: Fri, 7 Nov 2025 16:01:12 -0500 Subject: [PATCH 1/4] enforce client side auth requirement for metrics endpoint Signed-off-by: Ankita Thomas --- cmd/manager/main.go | 25 ++- manifests/06_role_binding.yaml | 20 ++ pkg/certificateauthority/clientcaconfigmap.go | 25 +++ .../configmap/configmap_controller.go | 45 +++- .../configmap/configmap_controller_test.go | 204 ++++++++++++++++++ pkg/controller/options/options.go | 3 + pkg/metrics/metrics.go | 9 +- 7 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 pkg/certificateauthority/clientcaconfigmap.go create mode 100644 pkg/controller/configmap/configmap_controller_test.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 15b408c75..965ee2d8b 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/x509" "flag" "fmt" "net/http" @@ -9,7 +10,7 @@ import ( "runtime" "time" - "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" + ca "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/fields" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -24,6 +25,7 @@ import ( configv1 "github.com/operator-framework/operator-marketplace/pkg/apis/config/v1" apiutils "github.com/operator-framework/operator-marketplace/pkg/apis/operators/shared" "github.com/operator-framework/operator-marketplace/pkg/controller" + "github.com/operator-framework/operator-marketplace/pkg/controller/configmap" "github.com/operator-framework/operator-marketplace/pkg/controller/options" "github.com/operator-framework/operator-marketplace/pkg/defaults" "github.com/operator-framework/operator-marketplace/pkg/metrics" @@ -112,9 +114,10 @@ func main() { os.Exit(0) } + clientCAStore := ca.NewClientCAStore(x509.NewCertPool()) // set TLS to serve metrics over a secure channel if cert is provided // cert is provided by default by the marketplace-trusted-ca volume mounted as part of the marketplace-operator deployment - if err := metrics.ServePrometheus(tlsCertPath, tlsKeyPath); err != nil { + if err := metrics.ServePrometheus(tlsCertPath, tlsKeyPath, clientCAStore); err != nil { logger.Fatalf("failed to serve prometheus metrics: %s", err) } @@ -153,10 +156,18 @@ func main() { Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ &corev1.ConfigMap{}: { - Field: fields.SelectorFromSet(fields.Set{ - "metadata.namespace": namespace, - "metadata.name": certificateauthority.TrustedCaConfigMapName, - }), + Namespaces: map[string]cache.Config{ + namespace: { + FieldSelector: fields.SelectorFromSet(fields.Set{ + "metadata.name": ca.TrustedCaConfigMapName, + }), + }, + configmap.ClientCANamespace: { + FieldSelector: fields.SelectorFromSet(fields.Set{ + "metadata.name": configmap.ClientCAConfigMap, + }), + }, + }, }, }, }, @@ -192,7 +203,7 @@ func main() { } logger.Info("setting up controllers") - if err := controller.AddToManager(mgr, options.ControllerOptions{}); err != nil { + if err := controller.AddToManager(mgr, options.ControllerOptions{ClientCAStore: clientCAStore}); err != nil { logger.Fatal(err) } diff --git a/manifests/06_role_binding.yaml b/manifests/06_role_binding.yaml index 5b0d4e443..869d02eb1 100644 --- a/manifests/06_role_binding.yaml +++ b/manifests/06_role_binding.yaml @@ -36,3 +36,23 @@ roleRef: kind: Role name: marketplace-operator apiGroup: rbac.authorization.k8s.io +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: marketplace-operator-auth-reader + namespace: kube-system + annotations: + include.release.openshift.io/hypershift: "true" + include.release.openshift.io/ibm-cloud-managed: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + capability.openshift.io/name: "marketplace" +subjects: +- kind: ServiceAccount + name: marketplace-operator + namespace: openshift-marketplace +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: extension-apiserver-authentication-reader diff --git a/pkg/certificateauthority/clientcaconfigmap.go b/pkg/certificateauthority/clientcaconfigmap.go new file mode 100644 index 000000000..f424a97e0 --- /dev/null +++ b/pkg/certificateauthority/clientcaconfigmap.go @@ -0,0 +1,25 @@ +package certificateauthority + +import "crypto/x509" + +type ClientCAStore struct { + clientCA *x509.CertPool +} + +func NewClientCAStore(certpool *x509.CertPool) *ClientCAStore { + if certpool == nil { + certpool = x509.NewCertPool() + } + return &ClientCAStore{clientCA: certpool} +} + +func (c *ClientCAStore) Update(newCAPEM []byte) { + if newCAPEM == nil { + return + } + c.clientCA.AppendCertsFromPEM(newCAPEM) +} + +func (c *ClientCAStore) GetCA() *x509.CertPool { + return c.clientCA +} \ No newline at end of file diff --git a/pkg/controller/configmap/configmap_controller.go b/pkg/controller/configmap/configmap_controller.go index 0eaa8a07b..05ff2d88e 100644 --- a/pkg/controller/configmap/configmap_controller.go +++ b/pkg/controller/configmap/configmap_controller.go @@ -3,6 +3,7 @@ package configmap import ( "context" "os" + "sigs.k8s.io/controller-runtime/pkg/builder" mktconfig "github.com/operator-framework/operator-marketplace/pkg/apis/config/v1" @@ -18,18 +19,28 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ) +const ( + // the rootCA for authenticating client certs is made available at the + // kube-system/extension-apiserver-authentication configMap, under the key + // client-ca-file. + ClientCANamespace = "kube-system" + ClientCAConfigMap = "extension-apiserver-authentication" + ClientCAKey = "client-ca-file" +) + // Add creates a new ConfigMap Controller and adds it to the Manager. The Manager will set fields on the Controller // and Start it when the Manager is Started. -func Add(mgr manager.Manager, _ options.ControllerOptions) error { - return add(mgr, newReconciler(mgr)) +func Add(mgr manager.Manager, o options.ControllerOptions) error { + return add(mgr, NewReconciler(mgr, o.ClientCAStore)) } // newReconciler returns a new ReconcileConfigMap. -func newReconciler(mgr manager.Manager) *ReconcileConfigMap { +func NewReconciler(mgr manager.Manager, clientCAStore *ca.ClientCAStore) *ReconcileConfigMap { client := mgr.GetClient() return &ReconcileConfigMap{ client: client, handler: ca.NewHandler(client), + clientCAStore: clientCAStore, } } @@ -54,6 +65,10 @@ func getPredicateFunctions() predicate.Funcs { return predicate.Funcs{ CreateFunc: func(e event.CreateEvent) bool { // If the ConfigMap is created we should kick off an event. + if e.Object.GetName() == ClientCAConfigMap && + e.Object.GetNamespace() == ClientCANamespace { + return true + } if e.Object.GetName() == ca.TrustedCaConfigMapName { return true } @@ -61,6 +76,10 @@ func getPredicateFunctions() predicate.Funcs { }, UpdateFunc: func(e event.UpdateEvent) bool { // If the ConfigMap is updated we should kick off an event. + if e.ObjectOld.GetName() == ClientCAConfigMap && + e.ObjectOld.GetNamespace() == ClientCANamespace { + return true + } if e.ObjectOld.GetName() == ca.TrustedCaConfigMapName { return true } @@ -81,6 +100,7 @@ var _ reconcile.Reconciler = &ReconcileConfigMap{} type ReconcileConfigMap struct { client client.Client handler ca.Handler + clientCAStore *ca.ClientCAStore } // Reconcile will restart the marketplace operator if the Certificate Authority ConfigMap is @@ -88,6 +108,9 @@ type ReconcileConfigMap struct { func (r *ReconcileConfigMap) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { log.Printf("Reconciling ConfigMap %s/%s", request.Namespace, request.Name) + if request.Name == ClientCAConfigMap && request.Namespace == ClientCANamespace { + return r.updateClientCA(ctx, request) + } // Check if the CA ConfigMap is in the same namespace that Marketplace is deployed in. isConfigMapInOtherNamespace, err := shared.IsObjectInOtherNamespace(request.Namespace) if err != nil { @@ -114,3 +137,19 @@ func isRunningOnPod() bool { _, err := os.Stat("/var/run/secrets/kubernetes.io/serviceaccount/namespace") return !os.IsNotExist(err) } + +func (r *ReconcileConfigMap) updateClientCA(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + // Get configMap object + clientCAConfigMap := &corev1.ConfigMap{} + if err := r.client.Get(ctx, request.NamespacedName, clientCAConfigMap); err != nil { + // Requested object was not found, could have been deleted after reconcile request. + // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. + // Return and don't requeue + return reconcile.Result{}, client.IgnoreNotFound(err) + } + if newCA, ok := clientCAConfigMap.Data[ClientCAKey]; ok { + r.clientCAStore.Update([]byte(newCA)) + } + + return reconcile.Result{}, nil +} \ No newline at end of file diff --git a/pkg/controller/configmap/configmap_controller_test.go b/pkg/controller/configmap/configmap_controller_test.go new file mode 100644 index 000000000..f8d123364 --- /dev/null +++ b/pkg/controller/configmap/configmap_controller_test.go @@ -0,0 +1,204 @@ +package configmap_test + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io/fs" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + openshiftconfigv1 "github.com/openshift/api/config/v1" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" + "github.com/operator-framework/operator-marketplace/pkg/controller/configmap" + "github.com/operator-framework/operator-marketplace/pkg/metrics" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type certKeyPair struct { + serverName string + key *rsa.PrivateKey + keyPEM []byte + cert *x509.Certificate + certPEM []byte +} + +func (i *certKeyPair) generateTestCert(parentCert *x509.Certificate, parentKey *rsa.PrivateKey) error { + randSource := rand.New(rand.NewSource(0)) + if i.key == nil { + // generate key once, if it does not exist. + key, err := rsa.GenerateKey(randSource, 1024) + if err != nil { + return err + } + i.key = key + keyPEMBytes := bytes.Buffer{} + pem.Encode(&keyPEMBytes, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(i.key)}) + i.keyPEM = keyPEMBytes.Bytes() + } + + templateCert := &x509.Certificate{NotAfter: metav1.Now().Add(5*time.Minute)} + if len(i.serverName) != 0 { + templateCert.DNSNames = []string{i.serverName} + } + if parentCert == nil && parentKey == nil { + // root CA + templateCert.MaxPathLen = 0 + templateCert.MaxPathLenZero = true + templateCert.BasicConstraintsValid = true + templateCert.IsCA = true + templateCert.KeyUsage = x509.KeyUsageCertSign + parentKey = i.key + parentCert = templateCert + } else if parentKey == nil || parentCert == nil { + return fmt.Errorf("Both parentCert and parentKey must be non-nil for non-rootCA certificates") + } + certBytes, err := x509.CreateCertificate(randSource, templateCert, parentCert, i.key.Public(), parentKey) + if err != nil { + return err + } + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return err + } + i.cert = cert + + certPEMBytes := bytes.Buffer{} + pem.Encode(&certPEMBytes, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + i.certPEM = certPEMBytes.Bytes() + + return nil +} + +func updateTestCAConfigMap(client crclient.WithWatch, reconciler *configmap.ReconcileConfigMap, caPEM []byte) error { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configmap.ClientCAConfigMap, Namespace: configmap.ClientCANamespace}, Data:map[string]string{configmap.ClientCAKey: string(caPEM)}} + if err := client.Create(context.TODO(), cm); err != nil { + if errors.IsAlreadyExists(err) { + err = client.Update(context.TODO(), cm) + } + if err != nil { + return err + } + } + if _, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: types.NamespacedName{Name: cm.Name, Namespace: cm.Namespace}}); err != nil { + return err + } + return nil +} + +func makeRequest(t *testing.T, certPool *x509.CertPool, clientCert *tls.Certificate, serverName string, expectedCond func (*http.Response, error) bool) { + httpClient := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + ServerName: serverName, + }, + }} + + if clientCert != nil { + httpClient.Transport.(*http.Transport).TLSClientConfig.Certificates =[]tls.Certificate{*clientCert} + } + + require.Eventually(t, func() bool{ + response, err := httpClient.Get("https://:8081/metrics") + return expectedCond(response, err) + }, 5*time.Second, 1*time.Second) +} + +func TestConfigMapClientCAAuth(t *testing.T) { + caStore := certificateauthority.NewClientCAStore(x509.NewCertPool()) + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, openshiftconfigv1.AddToScheme(scheme)) + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: configmap.ClientCANamespace}}).Build() + mgr, err := manager.New(&rest.Config{}, manager.Options{NewClient: func(config *rest.Config, options crclient.Options) (crclient.Client, error){return client, nil}}) + require.NoError(t, err) + reconciler := configmap.NewReconciler(mgr, caStore) + + // client CA and key + testClientName := "test.client" + clientCAKeyCertPair := certKeyPair{serverName: testClientName} + require.NoError(t, clientCAKeyCertPair.generateTestCert(nil, nil)) + + // Update configmap with current client rootCA + require.NoError(t, updateTestCAConfigMap(client, reconciler, clientCAKeyCertPair.certPEM)) + + // client cert signed by CA + clientKeyCertPair := certKeyPair{} + require.NoError(t, clientKeyCertPair.generateTestCert(clientCAKeyCertPair.cert, clientCAKeyCertPair.key)) + tlsClientCert, err := tls.X509KeyPair(clientKeyCertPair.certPEM, clientKeyCertPair.keyPEM) + require.NoError(t, err) + + // self-signed server cert-key pair + testServerName := "test.server" + serverKeyCertPair := certKeyPair{serverName: testServerName} + require.NoError(t, serverKeyCertPair.generateTestCert(nil, nil)) + + // create cert and key files for metrics server + testDir, err := os.MkdirTemp("", "client-auth-") + defer os.RemoveAll(testDir) + require.NoError(t, err) + certFile := filepath.Join(testDir, "server.crt") + keyFile := filepath.Join(testDir, "server.key") + require.NoError(t, os.WriteFile(keyFile, serverKeyCertPair.keyPEM, fs.ModePerm|os.FileMode(os.O_CREATE|os.O_RDWR))) + require.NoError(t, os.WriteFile(certFile, serverKeyCertPair.certPEM, fs.ModePerm|os.FileMode(os.O_CREATE|os.O_RDWR))) + + // start metrics HTTPS server + go func() { + if err := metrics.ServePrometheus(certFile, keyFile, caStore); err != nil { + panic(err) + } + }() + + // certpool used to validate server certs, with server cert added as a valid CA + serverCertPool := x509.NewCertPool() + serverCertPool.AddCert(serverKeyCertPair.cert) + + // Fail unauthenticated client request + makeRequest(t, serverCertPool, nil, testServerName, func(_ *http.Response, err error) bool { + return err != nil && strings.Contains(err.Error(), "tls: certificate required") + }) + + // Succeed when providing client cert + makeRequest(t, serverCertPool, &tlsClientCert, testServerName, func(response *http.Response, err error) bool { + return response != nil && response.StatusCode == 200 + }) + + // Fail when client uses new CA before update + clientCAKeyCertPairNew := certKeyPair{serverName: testClientName} + require.NoError(t, clientCAKeyCertPairNew.generateTestCert(nil, nil)) + require.NoError(t, clientKeyCertPair.generateTestCert(clientCAKeyCertPairNew.cert, clientCAKeyCertPairNew.key)) + tlsClientCert, err = tls.X509KeyPair(clientKeyCertPair.certPEM, clientKeyCertPair.keyPEM) + require.NoError(t, err) + makeRequest(t, serverCertPool, &tlsClientCert, testServerName, func(response *http.Response, err error) bool { + return err != nil && strings.Contains(err.Error(), "tls: unknown certificate authority") + }) + + // Succeed after reconciling the clientCA ConfigMap + require.NoError(t, updateTestCAConfigMap(client, reconciler, clientCAKeyCertPairNew.certPEM)) + makeRequest(t, serverCertPool, &tlsClientCert, testServerName, func(response *http.Response, err error) bool { + return response != nil && response.StatusCode == 200 + }) +} diff --git a/pkg/controller/options/options.go b/pkg/controller/options/options.go index 000c34a7a..e2376d98c 100644 --- a/pkg/controller/options/options.go +++ b/pkg/controller/options/options.go @@ -1,4 +1,7 @@ package options +import "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" + type ControllerOptions struct { + ClientCAStore *certificateauthority.ClientCAStore } diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 55ff34aed..9363da410 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" "github.com/operator-framework/operator-marketplace/pkg/filemonitor" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -23,7 +24,7 @@ const ( ) // ServePrometheus enables marketplace to serve prometheus metrics. -func ServePrometheus(cert, key string) error { +func ServePrometheus(cert, key string, clientCAStore *certificateauthority.ClientCAStore) error { // Register metrics for the operator with the prometheus. logrus.Info("[metrics] Registering marketplace metrics") @@ -52,6 +53,12 @@ func ServePrometheus(cert, key string) error { GetCertificate: tlsGetCertFn, }, } + if clientCAStore != nil { + httpsServer.TLSConfig.ClientCAs = clientCAStore.GetCA() + httpsServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert + } else { + logrus.Warnf("No client CA configured, continuing without client cert verification") + } err := httpsServer.ListenAndServeTLS("", "") if err != nil { if err == http.ErrServerClosed { From dbb4f08cac88b5564de7f79159972072a0a76eed Mon Sep 17 00:00:00 2001 From: Ankita Thomas Date: Mon, 10 Nov 2025 02:59:33 -0500 Subject: [PATCH 2/4] vendor update Signed-off-by: Ankita Thomas --- .../configmap/configmap_controller_test.go | 17 +- .../k8s.io/apimachinery/pkg/util/rand/rand.go | 127 ++ vendor/modules.txt | 4 + .../pkg/client/fake/client.go | 1602 +++++++++++++++++ .../controller-runtime/pkg/client/fake/doc.go | 38 + .../pkg/client/interceptor/intercept.go | 166 ++ .../pkg/internal/objectutil/objectutil.go | 42 + 7 files changed, 1990 insertions(+), 6 deletions(-) create mode 100644 vendor/k8s.io/apimachinery/pkg/util/rand/rand.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go diff --git a/pkg/controller/configmap/configmap_controller_test.go b/pkg/controller/configmap/configmap_controller_test.go index f8d123364..2bbe8e9c2 100644 --- a/pkg/controller/configmap/configmap_controller_test.go +++ b/pkg/controller/configmap/configmap_controller_test.go @@ -18,23 +18,24 @@ import ( "time" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" openshiftconfigv1 "github.com/openshift/api/config/v1" - crclient "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" "github.com/operator-framework/operator-marketplace/pkg/controller/configmap" "github.com/operator-framework/operator-marketplace/pkg/metrics" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type certKeyPair struct { @@ -55,7 +56,9 @@ func (i *certKeyPair) generateTestCert(parentCert *x509.Certificate, parentKey * } i.key = key keyPEMBytes := bytes.Buffer{} - pem.Encode(&keyPEMBytes, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(i.key)}) + if err := pem.Encode(&keyPEMBytes, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(i.key)}); err != nil { + return err + } i.keyPEM = keyPEMBytes.Bytes() } @@ -86,7 +89,9 @@ func (i *certKeyPair) generateTestCert(parentCert *x509.Certificate, parentKey * i.cert = cert certPEMBytes := bytes.Buffer{} - pem.Encode(&certPEMBytes, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + if err := pem.Encode(&certPEMBytes, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { + return err + } i.certPEM = certPEMBytes.Bytes() return nil diff --git a/vendor/k8s.io/apimachinery/pkg/util/rand/rand.go b/vendor/k8s.io/apimachinery/pkg/util/rand/rand.go new file mode 100644 index 000000000..82a473bb1 --- /dev/null +++ b/vendor/k8s.io/apimachinery/pkg/util/rand/rand.go @@ -0,0 +1,127 @@ +/* +Copyright 2015 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package rand provides utilities related to randomization. +package rand + +import ( + "math/rand" + "sync" + "time" +) + +var rng = struct { + sync.Mutex + rand *rand.Rand +}{ + rand: rand.New(rand.NewSource(time.Now().UnixNano())), +} + +// Int returns a non-negative pseudo-random int. +func Int() int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Int() +} + +// Intn generates an integer in range [0,max). +// By design this should panic if input is invalid, <= 0. +func Intn(max int) int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Intn(max) +} + +// IntnRange generates an integer in range [min,max). +// By design this should panic if input is invalid, <= 0. +func IntnRange(min, max int) int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Intn(max-min) + min +} + +// IntnRange generates an int64 integer in range [min,max). +// By design this should panic if input is invalid, <= 0. +func Int63nRange(min, max int64) int64 { + rng.Lock() + defer rng.Unlock() + return rng.rand.Int63n(max-min) + min +} + +// Seed seeds the rng with the provided seed. +func Seed(seed int64) { + rng.Lock() + defer rng.Unlock() + + rng.rand = rand.New(rand.NewSource(seed)) +} + +// Perm returns, as a slice of n ints, a pseudo-random permutation of the integers [0,n) +// from the default Source. +func Perm(n int) []int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Perm(n) +} + +const ( + // We omit vowels from the set of available characters to reduce the chances + // of "bad words" being formed. + alphanums = "bcdfghjklmnpqrstvwxz2456789" + // No. of bits required to index into alphanums string. + alphanumsIdxBits = 5 + // Mask used to extract last alphanumsIdxBits of an int. + alphanumsIdxMask = 1<>= alphanumsIdxBits + remaining-- + } + return string(b) +} + +// SafeEncodeString encodes s using the same characters as rand.String. This reduces the chances of bad words and +// ensures that strings generated from hash functions appear consistent throughout the API. +func SafeEncodeString(s string) string { + r := make([]byte, len(s)) + for i, b := range []rune(s) { + r[i] = alphanums[(int(b) % len(alphanums))] + } + return string(r) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 037b03502..c21902076 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -488,6 +488,7 @@ k8s.io/apimachinery/pkg/util/managedfields/internal k8s.io/apimachinery/pkg/util/mergepatch k8s.io/apimachinery/pkg/util/naming k8s.io/apimachinery/pkg/util/net +k8s.io/apimachinery/pkg/util/rand k8s.io/apimachinery/pkg/util/runtime k8s.io/apimachinery/pkg/util/sets k8s.io/apimachinery/pkg/util/strategicpatch @@ -831,6 +832,8 @@ sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics sigs.k8s.io/controller-runtime/pkg/client sigs.k8s.io/controller-runtime/pkg/client/apiutil sigs.k8s.io/controller-runtime/pkg/client/config +sigs.k8s.io/controller-runtime/pkg/client/fake +sigs.k8s.io/controller-runtime/pkg/client/interceptor sigs.k8s.io/controller-runtime/pkg/cluster sigs.k8s.io/controller-runtime/pkg/config sigs.k8s.io/controller-runtime/pkg/controller @@ -845,6 +848,7 @@ sigs.k8s.io/controller-runtime/pkg/internal/field/selector sigs.k8s.io/controller-runtime/pkg/internal/httpserver sigs.k8s.io/controller-runtime/pkg/internal/log sigs.k8s.io/controller-runtime/pkg/internal/metrics +sigs.k8s.io/controller-runtime/pkg/internal/objectutil sigs.k8s.io/controller-runtime/pkg/internal/recorder sigs.k8s.io/controller-runtime/pkg/internal/source sigs.k8s.io/controller-runtime/pkg/internal/syncs diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go new file mode 100644 index 000000000..16e2cba51 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go @@ -0,0 +1,1602 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fake + +import ( + "bytes" + "context" + "errors" + "fmt" + "reflect" + "runtime/debug" + "strconv" + "strings" + "sync" + "time" + + /* + Stick with gopkg.in/evanphx/json-patch.v4 here to match + upstream Kubernetes code and avoid breaking changes introduced in v5. + - Kubernetes itself remains on json-patch v4 to avoid compatibility issues + tied to v5’s stricter RFC6902 compliance. + - The fake client code is adapted from client-go’s testing fixture, which also + relies on json-patch v4. + See: + https://github.com/kubernetes/kubernetes/pull/91622 (discussion of why K8s + stays on v4) + https://github.com/kubernetes/kubernetes/pull/120326 (v5.6.0+incompatible + missing a critical fix) + */ + jsonpatch "gopkg.in/evanphx/json-patch.v4" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + policyv1beta1 "k8s.io/api/policy/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/json" + utilrand "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/testing" + "k8s.io/utils/ptr" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/internal/field/selector" + "sigs.k8s.io/controller-runtime/pkg/internal/objectutil" +) + +type versionedTracker struct { + testing.ObjectTracker + scheme *runtime.Scheme + withStatusSubresource sets.Set[schema.GroupVersionKind] +} + +type fakeClient struct { + // trackerWriteLock must be acquired before writing to + // the tracker or performing reads that affect a following + // write. + trackerWriteLock sync.Mutex + tracker versionedTracker + + schemeLock sync.RWMutex + scheme *runtime.Scheme + + restMapper meta.RESTMapper + withStatusSubresource sets.Set[schema.GroupVersionKind] + + // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. + // The inner map maps from index name to IndexerFunc. + indexes map[schema.GroupVersionKind]map[string]client.IndexerFunc + // indexesLock must be held when accessing indexes. + indexesLock sync.RWMutex +} + +var _ client.WithWatch = &fakeClient{} + +const ( + maxNameLength = 63 + randomLength = 5 + maxGeneratedNameLength = maxNameLength - randomLength + + subResourceScale = "scale" +) + +// NewFakeClient creates a new fake client for testing. +// You can choose to initialize it with a slice of runtime.Object. +func NewFakeClient(initObjs ...runtime.Object) client.WithWatch { + return NewClientBuilder().WithRuntimeObjects(initObjs...).Build() +} + +// NewClientBuilder returns a new builder to create a fake client. +func NewClientBuilder() *ClientBuilder { + return &ClientBuilder{} +} + +// ClientBuilder builds a fake client. +type ClientBuilder struct { + scheme *runtime.Scheme + restMapper meta.RESTMapper + initObject []client.Object + initLists []client.ObjectList + initRuntimeObjects []runtime.Object + withStatusSubresource []client.Object + objectTracker testing.ObjectTracker + interceptorFuncs *interceptor.Funcs + + // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. + // The inner map maps from index name to IndexerFunc. + indexes map[schema.GroupVersionKind]map[string]client.IndexerFunc +} + +// WithScheme sets this builder's internal scheme. +// If not set, defaults to client-go's global scheme.Scheme. +func (f *ClientBuilder) WithScheme(scheme *runtime.Scheme) *ClientBuilder { + f.scheme = scheme + return f +} + +// WithRESTMapper sets this builder's restMapper. +// The restMapper is directly set as mapper in the Client. This can be used for example +// with a meta.DefaultRESTMapper to provide a static rest mapping. +// If not set, defaults to an empty meta.DefaultRESTMapper. +func (f *ClientBuilder) WithRESTMapper(restMapper meta.RESTMapper) *ClientBuilder { + f.restMapper = restMapper + return f +} + +// WithObjects can be optionally used to initialize this fake client with client.Object(s). +func (f *ClientBuilder) WithObjects(initObjs ...client.Object) *ClientBuilder { + f.initObject = append(f.initObject, initObjs...) + return f +} + +// WithLists can be optionally used to initialize this fake client with client.ObjectList(s). +func (f *ClientBuilder) WithLists(initLists ...client.ObjectList) *ClientBuilder { + f.initLists = append(f.initLists, initLists...) + return f +} + +// WithRuntimeObjects can be optionally used to initialize this fake client with runtime.Object(s). +func (f *ClientBuilder) WithRuntimeObjects(initRuntimeObjs ...runtime.Object) *ClientBuilder { + f.initRuntimeObjects = append(f.initRuntimeObjects, initRuntimeObjs...) + return f +} + +// WithObjectTracker can be optionally used to initialize this fake client with testing.ObjectTracker. +func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder { + f.objectTracker = ot + return f +} + +// WithIndex can be optionally used to register an index with name `field` and indexer `extractValue` +// for API objects of the same GroupVersionKind (GVK) as `obj` in the fake client. +// It can be invoked multiple times, both with objects of the same GVK or different ones. +// Invoking WithIndex twice with the same `field` and GVK (via `obj`) arguments will panic. +// WithIndex retrieves the GVK of `obj` using the scheme registered via WithScheme if +// WithScheme was previously invoked, the default scheme otherwise. +func (f *ClientBuilder) WithIndex(obj runtime.Object, field string, extractValue client.IndexerFunc) *ClientBuilder { + objScheme := f.scheme + if objScheme == nil { + objScheme = scheme.Scheme + } + + gvk, err := apiutil.GVKForObject(obj, objScheme) + if err != nil { + panic(err) + } + + // If this is the first index being registered, we initialize the map storing all the indexes. + if f.indexes == nil { + f.indexes = make(map[schema.GroupVersionKind]map[string]client.IndexerFunc) + } + + // If this is the first index being registered for the GroupVersionKind of `obj`, we initialize + // the map storing the indexes for that GroupVersionKind. + if f.indexes[gvk] == nil { + f.indexes[gvk] = make(map[string]client.IndexerFunc) + } + + if _, fieldAlreadyIndexed := f.indexes[gvk][field]; fieldAlreadyIndexed { + panic(fmt.Errorf("indexer conflict: field %s for GroupVersionKind %v is already indexed", + field, gvk)) + } + + f.indexes[gvk][field] = extractValue + + return f +} + +// WithStatusSubresource configures the passed object with a status subresource, which means +// calls to Update and Patch will not alter its status. +func (f *ClientBuilder) WithStatusSubresource(o ...client.Object) *ClientBuilder { + f.withStatusSubresource = append(f.withStatusSubresource, o...) + return f +} + +// WithInterceptorFuncs configures the client methods to be intercepted using the provided interceptor.Funcs. +func (f *ClientBuilder) WithInterceptorFuncs(interceptorFuncs interceptor.Funcs) *ClientBuilder { + f.interceptorFuncs = &interceptorFuncs + return f +} + +// Build builds and returns a new fake client. +func (f *ClientBuilder) Build() client.WithWatch { + if f.scheme == nil { + f.scheme = scheme.Scheme + } + if f.restMapper == nil { + f.restMapper = meta.NewDefaultRESTMapper([]schema.GroupVersion{}) + } + + var tracker versionedTracker + + withStatusSubResource := sets.New(inTreeResourcesWithStatus()...) + for _, o := range f.withStatusSubresource { + gvk, err := apiutil.GVKForObject(o, f.scheme) + if err != nil { + panic(fmt.Errorf("failed to get gvk for object %T: %w", withStatusSubResource, err)) + } + withStatusSubResource.Insert(gvk) + } + + if f.objectTracker == nil { + tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme, withStatusSubresource: withStatusSubResource} + } else { + tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource} + } + + for _, obj := range f.initObject { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add object %v to fake client: %w", obj, err)) + } + } + for _, obj := range f.initLists { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add list %v to fake client: %w", obj, err)) + } + } + for _, obj := range f.initRuntimeObjects { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add runtime object %v to fake client: %w", obj, err)) + } + } + + var result client.WithWatch = &fakeClient{ + tracker: tracker, + scheme: f.scheme, + restMapper: f.restMapper, + indexes: f.indexes, + withStatusSubresource: withStatusSubResource, + } + + if f.interceptorFuncs != nil { + result = interceptor.NewClient(result, *f.interceptorFuncs) + } + + return result +} + +const trackerAddResourceVersion = "999" + +func (t versionedTracker) Add(obj runtime.Object) error { + var objects []runtime.Object + if meta.IsListType(obj) { + var err error + objects, err = meta.ExtractList(obj) + if err != nil { + return err + } + } else { + objects = []runtime.Object{obj} + } + for _, obj := range objects { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetDeletionTimestamp() != nil && len(accessor.GetFinalizers()) == 0 { + return fmt.Errorf("refusing to create obj %s with metadata.deletionTimestamp but no finalizers", accessor.GetName()) + } + if accessor.GetResourceVersion() == "" { + // We use a "magic" value of 999 here because this field + // is parsed as uint and and 0 is already used in Update. + // As we can't go lower, go very high instead so this can + // be recognized + accessor.SetResourceVersion(trackerAddResourceVersion) + } + + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + if err := t.ObjectTracker.Add(obj); err != nil { + return err + } + } + + return nil +} + +func (t versionedTracker) Create(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.CreateOptions) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetName() == "" { + return apierrors.NewInvalid( + obj.GetObjectKind().GroupVersionKind().GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + if accessor.GetResourceVersion() != "" { + return apierrors.NewBadRequest("resourceVersion can not be set for Create requests") + } + accessor.SetResourceVersion("1") + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + if err := t.ObjectTracker.Create(gvr, obj, ns, opts...); err != nil { + accessor.SetResourceVersion("") + return err + } + + return nil +} + +// convertFromUnstructuredIfNecessary will convert runtime.Unstructured for a GVK that is recognized +// by the schema into the whatever the schema produces with New() for said GVK. +// This is required because the tracker unconditionally saves on manipulations, but its List() implementation +// tries to assign whatever it finds into a ListType it gets from schema.New() - Thus we have to ensure +// we save as the very same type, otherwise subsequent List requests will fail. +func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (runtime.Object, error) { + u, isUnstructured := o.(runtime.Unstructured) + if !isUnstructured { + return o, nil + } + gvk := o.GetObjectKind().GroupVersionKind() + if !s.Recognizes(gvk) { + return o, nil + } + + typed, err := s.New(gvk) + if err != nil { + return nil, fmt.Errorf("scheme recognizes %s but failed to produce an object for it: %w", gvk, err) + } + + unstructuredSerialized, err := json.Marshal(u) + if err != nil { + return nil, fmt.Errorf("failed to serialize %T: %w", unstructuredSerialized, err) + } + if err := json.Unmarshal(unstructuredSerialized, typed); err != nil { + return nil, fmt.Errorf("failed to unmarshal the content of %T into %T: %w", u, typed, err) + } + + return typed, nil +} + +func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.UpdateOptions) error { + updateOpts, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + return t.update(gvr, obj, ns, false, false, updateOpts) +} + +func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, opts metav1.UpdateOptions) error { + obj, err := t.updateObject(gvr, obj, ns, isStatus, deleting, opts.DryRun) + if err != nil { + return err + } + if obj == nil { + return nil + } + + return t.ObjectTracker.Update(gvr, obj, ns, opts) +} + +func (t versionedTracker) Patch(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.PatchOptions) error { + patchOptions, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + // We apply patches using a client-go reaction that ends up calling the trackers Patch. As we can't change + // that reaction, we use the callstack to figure out if this originated from the status client. + isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) + + obj, err = t.updateObject(gvr, obj, ns, isStatus, false, patchOptions.DryRun) + if err != nil { + return err + } + if obj == nil { + return nil + } + + return t.ObjectTracker.Patch(gvr, obj, ns, patchOptions) +} + +func (t versionedTracker) updateObject(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, dryRun []string) (runtime.Object, error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, fmt.Errorf("failed to get accessor for object: %w", err) + } + + if accessor.GetName() == "" { + return nil, apierrors.NewInvalid( + obj.GetObjectKind().GroupVersionKind().GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + + gvk, err := apiutil.GVKForObject(obj, t.scheme) + if err != nil { + return nil, err + } + + oldObject, err := t.ObjectTracker.Get(gvr, ns, accessor.GetName()) + if err != nil { + // If the resource is not found and the resource allows create on update, issue a + // create instead. + if apierrors.IsNotFound(err) && allowsCreateOnUpdate(gvk) { + return nil, t.Create(gvr, obj, ns) + } + return nil, err + } + + if t.withStatusSubresource.Has(gvk) { + if isStatus { // copy everything but status and metadata.ResourceVersion from original object + if err := copyStatusFrom(obj, oldObject); err != nil { + return nil, fmt.Errorf("failed to copy non-status field for object with status subresouce: %w", err) + } + passedRV := accessor.GetResourceVersion() + if err := copyFrom(oldObject, obj); err != nil { + return nil, fmt.Errorf("failed to restore non-status fields: %w", err) + } + accessor.SetResourceVersion(passedRV) + } else { // copy status from original object + if err := copyStatusFrom(oldObject, obj); err != nil { + return nil, fmt.Errorf("failed to copy the status for object with status subresource: %w", err) + } + } + } else if isStatus { + return nil, apierrors.NewNotFound(gvr.GroupResource(), accessor.GetName()) + } + + oldAccessor, err := meta.Accessor(oldObject) + if err != nil { + return nil, err + } + + // If the new object does not have the resource version set and it allows unconditional update, + // default it to the resource version of the existing resource + if accessor.GetResourceVersion() == "" { + switch { + case allowsUnconditionalUpdate(gvk): + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + // This is needed because if the patch explicitly sets the RV to null, the client-go reaction we use + // to apply it and whose output we process here will have it unset. It is not clear why the Kubernetes + // apiserver accepts such a patch, but it does so we just copy that behavior. + // Kubernetes apiserver behavior can be checked like this: + // `kubectl patch configmap foo --patch '{"metadata":{"annotations":{"foo":"bar"},"resourceVersion":null}}' -v=9` + case bytes. + Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Patch")): + // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change + // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Patch" func. + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + } + } + + if accessor.GetResourceVersion() != oldAccessor.GetResourceVersion() { + return nil, apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), errors.New("object was modified")) + } + if oldAccessor.GetResourceVersion() == "" { + oldAccessor.SetResourceVersion("0") + } + intResourceVersion, err := strconv.ParseUint(oldAccessor.GetResourceVersion(), 10, 64) + if err != nil { + return nil, fmt.Errorf("can not convert resourceVersion %q to int: %w", oldAccessor.GetResourceVersion(), err) + } + intResourceVersion++ + accessor.SetResourceVersion(strconv.FormatUint(intResourceVersion, 10)) + + if !deleting && !deletionTimestampEqual(accessor, oldAccessor) { + return nil, fmt.Errorf("error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName()) + } + + if !accessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 { + return nil, t.ObjectTracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName(), metav1.DeleteOptions{DryRun: dryRun}) + } + return convertFromUnstructuredIfNecessary(t.scheme, obj) +} + +func (c *fakeClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + o, err := c.tracker.Get(gvr, key.Namespace, key.Name) + if err != nil { + return err + } + + _, isUnstructured := obj.(runtime.Unstructured) + _, isPartialObject := obj.(*metav1.PartialObjectMetadata) + + if isUnstructured || isPartialObject { + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + ta.SetKind(gvk.Kind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + return json.Unmarshal(j, obj) +} + +func (c *fakeClient) Watch(ctx context.Context, list client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + gvk, err := apiutil.GVKForObject(list, c.scheme) + if err != nil { + return nil, err + } + + gvk.Kind = strings.TrimSuffix(gvk.Kind, "List") + + listOpts := client.ListOptions{} + listOpts.ApplyOptions(opts) + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return c.tracker.Watch(gvr, listOpts.Namespace) +} + +func (c *fakeClient) List(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + originalKind := gvk.Kind + + gvk.Kind = strings.TrimSuffix(gvk.Kind, "List") + + if _, isUnstructuredList := obj.(runtime.Unstructured); isUnstructuredList && !c.scheme.Recognizes(gvk) { + // We need to register the ListKind with UnstructuredList: + // https://github.com/kubernetes/kubernetes/blob/7b2776b89fb1be28d4e9203bdeec079be903c103/staging/src/k8s.io/client-go/dynamic/fake/simple.go#L44-L51 + c.schemeLock.RUnlock() + c.schemeLock.Lock() + c.scheme.AddKnownTypeWithName(gvk.GroupVersion().WithKind(gvk.Kind+"List"), &unstructured.UnstructuredList{}) + c.schemeLock.Unlock() + c.schemeLock.RLock() + } + + listOpts := client.ListOptions{} + listOpts.ApplyOptions(opts) + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + o, err := c.tracker.List(gvr, gvk, listOpts.Namespace) + if err != nil { + return err + } + + if _, isUnstructured := obj.(runtime.Unstructured); isUnstructured { + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + ta.SetKind(originalKind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + objCopy := obj.DeepCopyObject().(client.ObjectList) + if err := json.Unmarshal(j, objCopy); err != nil { + return err + } + + if _, isUnstructured := obj.(runtime.Unstructured); isUnstructured { + ta, err := meta.TypeAccessor(obj) + if err != nil { + return err + } + ta.SetKind(originalKind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + objs, err := meta.ExtractList(objCopy) + if err != nil { + return err + } + + if listOpts.LabelSelector == nil && listOpts.FieldSelector == nil { + return meta.SetList(obj, objs) + } + + // If we're here, either a label or field selector are specified (or both), so before we return + // the list we must filter it. If both selectors are set, they are ANDed. + filteredList, err := c.filterList(objs, gvk, listOpts.LabelSelector, listOpts.FieldSelector) + if err != nil { + return err + } + + return meta.SetList(obj, filteredList) +} + +func (c *fakeClient) filterList(list []runtime.Object, gvk schema.GroupVersionKind, ls labels.Selector, fs fields.Selector) ([]runtime.Object, error) { + // Filter the objects with the label selector + filteredList := list + if ls != nil { + objsFilteredByLabel, err := objectutil.FilterWithLabels(list, ls) + if err != nil { + return nil, err + } + filteredList = objsFilteredByLabel + } + + // Filter the result of the previous pass with the field selector + if fs != nil { + objsFilteredByField, err := c.filterWithFields(filteredList, gvk, fs) + if err != nil { + return nil, err + } + filteredList = objsFilteredByField + } + + return filteredList, nil +} + +func (c *fakeClient) filterWithFields(list []runtime.Object, gvk schema.GroupVersionKind, fs fields.Selector) ([]runtime.Object, error) { + requiresExact := selector.RequiresExactMatch(fs) + if !requiresExact { + return nil, fmt.Errorf(`field selector %s is not in one of the two supported forms "key==val" or "key=val"`, fs) + } + + c.indexesLock.RLock() + defer c.indexesLock.RUnlock() + // Field selection is mimicked via indexes, so there's no sane answer this function can give + // if there are no indexes registered for the GroupVersionKind of the objects in the list. + indexes := c.indexes[gvk] + for _, req := range fs.Requirements() { + if len(indexes) == 0 || indexes[req.Field] == nil { + return nil, fmt.Errorf("List on GroupVersionKind %v specifies selector on field %s, but no "+ + "index with name %s has been registered for GroupVersionKind %v", gvk, req.Field, req.Field, gvk) + } + } + + filteredList := make([]runtime.Object, 0, len(list)) + for _, obj := range list { + matches := true + for _, req := range fs.Requirements() { + indexExtractor := indexes[req.Field] + if !c.objMatchesFieldSelector(obj, indexExtractor, req.Value) { + matches = false + break + } + } + if matches { + filteredList = append(filteredList, obj) + } + } + return filteredList, nil +} + +func (c *fakeClient) objMatchesFieldSelector(o runtime.Object, extractIndex client.IndexerFunc, val string) bool { + obj, isClientObject := o.(client.Object) + if !isClientObject { + panic(fmt.Errorf("expected object %v to be of type client.Object, but it's not", o)) + } + + for _, extractedVal := range extractIndex(obj) { + if extractedVal == val { + return true + } + } + + return false +} + +func (c *fakeClient) Scheme() *runtime.Scheme { + return c.scheme +} + +func (c *fakeClient) RESTMapper() meta.RESTMapper { + return c.restMapper +} + +// GroupVersionKindFor returns the GroupVersionKind for the given object. +func (c *fakeClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return apiutil.GVKForObject(obj, c.scheme) +} + +// IsObjectNamespaced returns true if the GroupVersionKind of the object is namespaced. +func (c *fakeClient) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return apiutil.IsObjectNamespaced(obj, c.scheme, c.restMapper) +} + +func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + createOptions := &client.CreateOptions{} + createOptions.ApplyOptions(opts) + + for _, dryRunOpt := range createOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + if accessor.GetName() == "" && accessor.GetGenerateName() != "" { + base := accessor.GetGenerateName() + if len(base) > maxGeneratedNameLength { + base = base[:maxGeneratedNameLength] + } + accessor.SetName(fmt.Sprintf("%s%s", base, utilrand.String(randomLength))) + } + // Ignore attempts to set deletion timestamp + if !accessor.GetDeletionTimestamp().IsZero() { + accessor.SetDeletionTimestamp(nil) + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + return c.tracker.Create(gvr, obj, accessor.GetNamespace()) +} + +func (c *fakeClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + delOptions := client.DeleteOptions{} + delOptions.ApplyOptions(opts) + + for _, dryRunOpt := range delOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + // Check the ResourceVersion if that Precondition was specified. + if delOptions.Preconditions != nil && delOptions.Preconditions.ResourceVersion != nil { + name := accessor.GetName() + dbObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), name) + if err != nil { + return err + } + oldAccessor, err := meta.Accessor(dbObj) + if err != nil { + return err + } + actualRV := oldAccessor.GetResourceVersion() + expectRV := *delOptions.Preconditions.ResourceVersion + if actualRV != expectRV { + msg := fmt.Sprintf( + "the ResourceVersion in the precondition (%s) does not match the ResourceVersion in record (%s). "+ + "The object might have been modified", + expectRV, actualRV) + return apierrors.NewConflict(gvr.GroupResource(), name, errors.New(msg)) + } + } + + return c.deleteObjectLocked(gvr, accessor) +} + +func (c *fakeClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + dcOptions := client.DeleteAllOfOptions{} + dcOptions.ApplyOptions(opts) + + for _, dryRunOpt := range dcOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + o, err := c.tracker.List(gvr, gvk, dcOptions.Namespace) + if err != nil { + return err + } + + objs, err := meta.ExtractList(o) + if err != nil { + return err + } + filteredObjs, err := objectutil.FilterWithLabels(objs, dcOptions.LabelSelector) + if err != nil { + return err + } + for _, o := range filteredObjs { + accessor, err := meta.Accessor(o) + if err != nil { + return err + } + err = c.deleteObjectLocked(gvr, accessor) + if err != nil { + return err + } + } + return nil +} + +func (c *fakeClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return c.update(obj, false, opts...) +} + +func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.UpdateOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + updateOptions := &client.UpdateOptions{} + updateOptions.ApplyOptions(opts) + + for _, dryRunOpt := range updateOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus, false, *updateOptions.AsUpdateOptions()) +} + +func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + return c.patch(obj, patch, opts...) +} + +func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + patchOptions := &client.PatchOptions{} + patchOptions.ApplyOptions(opts) + + for _, dryRunOpt := range patchOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + data, err := patch.Data(obj) + if err != nil { + return err + } + + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if err != nil { + return err + } + oldAccessor, err := meta.Accessor(oldObj) + if err != nil { + return err + } + + // Apply patch without updating object. + // To remain in accordance with the behavior of k8s api behavior, + // a patch must not allow for changes to the deletionTimestamp of an object. + // The reaction() function applies the patch to the object and calls Update(), + // whereas dryPatch() replicates this behavior but skips the call to Update(). + // This ensures that the patch may be rejected if a deletionTimestamp is modified, prior + // to updating the object. + action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data) + o, err := dryPatch(action, c.tracker) + if err != nil { + return err + } + newObj, err := meta.Accessor(o) + if err != nil { + return err + } + + // Validate that deletionTimestamp has not been changed + if !deletionTimestampEqual(newObj, oldAccessor) { + return fmt.Errorf("rejected patch, metadata.deletionTimestamp immutable") + } + + reaction := testing.ObjectReaction(c.tracker) + handled, o, err := reaction(action) + if err != nil { + return err + } + if !handled { + panic("tracker could not handle patch method") + } + + if _, isUnstructured := obj.(runtime.Unstructured); isUnstructured { + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + ta.SetKind(gvk.Kind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + return json.Unmarshal(j, obj) +} + +// Applying a patch results in a deletionTimestamp that is truncated to the nearest second. +// Check that the diff between a new and old deletion timestamp is within a reasonable threshold +// to be considered unchanged. +func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool { + newTime := newObj.GetDeletionTimestamp() + oldTime := obj.GetDeletionTimestamp() + + if newTime == nil || oldTime == nil { + return newTime == oldTime + } + return newTime.Time.Sub(oldTime.Time).Abs() < time.Second +} + +// The behavior of applying the patch is pulled out into dryPatch(), +// which applies the patch and returns an object, but does not Update() the object. +// This function returns a patched runtime object that may then be validated before a call to Update() is executed. +// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data +// and easier than refactoring the k8s client-go method upstream. +// Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194 +func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) { + ns := action.GetNamespace() + gvr := action.GetResource() + + obj, err := tracker.Get(gvr, ns, action.GetName()) + if err != nil { + return nil, err + } + + old, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + // reset the object in preparation to unmarshal, since unmarshal does not guarantee that fields + // in obj that are removed by patch are cleared + value := reflect.ValueOf(obj) + value.Elem().Set(reflect.New(value.Type().Elem()).Elem()) + + switch action.GetPatchType() { + case types.JSONPatchType: + patch, err := jsonpatch.DecodePatch(action.GetPatch()) + if err != nil { + return nil, err + } + modified, err := patch.Apply(old) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(modified, obj); err != nil { + return nil, err + } + case types.MergePatchType: + modified, err := jsonpatch.MergePatch(old, action.GetPatch()) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(modified, obj); err != nil { + return nil, err + } + case types.StrategicMergePatchType: + mergedByte, err := strategicpatch.StrategicMergePatch(old, action.GetPatch(), obj) + if err != nil { + return nil, err + } + if err = json.Unmarshal(mergedByte, obj); err != nil { + return nil, err + } + case types.ApplyPatchType: + return nil, errors.New("apply patches are not supported in the fake client. Follow https://github.com/kubernetes/kubernetes/issues/115598 for the current status") + case types.ApplyCBORPatchType: + return nil, errors.New("apply CBOR patches are not supported in the fake client") + default: + return nil, fmt.Errorf("%s PatchType is not supported", action.GetPatchType()) + } + return obj, nil +} + +// copyStatusFrom copies the status from old into new +func copyStatusFrom(old, n runtime.Object) error { + oldMapStringAny, err := toMapStringAny(old) + if err != nil { + return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err) + } + newMapStringAny, err := toMapStringAny(n) + if err != nil { + return fmt.Errorf("failed to convert new to *unststructured.Unstructured: %w", err) + } + + newMapStringAny["status"] = oldMapStringAny["status"] + + if err := fromMapStringAny(newMapStringAny, n); err != nil { + return fmt.Errorf("failed to convert back from map[string]any: %w", err) + } + + return nil +} + +// copyFrom copies from old into new +func copyFrom(old, n runtime.Object) error { + oldMapStringAny, err := toMapStringAny(old) + if err != nil { + return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err) + } + if err := fromMapStringAny(oldMapStringAny, n); err != nil { + return fmt.Errorf("failed to convert back from map[string]any: %w", err) + } + + return nil +} + +func toMapStringAny(obj runtime.Object) (map[string]any, error) { + if unstructured, isUnstructured := obj.(*unstructured.Unstructured); isUnstructured { + return unstructured.Object, nil + } + + serialized, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + u := map[string]any{} + return u, json.Unmarshal(serialized, &u) +} + +func fromMapStringAny(u map[string]any, target runtime.Object) error { + if targetUnstructured, isUnstructured := target.(*unstructured.Unstructured); isUnstructured { + targetUnstructured.Object = u + return nil + } + + serialized, err := json.Marshal(u) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + + zero(target) + if err := json.Unmarshal(serialized, &target); err != nil { + return fmt.Errorf("failed to deserialize: %w", err) + } + + return nil +} + +func (c *fakeClient) Status() client.SubResourceWriter { + return c.SubResource("status") +} + +func (c *fakeClient) SubResource(subResource string) client.SubResourceClient { + return &fakeSubResourceClient{client: c, subResource: subResource} +} + +func (c *fakeClient) deleteObjectLocked(gvr schema.GroupVersionResource, accessor metav1.Object) error { + old, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if err == nil { + oldAccessor, err := meta.Accessor(old) + if err == nil { + if len(oldAccessor.GetFinalizers()) > 0 { + now := metav1.Now() + oldAccessor.SetDeletionTimestamp(&now) + // Call update directly with mutability parameter set to true to allow + // changes to deletionTimestamp + return c.tracker.update(gvr, old, accessor.GetNamespace(), false, true, metav1.UpdateOptions{}) + } + } + } + + // TODO: implement propagation + return c.tracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName()) +} + +func getGVRFromObject(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionResource, error) { + gvk, err := apiutil.GVKForObject(obj, scheme) + if err != nil { + return schema.GroupVersionResource{}, err + } + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return gvr, nil +} + +type fakeSubResourceClient struct { + client *fakeClient + subResource string +} + +func (sw *fakeSubResourceClient) Get(ctx context.Context, obj, subResource client.Object, opts ...client.SubResourceGetOption) error { + switch sw.subResource { + case subResourceScale: + // Actual client looks up resource, then extracts the scale sub-resource: + // https://github.com/kubernetes/kubernetes/blob/fb6bbc9781d11a87688c398778525c4e1dcb0f08/pkg/registry/apps/deployment/storage/storage.go#L307 + if err := sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + return err + } + scale, isScale := subResource.(*autoscalingv1.Scale) + if !isScale { + return apierrors.NewBadRequest(fmt.Sprintf("expected Scale, got %T", subResource)) + } + scaleOut, err := extractScale(obj) + if err != nil { + return err + } + *scale = *scaleOut + return nil + default: + return fmt.Errorf("fakeSubResourceClient does not support get for %s", sw.subResource) + } +} + +func (sw *fakeSubResourceClient) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + switch sw.subResource { + case "eviction": + _, isEviction := subResource.(*policyv1beta1.Eviction) + if !isEviction { + _, isEviction = subResource.(*policyv1.Eviction) + } + if !isEviction { + return apierrors.NewBadRequest(fmt.Sprintf("got invalid type %T, expected Eviction", subResource)) + } + if _, isPod := obj.(*corev1.Pod); !isPod { + return apierrors.NewNotFound(schema.GroupResource{}, "") + } + + return sw.client.Delete(ctx, obj) + case "token": + tokenRequest, isTokenRequest := subResource.(*authenticationv1.TokenRequest) + if !isTokenRequest { + return apierrors.NewBadRequest(fmt.Sprintf("got invalid type %T, expected TokenRequest", subResource)) + } + if _, isServiceAccount := obj.(*corev1.ServiceAccount); !isServiceAccount { + return apierrors.NewNotFound(schema.GroupResource{}, "") + } + + tokenRequest.Status.Token = "fake-token" + tokenRequest.Status.ExpirationTimestamp = metav1.Date(6041, 1, 1, 0, 0, 0, 0, time.UTC) + + return sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj) + default: + return fmt.Errorf("fakeSubResourceWriter does not support create for %s", sw.subResource) + } +} + +func (sw *fakeSubResourceClient) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + updateOptions := client.SubResourceUpdateOptions{} + updateOptions.ApplyOptions(opts) + + switch sw.subResource { + case subResourceScale: + if err := sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj.DeepCopyObject().(client.Object)); err != nil { + return err + } + if updateOptions.SubResourceBody == nil { + return apierrors.NewBadRequest("missing SubResourceBody") + } + + scale, isScale := updateOptions.SubResourceBody.(*autoscalingv1.Scale) + if !isScale { + return apierrors.NewBadRequest(fmt.Sprintf("expected Scale, got %T", updateOptions.SubResourceBody)) + } + if err := applyScale(obj, scale); err != nil { + return err + } + return sw.client.update(obj, false, &updateOptions.UpdateOptions) + default: + body := obj + if updateOptions.SubResourceBody != nil { + body = updateOptions.SubResourceBody + } + return sw.client.update(body, true, &updateOptions.UpdateOptions) + } +} + +func (sw *fakeSubResourceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + patchOptions := client.SubResourcePatchOptions{} + patchOptions.ApplyOptions(opts) + + body := obj + if patchOptions.SubResourceBody != nil { + body = patchOptions.SubResourceBody + } + + // this is necessary to identify that last call was made for status patch, through stack trace. + if sw.subResource == "status" { + return sw.statusPatch(body, patch, patchOptions) + } + + return sw.client.patch(body, patch, &patchOptions.PatchOptions) +} + +func (sw *fakeSubResourceClient) statusPatch(body client.Object, patch client.Patch, patchOptions client.SubResourcePatchOptions) error { + return sw.client.patch(body, patch, &patchOptions.PatchOptions) +} + +func allowsUnconditionalUpdate(gvk schema.GroupVersionKind) bool { + switch gvk.Group { + case "apps": + switch gvk.Kind { + case "ControllerRevision", "DaemonSet", "Deployment", "ReplicaSet", "StatefulSet": + return true + } + case "autoscaling": + switch gvk.Kind { + case "HorizontalPodAutoscaler": + return true + } + case "batch": + switch gvk.Kind { + case "CronJob", "Job": + return true + } + case "certificates": + switch gvk.Kind { + case "Certificates": + return true + } + case "flowcontrol": + switch gvk.Kind { + case "FlowSchema", "PriorityLevelConfiguration": + return true + } + case "networking": + switch gvk.Kind { + case "Ingress", "IngressClass", "NetworkPolicy": + return true + } + case "policy": + switch gvk.Kind { + case "PodSecurityPolicy": + return true + } + case "rbac.authorization.k8s.io": + switch gvk.Kind { + case "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding": + return true + } + case "scheduling": + switch gvk.Kind { + case "PriorityClass": + return true + } + case "settings": + switch gvk.Kind { + case "PodPreset": + return true + } + case "storage": + switch gvk.Kind { + case "StorageClass": + return true + } + case "": + switch gvk.Kind { + case "ConfigMap", "Endpoint", "Event", "LimitRange", "Namespace", "Node", + "PersistentVolume", "PersistentVolumeClaim", "Pod", "PodTemplate", + "ReplicationController", "ResourceQuota", "Secret", "Service", + "ServiceAccount", "EndpointSlice": + return true + } + } + + return false +} + +func allowsCreateOnUpdate(gvk schema.GroupVersionKind) bool { + switch gvk.Group { + case "coordination": + switch gvk.Kind { + case "Lease": + return true + } + case "node": + switch gvk.Kind { + case "RuntimeClass": + return true + } + case "rbac": + switch gvk.Kind { + case "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding": + return true + } + case "": + switch gvk.Kind { + case "Endpoint", "Event", "LimitRange", "Service": + return true + } + } + + return false +} + +func inTreeResourcesWithStatus() []schema.GroupVersionKind { + return []schema.GroupVersionKind{ + {Version: "v1", Kind: "Namespace"}, + {Version: "v1", Kind: "Node"}, + {Version: "v1", Kind: "PersistentVolumeClaim"}, + {Version: "v1", Kind: "PersistentVolume"}, + {Version: "v1", Kind: "Pod"}, + {Version: "v1", Kind: "ReplicationController"}, + {Version: "v1", Kind: "Service"}, + + {Group: "apps", Version: "v1", Kind: "Deployment"}, + {Group: "apps", Version: "v1", Kind: "DaemonSet"}, + {Group: "apps", Version: "v1", Kind: "ReplicaSet"}, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}, + + {Group: "autoscaling", Version: "v1", Kind: "HorizontalPodAutoscaler"}, + + {Group: "batch", Version: "v1", Kind: "CronJob"}, + {Group: "batch", Version: "v1", Kind: "Job"}, + + {Group: "certificates.k8s.io", Version: "v1", Kind: "CertificateSigningRequest"}, + + {Group: "networking.k8s.io", Version: "v1", Kind: "Ingress"}, + {Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"}, + + {Group: "policy", Version: "v1", Kind: "PodDisruptionBudget"}, + + {Group: "storage.k8s.io", Version: "v1", Kind: "VolumeAttachment"}, + + {Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, + + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "FlowSchema"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "PriorityLevelConfiguration"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1", Kind: "FlowSchema"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1", Kind: "PriorityLevelConfiguration"}, + } +} + +// zero zeros the value of a pointer. +func zero(x interface{}) { + if x == nil { + return + } + res := reflect.ValueOf(x).Elem() + res.Set(reflect.Zero(res.Type())) +} + +// getSingleOrZeroOptions returns the single options value in the slice, its +// zero value if the slice is empty, or an error if the slice contains more than +// one option value. +func getSingleOrZeroOptions[T any](opts []T) (opt T, err error) { + switch len(opts) { + case 0: + case 1: + opt = opts[0] + default: + err = fmt.Errorf("expected single or no options value, got %d values", len(opts)) + } + return +} + +func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { + switch obj := obj.(type) { + case *appsv1.Deployment: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + case *appsv1.ReplicaSet: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + case *corev1.ReplicationController: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: labels.Set(obj.Spec.Selector).String(), + }, + }, nil + case *appsv1.StatefulSet: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + default: + // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + return nil, fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } +} + +func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { + switch obj := obj.(type) { + case *appsv1.Deployment: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *appsv1.ReplicaSet: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *corev1.ReplicationController: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *appsv1.StatefulSet: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + default: + // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + return fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } + return nil +} + +// AddIndex adds an index to a fake client. It will panic if used with a client that is not a fake client. +// It will error if there is already an index for given object with the same name as field. +// +// It can be used to test code that adds indexes to the cache at runtime. +func AddIndex(c client.Client, obj runtime.Object, field string, extractValue client.IndexerFunc) error { + fakeClient, isFakeClient := c.(*fakeClient) + if !isFakeClient { + panic("AddIndex can only be used with a fake client") + } + fakeClient.indexesLock.Lock() + defer fakeClient.indexesLock.Unlock() + + if fakeClient.indexes == nil { + fakeClient.indexes = make(map[schema.GroupVersionKind]map[string]client.IndexerFunc, 1) + } + + gvk, err := apiutil.GVKForObject(obj, fakeClient.scheme) + if err != nil { + return fmt.Errorf("failed to get gvk for %T: %w", obj, err) + } + + if fakeClient.indexes[gvk] == nil { + fakeClient.indexes[gvk] = make(map[string]client.IndexerFunc, 1) + } + + if fakeClient.indexes[gvk][field] != nil { + return fmt.Errorf("index %s already exists", field) + } + + fakeClient.indexes[gvk][field] = extractValue + + return nil +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go new file mode 100644 index 000000000..47cad3980 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go @@ -0,0 +1,38 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package fake provides a fake client for testing. + +A fake client is backed by its simple object store indexed by GroupVersionResource. +You can create a fake client with optional objects. + + client := NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + +You can invoke the methods defined in the Client interface. + +When in doubt, it's almost always better not to use this package and instead use +envtest.Environment with a real client and API server. + +WARNING: ⚠️ Current Limitations / Known Issues with the fake Client ⚠️ + - This client does not have a way to inject specific errors to test handled vs. unhandled errors. + - There is some support for sub resources which can cause issues with tests if you're trying to update + e.g. metadata and status in the same reconcile. + - No OpenAPI validation is performed when creating or updating objects. + - ObjectMeta's `Generation` and `ResourceVersion` don't behave properly, Patch or Update + operations that rely on these fields will fail, or give false positives. +*/ +package fake diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go new file mode 100644 index 000000000..3d3f3cb01 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go @@ -0,0 +1,166 @@ +package interceptor + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Funcs contains functions that are called instead of the underlying client's methods. +type Funcs struct { + Get func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error + List func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error + Create func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error + Delete func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error + DeleteAllOf func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteAllOfOption) error + Update func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error + Patch func(ctx context.Context, client client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error + Watch func(ctx context.Context, client client.WithWatch, obj client.ObjectList, opts ...client.ListOption) (watch.Interface, error) + SubResource func(client client.WithWatch, subResource string) client.SubResourceClient + SubResourceGet func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error + SubResourceCreate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error + SubResourceUpdate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error + SubResourcePatch func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error +} + +// NewClient returns a new interceptor client that calls the functions in funcs instead of the underlying client's methods, if they are not nil. +func NewClient(interceptedClient client.WithWatch, funcs Funcs) client.WithWatch { + return interceptor{ + client: interceptedClient, + funcs: funcs, + } +} + +type interceptor struct { + client client.WithWatch + funcs Funcs +} + +var _ client.WithWatch = &interceptor{} + +func (c interceptor) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return c.client.GroupVersionKindFor(obj) +} + +func (c interceptor) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return c.client.IsObjectNamespaced(obj) +} + +func (c interceptor) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if c.funcs.Get != nil { + return c.funcs.Get(ctx, c.client, key, obj, opts...) + } + return c.client.Get(ctx, key, obj, opts...) +} + +func (c interceptor) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if c.funcs.List != nil { + return c.funcs.List(ctx, c.client, list, opts...) + } + return c.client.List(ctx, list, opts...) +} + +func (c interceptor) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if c.funcs.Create != nil { + return c.funcs.Create(ctx, c.client, obj, opts...) + } + return c.client.Create(ctx, obj, opts...) +} + +func (c interceptor) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if c.funcs.Delete != nil { + return c.funcs.Delete(ctx, c.client, obj, opts...) + } + return c.client.Delete(ctx, obj, opts...) +} + +func (c interceptor) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if c.funcs.Update != nil { + return c.funcs.Update(ctx, c.client, obj, opts...) + } + return c.client.Update(ctx, obj, opts...) +} + +func (c interceptor) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if c.funcs.Patch != nil { + return c.funcs.Patch(ctx, c.client, obj, patch, opts...) + } + return c.client.Patch(ctx, obj, patch, opts...) +} + +func (c interceptor) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + if c.funcs.DeleteAllOf != nil { + return c.funcs.DeleteAllOf(ctx, c.client, obj, opts...) + } + return c.client.DeleteAllOf(ctx, obj, opts...) +} + +func (c interceptor) Status() client.SubResourceWriter { + return c.SubResource("status") +} + +func (c interceptor) SubResource(subResource string) client.SubResourceClient { + if c.funcs.SubResource != nil { + return c.funcs.SubResource(c.client, subResource) + } + return subResourceInterceptor{ + subResourceName: subResource, + client: c.client, + funcs: c.funcs, + } +} + +func (c interceptor) Scheme() *runtime.Scheme { + return c.client.Scheme() +} + +func (c interceptor) RESTMapper() meta.RESTMapper { + return c.client.RESTMapper() +} + +func (c interceptor) Watch(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + if c.funcs.Watch != nil { + return c.funcs.Watch(ctx, c.client, obj, opts...) + } + return c.client.Watch(ctx, obj, opts...) +} + +type subResourceInterceptor struct { + subResourceName string + client client.Client + funcs Funcs +} + +var _ client.SubResourceClient = &subResourceInterceptor{} + +func (s subResourceInterceptor) Get(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error { + if s.funcs.SubResourceGet != nil { + return s.funcs.SubResourceGet(ctx, s.client, s.subResourceName, obj, subResource, opts...) + } + return s.client.SubResource(s.subResourceName).Get(ctx, obj, subResource, opts...) +} + +func (s subResourceInterceptor) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + if s.funcs.SubResourceCreate != nil { + return s.funcs.SubResourceCreate(ctx, s.client, s.subResourceName, obj, subResource, opts...) + } + return s.client.SubResource(s.subResourceName).Create(ctx, obj, subResource, opts...) +} + +func (s subResourceInterceptor) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if s.funcs.SubResourceUpdate != nil { + return s.funcs.SubResourceUpdate(ctx, s.client, s.subResourceName, obj, opts...) + } + return s.client.SubResource(s.subResourceName).Update(ctx, obj, opts...) +} + +func (s subResourceInterceptor) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if s.funcs.SubResourcePatch != nil { + return s.funcs.SubResourcePatch(ctx, s.client, s.subResourceName, obj, patch, opts...) + } + return s.client.SubResource(s.subResourceName).Patch(ctx, obj, patch, opts...) +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go b/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go new file mode 100644 index 000000000..0189c0432 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go @@ -0,0 +1,42 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package objectutil + +import ( + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +// FilterWithLabels returns a copy of the items in objs matching labelSel. +func FilterWithLabels(objs []runtime.Object, labelSel labels.Selector) ([]runtime.Object, error) { + outItems := make([]runtime.Object, 0, len(objs)) + for _, obj := range objs { + meta, err := apimeta.Accessor(obj) + if err != nil { + return nil, err + } + if labelSel != nil { + lbls := labels.Set(meta.GetLabels()) + if !labelSel.Matches(lbls) { + continue + } + } + outItems = append(outItems, obj.DeepCopyObject()) + } + return outItems, nil +} From 17726f0b787a5879796b13263c1cfc8dff58b570 Mon Sep 17 00:00:00 2001 From: Ankita Thomas Date: Tue, 11 Nov 2025 16:28:54 -0500 Subject: [PATCH 3/4] certpool mutex, variable rename Signed-off-by: Ankita Thomas --- cmd/manager/main.go | 2 +- pkg/certificateauthority/clientcaconfigmap.go | 10 +++++++++- pkg/controller/configmap/configmap_controller.go | 8 ++++---- pkg/controller/configmap/configmap_controller_test.go | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 965ee2d8b..952413e56 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -164,7 +164,7 @@ func main() { }, configmap.ClientCANamespace: { FieldSelector: fields.SelectorFromSet(fields.Set{ - "metadata.name": configmap.ClientCAConfigMap, + "metadata.name": configmap.ClientCAConfigMapName, }), }, }, diff --git a/pkg/certificateauthority/clientcaconfigmap.go b/pkg/certificateauthority/clientcaconfigmap.go index f424a97e0..c00c919b8 100644 --- a/pkg/certificateauthority/clientcaconfigmap.go +++ b/pkg/certificateauthority/clientcaconfigmap.go @@ -1,9 +1,13 @@ package certificateauthority -import "crypto/x509" +import ( + "crypto/x509" + "sync" +) type ClientCAStore struct { clientCA *x509.CertPool + mutex sync.RWMutex } func NewClientCAStore(certpool *x509.CertPool) *ClientCAStore { @@ -17,9 +21,13 @@ func (c *ClientCAStore) Update(newCAPEM []byte) { if newCAPEM == nil { return } + c.mutex.Lock() + defer c.mutex.Unlock() c.clientCA.AppendCertsFromPEM(newCAPEM) } func (c *ClientCAStore) GetCA() *x509.CertPool { + c.mutex.RLock() + defer c.mutex.RUnlock() return c.clientCA } \ No newline at end of file diff --git a/pkg/controller/configmap/configmap_controller.go b/pkg/controller/configmap/configmap_controller.go index 05ff2d88e..07a5f22f0 100644 --- a/pkg/controller/configmap/configmap_controller.go +++ b/pkg/controller/configmap/configmap_controller.go @@ -24,7 +24,7 @@ const ( // kube-system/extension-apiserver-authentication configMap, under the key // client-ca-file. ClientCANamespace = "kube-system" - ClientCAConfigMap = "extension-apiserver-authentication" + ClientCAConfigMapName = "extension-apiserver-authentication" ClientCAKey = "client-ca-file" ) @@ -65,7 +65,7 @@ func getPredicateFunctions() predicate.Funcs { return predicate.Funcs{ CreateFunc: func(e event.CreateEvent) bool { // If the ConfigMap is created we should kick off an event. - if e.Object.GetName() == ClientCAConfigMap && + if e.Object.GetName() == ClientCAConfigMapName && e.Object.GetNamespace() == ClientCANamespace { return true } @@ -76,7 +76,7 @@ func getPredicateFunctions() predicate.Funcs { }, UpdateFunc: func(e event.UpdateEvent) bool { // If the ConfigMap is updated we should kick off an event. - if e.ObjectOld.GetName() == ClientCAConfigMap && + if e.ObjectOld.GetName() == ClientCAConfigMapName && e.ObjectOld.GetNamespace() == ClientCANamespace { return true } @@ -108,7 +108,7 @@ type ReconcileConfigMap struct { func (r *ReconcileConfigMap) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { log.Printf("Reconciling ConfigMap %s/%s", request.Namespace, request.Name) - if request.Name == ClientCAConfigMap && request.Namespace == ClientCANamespace { + if request.Name == ClientCAConfigMapName && request.Namespace == ClientCANamespace { return r.updateClientCA(ctx, request) } // Check if the CA ConfigMap is in the same namespace that Marketplace is deployed in. diff --git a/pkg/controller/configmap/configmap_controller_test.go b/pkg/controller/configmap/configmap_controller_test.go index 2bbe8e9c2..36853c5d3 100644 --- a/pkg/controller/configmap/configmap_controller_test.go +++ b/pkg/controller/configmap/configmap_controller_test.go @@ -98,7 +98,7 @@ func (i *certKeyPair) generateTestCert(parentCert *x509.Certificate, parentKey * } func updateTestCAConfigMap(client crclient.WithWatch, reconciler *configmap.ReconcileConfigMap, caPEM []byte) error { - cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configmap.ClientCAConfigMap, Namespace: configmap.ClientCANamespace}, Data:map[string]string{configmap.ClientCAKey: string(caPEM)}} + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configmap.ClientCAConfigMapName, Namespace: configmap.ClientCANamespace}, Data:map[string]string{configmap.ClientCAKey: string(caPEM)}} if err := client.Create(context.TODO(), cm); err != nil { if errors.IsAlreadyExists(err) { err = client.Update(context.TODO(), cm) From 9dafa2461d1870fecb780b60301448913cf3a9f6 Mon Sep 17 00:00:00 2001 From: Ankita Thomas Date: Thu, 13 Nov 2025 10:43:20 -0500 Subject: [PATCH 4/4] initialize client ca certpool Signed-off-by: Ankita Thomas --- cmd/manager/main.go | 28 ++++++++++++++----- pkg/certificateauthority/clientcaconfigmap.go | 2 +- .../configmap/configmap_controller.go | 26 +++++++++++++---- pkg/metrics/metrics.go | 6 +++- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 952413e56..13e43cab8 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -13,6 +13,7 @@ import ( ca "github.com/operator-framework/operator-marketplace/pkg/certificateauthority" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -114,13 +115,6 @@ func main() { os.Exit(0) } - clientCAStore := ca.NewClientCAStore(x509.NewCertPool()) - // set TLS to serve metrics over a secure channel if cert is provided - // cert is provided by default by the marketplace-trusted-ca volume mounted as part of the marketplace-operator deployment - if err := metrics.ServePrometheus(tlsCertPath, tlsKeyPath, clientCAStore); err != nil { - logger.Fatalf("failed to serve prometheus metrics: %s", err) - } - namespace, err := apiutils.GetWatchNamespace() if err != nil { logger.Fatalf("failed to get watch namespace: %v", err) @@ -176,6 +170,26 @@ func main() { logger.Fatal(err) } + clientCAStore := ca.NewClientCAStore(x509.NewCertPool()) + // Best effort attempt to fetch client rootCA + // Should not fail if this does not immediately succeed, the configmap controller will continue to + // watch for the right configmap for updating this certpool as soon as it is created. + caData, err := configmap.GetClientCAFromConfigMap(context.TODO(), mgr.GetClient(), types.NamespacedName{Name: configmap.ClientCAConfigMapName, Namespace: configmap.ClientCANamespace}) + if err == nil && len(caData) > 0 { + clientCAStore.Update(caData) + } else if err != nil { + logger.Warn("failed to initialize client CA certPool for the metrics endpoint: %w", err) + } else if len(caData) == 0 { + logger.Warn("could not find client CA to initialize client rootCA certpool, the clientCA configMap may not be initialized properly yet") + } + + // set TLS to serve metrics over a secure channel if cert is provided + // cert is provided by default by the marketplace-trusted-ca volume mounted as part of the marketplace-operator deployment + if err := metrics.ServePrometheus(tlsCertPath, tlsKeyPath, clientCAStore); err != nil { + logger.Fatalf("failed to serve prometheus metrics: %s", err) + } + + logger.Info("setting up health checks") http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/pkg/certificateauthority/clientcaconfigmap.go b/pkg/certificateauthority/clientcaconfigmap.go index c00c919b8..2586d1394 100644 --- a/pkg/certificateauthority/clientcaconfigmap.go +++ b/pkg/certificateauthority/clientcaconfigmap.go @@ -30,4 +30,4 @@ func (c *ClientCAStore) GetCA() *x509.CertPool { c.mutex.RLock() defer c.mutex.RUnlock() return c.clientCA -} \ No newline at end of file +} diff --git a/pkg/controller/configmap/configmap_controller.go b/pkg/controller/configmap/configmap_controller.go index 07a5f22f0..d70008149 100644 --- a/pkg/controller/configmap/configmap_controller.go +++ b/pkg/controller/configmap/configmap_controller.go @@ -12,6 +12,7 @@ import ( "github.com/operator-framework/operator-marketplace/pkg/controller/options" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -139,17 +140,30 @@ func isRunningOnPod() bool { } func (r *ReconcileConfigMap) updateClientCA(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + caData, err := GetClientCAFromConfigMap(ctx, r.client, request.NamespacedName) + if err != nil { + return reconcile.Result{}, err + } + if len(caData) == 0 { + return reconcile.Result{}, nil + } + r.clientCAStore.Update(caData) + return reconcile.Result{}, nil +} + +// GetClientCAFromConfigMap is used for fetching the clientCA from the provided configMap reference. +// This is used both for initializing and updating the cached clientCA certPool. +func GetClientCAFromConfigMap(ctx context.Context, c client.Client, configMapKey types.NamespacedName) ([]byte, error) { // Get configMap object clientCAConfigMap := &corev1.ConfigMap{} - if err := r.client.Get(ctx, request.NamespacedName, clientCAConfigMap); err != nil { + if err := c.Get(ctx, configMapKey, clientCAConfigMap); err != nil { // Requested object was not found, could have been deleted after reconcile request. // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. // Return and don't requeue - return reconcile.Result{}, client.IgnoreNotFound(err) + return nil, client.IgnoreNotFound(err) } if newCA, ok := clientCAConfigMap.Data[ClientCAKey]; ok { - r.clientCAStore.Update([]byte(newCA)) + return []byte(newCA), nil } - - return reconcile.Result{}, nil -} \ No newline at end of file + return nil, nil +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 9363da410..33acdfcda 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -57,7 +57,11 @@ func ServePrometheus(cert, key string, clientCAStore *certificateauthority.Clien httpsServer.TLSConfig.ClientCAs = clientCAStore.GetCA() httpsServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert } else { - logrus.Warnf("No client CA configured, continuing without client cert verification") + // enforce client cert requirement. + // Without this check, the client cert auth policy would be optional on startup + // if provided with a nil clientCAStore. + logrus.Errorf("No client CA configured, continuing without client cert verification") + return } err := httpsServer.ListenAndServeTLS("", "") if err != nil {