diff --git a/internal/clientfactory/client.go b/internal/clientfactory/client.go index c2ca9657..96cb511e 100644 --- a/internal/clientfactory/client.go +++ b/internal/clientfactory/client.go @@ -26,6 +26,8 @@ func NewClientFor(config *rest.Config, scheme *runtime.Scheme, name string) (*Cl if err != nil { return nil, err } + // TODO: a full clientset which contains all builtin groups is actually not needed here; + // a clientset for corev1 events, plus discovery would be sufficient clientset, err := kubernetes.NewForConfigAndClient(config, httpClient) if err != nil { return nil, err diff --git a/internal/clientfactory/client_test.go b/internal/clientfactory/client_test.go new file mode 100644 index 00000000..b8fdf030 --- /dev/null +++ b/internal/clientfactory/client_test.go @@ -0,0 +1,71 @@ +/* +SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and component-operator-runtime contributors +SPDX-License-Identifier: Apache-2.0 +*/ + +package clientfactory + +import ( + "context" + "fmt" + "time" + + "github.com/sap/go-generics/slices" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + apitypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("testing: client.go", func() { + + var scheme *runtime.Scheme + + BeforeEach(func() { + scheme = runtime.NewScheme() + corev1.AddToScheme(scheme) + }) + + It("should create a functional client", func() { + clnt, err := NewClientFor(env.Config(), scheme, "test-controller") + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Config()).To(Equal(env.Config())) + + resp, err := clnt.HttpClient().Get(fmt.Sprintf("%sversion", env.Config().Host)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + + kubeSystemNamespace := &corev1.Namespace{} + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, kubeSystemNamespace) + Expect(err).NotTo(HaveOccurred()) + + version, err := clnt.DiscoveryClient().ServerVersion() + Expect(err).NotTo(HaveOccurred()) + Expect(version.String()).To(Equal(env.Version().String())) + clnt.EventRecorder().Event(kubeSystemNamespace, corev1.EventTypeNormal, "TestEvent", "This is a test event") + Eventually(func() error { + eventList := &corev1.EventList{} + selector, err := fields.ParseSelector("involvedObject.apiVersion=v1,involvedObject.kind=Namespace,involvedObject.name=kube-system") + if err != nil { + return err + } + err = clnt.List(context.Background(), eventList, client.InNamespace("default"), client.MatchingFieldsSelector{Selector: selector}) + if err != nil { + return err + } + if slices.Any(eventList.Items, func(event corev1.Event) bool { + return event.Source.Component == "test-controller" && event.Reason == "TestEvent" && event.Message == "This is a test event" + }) { + return nil + } + return fmt.Errorf("event not found") + }, 10*time.Second, 1*time.Second).Should(Succeed()) + }) + +}) diff --git a/internal/clientfactory/factory.go b/internal/clientfactory/factory.go index 318e6ca5..f4936798 100644 --- a/internal/clientfactory/factory.go +++ b/internal/clientfactory/factory.go @@ -29,6 +29,7 @@ type ClientFactory struct { name string controllerName string config *rest.Config + validity time.Duration scheme *runtime.Scheme clients map[string]*Client } @@ -37,6 +38,8 @@ const validity = 15 * time.Minute func NewClientFactory(name string, controllerName string, config *rest.Config, schemeBuilders []types.SchemeBuilder) (*ClientFactory, error) { scheme := runtime.NewScheme() + // note: it would be sufficient to just add the corev1 scheme instead of the whole client-go scheme; + // but for convenience, we add all the api groups if err := clientgoscheme.AddToScheme(scheme); err != nil { return nil, err } @@ -56,6 +59,7 @@ func NewClientFactory(name string, controllerName string, config *rest.Config, s name: name, controllerName: controllerName, config: config, + validity: validity, scheme: scheme, clients: make(map[string]*Client), } @@ -116,7 +120,7 @@ func (f *ClientFactory) Get(kubeConfig []byte, impersonationUser string, imperso key := sha256sum(keyData) if clnt, ok := f.clients[key]; ok { - clnt.validUntil = time.Now().Add(validity) + clnt.validUntil = time.Now().Add(f.validity) return clnt, nil } @@ -124,7 +128,7 @@ func (f *ClientFactory) Get(kubeConfig []byte, impersonationUser string, imperso if err != nil { return nil, err } - clnt.validUntil = time.Now().Add(validity) + clnt.validUntil = time.Now().Add(f.validity) f.clients[key] = clnt metrics.CreatedClients.WithLabelValues(f.controllerName).Inc() metrics.ActiveClients.WithLabelValues(f.controllerName).Set(float64(len(f.clients))) @@ -134,6 +138,7 @@ func (f *ClientFactory) Get(kubeConfig []byte, impersonationUser string, imperso return clnt, nil } +// TODO: this could be repplaced by util.CalculateDigest() func sha256sum(data any) string { dataAsJson, err := json.Marshal(data) if err != nil { diff --git a/internal/clientfactory/factory_test.go b/internal/clientfactory/factory_test.go new file mode 100644 index 00000000..963304be --- /dev/null +++ b/internal/clientfactory/factory_test.go @@ -0,0 +1,182 @@ +/* +SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and component-operator-runtime contributors +SPDX-License-Identifier: Apache-2.0 +*/ + +package clientfactory + +import ( + "context" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + apitypes "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/sap/component-operator-runtime/pkg/types" + cstestingv1alpha1 "github.com/sap/component-operator-runtime/testing/environment/apis/testing.cs.sap.com/v1alpha1" +) + +var _ = Describe("testing: factory.go", func() { + + var factory *ClientFactory + var namespace string + + BeforeEach(func() { + var err error + + schemeBuilder := runtime.NewSchemeBuilder(cstestingv1alpha1.AddToScheme) + + factory, err = NewClientFactory("test-controller", "testing", env.Config(), []types.SchemeBuilder{&schemeBuilder}) + Expect(err).NotTo(HaveOccurred()) + + namespace, err = env.CreateNamespace() + Expect(err).NotTo(HaveOccurred()) + + err = env.CreateObject( + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create a functional client without impersonation", func() { + clnt, err := factory.Get(nil, "", nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiregistration.k8s.io", Version: "v1", Kind: "APIService"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "testing.cs.sap.com", Version: "v1alpha1", Kind: "Foo"})).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, &corev1.Namespace{}) + Expect(err).NotTo(HaveOccurred()) + + clnt, err = factory.Get([]byte{}, "", nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiregistration.k8s.io", Version: "v1", Kind: "APIService"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "testing.cs.sap.com", Version: "v1alpha1", Kind: "Foo"})).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, &corev1.Namespace{}) + Expect(err).NotTo(HaveOccurred()) + + clnt, err = factory.Get([]byte(env.KubeConfig()), "", nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiregistration.k8s.io", Version: "v1", Kind: "APIService"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "testing.cs.sap.com", Version: "v1alpha1", Kind: "Foo"})).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, &corev1.Namespace{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create a functional client with impersonation (service account)", func() { + clnt, err := factory.Get(nil, "system:serviceaccount:default:configmap-reader", nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiregistration.k8s.io", Version: "v1", Kind: "APIService"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "testing.cs.sap.com", Version: "v1alpha1", Kind: "Foo"})).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, &corev1.Namespace{}) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsForbidden(err)).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Namespace: namespace, Name: "test"}, &corev1.ConfigMap{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create a functional client with impersonation (user)", func() { + clnt, err := factory.Get(nil, "configmap-reader", nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiregistration.k8s.io", Version: "v1", Kind: "APIService"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "testing.cs.sap.com", Version: "v1alpha1", Kind: "Foo"})).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, &corev1.Namespace{}) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsForbidden(err)).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Namespace: namespace, Name: "test"}, &corev1.ConfigMap{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create a functional client with impersonation (group)", func() { + clnt, err := factory.Get(nil, "nobody", []string{"configmap-readers"}) + Expect(err).NotTo(HaveOccurred()) + + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "apiregistration.k8s.io", Version: "v1", Kind: "APIService"})).To(BeTrue()) + Expect(clnt.Scheme().Recognizes(schema.GroupVersionKind{Group: "testing.cs.sap.com", Version: "v1alpha1", Kind: "Foo"})).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Name: "kube-system"}, &corev1.Namespace{}) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsForbidden(err)).To(BeTrue()) + + err = clnt.Get(context.Background(), apitypes.NamespacedName{Namespace: namespace, Name: "test"}, &corev1.ConfigMap{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return cached clients and evict expired clients", func() { + factory.validity = 5 * time.Second + + clnt, err := factory.Get(nil, "", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(1)) + + clnt2, err := factory.Get(nil, "", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(1)) + Expect(clnt2).To(BeIdenticalTo(clnt)) + + clnt, err = factory.Get([]byte(env.KubeConfig()), "", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(2)) + + clnt2, err = factory.Get([]byte(env.KubeConfig()), "", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(2)) + Expect(clnt2).To(BeIdenticalTo(clnt)) + + clnt, err = factory.Get([]byte(env.KubeConfig()), "user", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(3)) + + clnt2, err = factory.Get([]byte(env.KubeConfig()), "user", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(3)) + Expect(clnt2).To(BeIdenticalTo(clnt)) + + clnt, err = factory.Get([]byte(env.KubeConfig()), "user", []string{"group"}) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(4)) + + clnt2, err = factory.Get([]byte(env.KubeConfig()), "user", []string{"group"}) + Expect(err).NotTo(HaveOccurred()) + Expect(factory.clients).To(HaveLen(4)) + Expect(clnt2).To(BeIdenticalTo(clnt)) + + Eventually(func() int { + return len(factory.clients) + }, 15*time.Second, 1*time.Second).Should(Equal(0)) + }) + +}) diff --git a/internal/clientfactory/suite_test.go b/internal/clientfactory/suite_test.go new file mode 100644 index 00000000..03f2fd4b --- /dev/null +++ b/internal/clientfactory/suite_test.go @@ -0,0 +1,85 @@ +/* +SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and component-operator-runtime contributors +SPDX-License-Identifier: Apache-2.0 +*/ + +package clientfactory + +import ( + "os" + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/sap/component-operator-runtime/testing/environment" +) + +func TestPackage(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Package Suite: internal/clientfactory") +} + +var env *environment.Environment + +var _ = BeforeSuite(func() { + By("initializing suite") + var err error + env, err = environment.Run(os.Stdout, os.Stderr, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + err = env.CreateObject( + &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "configmap-reader", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + }, + ) + Expect(err).NotTo(HaveOccurred()) + err = env.CreateObject( + &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "configmap-reader", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "default", + Name: "configmap-reader", + }, + { + APIGroup: rbacv1.GroupName, + Kind: "User", + Name: "configmap-reader", + }, + { + APIGroup: rbacv1.GroupName, + Kind: "Group", + Name: "configmap-readers", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "configmap-reader", + }, + }, + ) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + By("tearing down suite") + err := env.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/testing/environment/environment.go b/testing/environment/environment.go index 62b00fed..7e85bffe 100644 --- a/testing/environment/environment.go +++ b/testing/environment/environment.go @@ -24,6 +24,7 @@ import ( apitypes "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/retry" @@ -98,7 +99,7 @@ func Run(stdout io.Writer, stderr io.Writer, logger io.Writer) (_ *Environment, } scheme := runtime.NewScheme() - corev1.AddToScheme(scheme) + clientgoscheme.AddToScheme(scheme) apiextensionsv1.AddToScheme(scheme) apiregistrationv1.AddToScheme(scheme) cstestingv1alpha1.AddToScheme(scheme)