diff --git a/modules/certmanager/certificate.go b/modules/certmanager/certificate.go index 9c589e7c..58b5e5fa 100644 --- a/modules/certmanager/certificate.go +++ b/modules/certmanager/certificate.go @@ -25,7 +25,9 @@ import ( certmgrmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" "github.com/openstack-k8s-operators/lib-common/modules/common/secret" + "github.com/openstack-k8s-operators/lib-common/modules/common/service" "github.com/openstack-k8s-operators/lib-common/modules/common/util" + "golang.org/x/exp/maps" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -231,3 +233,84 @@ func EnsureCert( return certSecret, ctrl.Result{}, nil } + +// EnsureCertForServicesWithSelector - creates certificate for k8s services identified +// by a label selector +func EnsureCertForServicesWithSelector( + ctx context.Context, + helper *helper.Helper, + namespace string, + selector map[string]string, + issuer string, +) (map[string]string, ctrl.Result, error) { + certs := map[string]string{} + svcs, err := service.GetServicesListWithLabel( + ctx, + helper, + namespace, + selector, + ) + if err != nil { + return certs, ctrl.Result{}, err + } + + for _, svc := range svcs.Items { + hostname := fmt.Sprintf("%s.%s.svc", svc.Name, namespace) + // create cert for the service + certRequest := CertificateRequest{ + IssuerName: issuer, + CertName: fmt.Sprintf("%s-svc", svc.Name), + Hostnames: []string{hostname}, + Labels: svc.Labels, + } + certSecret, ctrlResult, err := EnsureCert( + ctx, + helper, + certRequest) + if err != nil { + return certs, ctrlResult, err + } else if (ctrlResult != ctrl.Result{}) { + return certs, ctrlResult, nil + } + + certs[hostname] = certSecret.Name + } + + return certs, ctrl.Result{}, nil +} + +// EnsureCertForServiceWithSelector - creates certificate for a k8s service identified +// by a label selector. The label selector must match a single service +func EnsureCertForServiceWithSelector( + ctx context.Context, + helper *helper.Helper, + namespace string, + selector map[string]string, + issuer string, +) (string, ctrl.Result, error) { + var cert string + svcs, err := service.GetServicesListWithLabel( + ctx, + helper, + namespace, + selector, + ) + if err != nil { + return cert, ctrl.Result{}, err + } + if len(svcs.Items) != 1 { + return cert, ctrl.Result{}, fmt.Errorf("multiple services identified by selector: %+v", selector) + } + + certs, ctrlResult, err := EnsureCertForServicesWithSelector( + ctx, helper, namespace, selector, issuer) + if err != nil { + return cert, ctrlResult, err + } else if (ctrlResult != ctrl.Result{}) { + return cert, ctrlResult, nil + } + + hostname := maps.Keys(certs) + + return certs[hostname[0]], ctrl.Result{}, nil +} diff --git a/modules/certmanager/go.mod b/modules/certmanager/go.mod index b5067230..4ca044c5 100644 --- a/modules/certmanager/go.mod +++ b/modules/certmanager/go.mod @@ -10,6 +10,7 @@ require ( github.com/onsi/gomega v1.30.0 github.com/openstack-k8s-operators/lib-common/modules/common v0.3.1-0.20231215134849-9acca0025036 go.uber.org/zap v1.26.0 + golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 k8s.io/api v0.26.11 k8s.io/apimachinery v0.26.11 k8s.io/client-go v0.26.11 diff --git a/modules/certmanager/go.sum b/modules/certmanager/go.sum index 088d4b12..0967669f 100644 --- a/modules/certmanager/go.sum +++ b/modules/certmanager/go.sum @@ -328,6 +328,7 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 h1:qCEDpW1G+vcj3Y7Fy52pEM1AWm3abj8WimGYejI3SC4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/modules/certmanager/test/functional/certmanager_test.go b/modules/certmanager/test/functional/certmanager_test.go index 8ea32adb..6c70e6b6 100644 --- a/modules/certmanager/test/functional/certmanager_test.go +++ b/modules/certmanager/test/functional/certmanager_test.go @@ -15,9 +15,13 @@ limitations under the License. package functional import ( + "fmt" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/openstack-k8s-operators/lib-common/modules/certmanager" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" certmgrmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" @@ -147,4 +151,170 @@ var _ = Describe("certmanager module", func() { Expect(err).ShouldNot(HaveOccurred()) th.AssertIssuerDoesNotExist(names.CertName) }) + + It("creates certificates for k8s services with label selector", func() { + i := certmanager.NewIssuer( + certmanager.CAIssuer( + "ca", + names.Namespace, + map[string]string{"f": "l"}, + "secret", + ), + timeout, + ) + + _, err := i.CreateOrPatch(ctx, h) + Expect(err).ShouldNot(HaveOccurred()) + issuer := th.GetIssuer(names.CAName) + Expect(issuer.Spec.CA).NotTo(BeNil()) + + svc1Name := types.NamespacedName{Name: "svc1", Namespace: names.Namespace} + svc2Name := types.NamespacedName{Name: "svc2", Namespace: names.Namespace} + svc3Name := types.NamespacedName{Name: "svc3", Namespace: names.Namespace} + th.CreateService(svc1Name, map[string]string{"foo": ""}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc1Name.Name, + Port: int32(1111), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + th.CreateService(svc2Name, map[string]string{"foo": ""}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc2Name.Name, + Port: int32(2222), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + th.CreateService(svc3Name, map[string]string{}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc2Name.Name, + Port: int32(3333), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + // simulate underlying cert secerts exist + th.CreateCertSecret(types.NamespacedName{Name: "cert-svc1-svc", Namespace: names.Namespace}) + th.CreateCertSecret(types.NamespacedName{Name: "cert-svc2-svc", Namespace: names.Namespace}) + + certs, _, err := certmanager.EnsureCertForServicesWithSelector( + th.Ctx, h, names.Namespace, map[string]string{"foo": ""}, names.CAName.Name) + Expect(err).ShouldNot(HaveOccurred()) + Expect(certs).To(HaveLen(2)) + Expect(certs).To(HaveKey(fmt.Sprintf("svc1.%s.svc", names.Namespace))) + Expect(certs).To(HaveKey(fmt.Sprintf("svc2.%s.svc", names.Namespace))) + }) + + It("creates a certificate for a specific k8s service matching label selector", func() { + i := certmanager.NewIssuer( + certmanager.CAIssuer( + "ca", + names.Namespace, + map[string]string{"f": "l"}, + "secret", + ), + timeout, + ) + + _, err := i.CreateOrPatch(ctx, h) + Expect(err).ShouldNot(HaveOccurred()) + issuer := th.GetIssuer(names.CAName) + Expect(issuer.Spec.CA).NotTo(BeNil()) + + svc1Name := types.NamespacedName{Name: "svc1", Namespace: names.Namespace} + svc2Name := types.NamespacedName{Name: "svc2", Namespace: names.Namespace} + svc3Name := types.NamespacedName{Name: "svc3", Namespace: names.Namespace} + th.CreateService(svc1Name, map[string]string{"foo": "1"}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc1Name.Name, + Port: int32(1111), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + th.CreateService(svc2Name, map[string]string{"foo": "2"}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc2Name.Name, + Port: int32(2222), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + th.CreateService(svc3Name, map[string]string{}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc2Name.Name, + Port: int32(3333), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + // simulate underlying cert secert exist + th.CreateCertSecret(types.NamespacedName{Name: "cert-svc2-svc", Namespace: names.Namespace}) + + cert, _, err := certmanager.EnsureCertForServiceWithSelector( + th.Ctx, h, names.Namespace, map[string]string{"foo": "2"}, names.CAName.Name) + Expect(err).ShouldNot(HaveOccurred()) + Expect(cert).To(Equal("cert-svc2-svc")) + + }) + + It("fails to create a certificate for a specific k8s service if the label selector returns not a single service", func() { + i := certmanager.NewIssuer( + certmanager.CAIssuer( + "ca", + names.Namespace, + map[string]string{"f": "l"}, + "secret", + ), + timeout, + ) + + _, err := i.CreateOrPatch(ctx, h) + Expect(err).ShouldNot(HaveOccurred()) + issuer := th.GetIssuer(names.CAName) + Expect(issuer.Spec.CA).NotTo(BeNil()) + + svc1Name := types.NamespacedName{Name: "svc1", Namespace: names.Namespace} + svc2Name := types.NamespacedName{Name: "svc2", Namespace: names.Namespace} + svc3Name := types.NamespacedName{Name: "svc3", Namespace: names.Namespace} + th.CreateService(svc1Name, map[string]string{"foo": ""}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc1Name.Name, + Port: int32(1111), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + th.CreateService(svc2Name, map[string]string{"foo": ""}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc2Name.Name, + Port: int32(2222), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + th.CreateService(svc3Name, map[string]string{}, corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: svc2Name.Name, + Port: int32(3333), + Protocol: corev1.ProtocolTCP, + }, + }, + }) + + _, _, err = certmanager.EnsureCertForServiceWithSelector( + th.Ctx, h, names.Namespace, map[string]string{"foo": ""}, names.CAName.Name) + Expect(err).To(HaveOccurred()) + }) }) diff --git a/modules/certmanager/test/functional/suite_test.go b/modules/certmanager/test/functional/suite_test.go index 514b86c3..d6af9823 100644 --- a/modules/certmanager/test/functional/suite_test.go +++ b/modules/certmanager/test/functional/suite_test.go @@ -29,6 +29,7 @@ import ( "go.uber.org/zap/zapcore" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -117,18 +118,6 @@ var _ = BeforeSuite(func() { th = certmanager_test.NewTestHelper(ctx, k8sClient, timeout, interval, logger) Expect(th).NotTo(BeNil()) - kclient, err := kubernetes.NewForConfig(cfg) - Expect(err).ToNot(HaveOccurred(), "failed to create kclient") - - // NOTE(gibi): helper.Helper needs an object that is being reconciled - // we are not really doing reconciliation in this test but still we need to - // provide a valid object. It is used as controller reference for certain - // objects created in the test. So we provide a simple one, a Namespace. - genericObject := th.CreateNamespace("generic-object") - h, err = helper.NewHelper(genericObject, k8sClient, kclient, testEnv.Scheme, ctrl.Log) - Expect(err).NotTo(HaveOccurred()) - Expect(h).NotTo(BeNil()) - go func() { defer GinkgoRecover() }() @@ -148,6 +137,21 @@ var _ = BeforeEach(func() { // https://book.kubebuilder.io/reference/envtest.html#namespace-usage-limitation namespace = uuid.New().String() th.CreateNamespace(namespace) + + kclient, err := kubernetes.NewForConfig(cfg) + Expect(err).ToNot(HaveOccurred(), "failed to create kclient") + + // NOTE(gibi): helper.Helper needs an object that is being reconciled + // we are not really doing reconciliation in this test but still we need to + // provide a valid object. It is used as controller reference for certain + // objects created in the test. So we provide a simple one, a Namespace. + // Note(mschuppert) using a Secret as a Namespace object does not have + // metadata with namespace and some functions use the BeforeObject.GetNamespace() + genericObject := th.CreateSecret(types.NamespacedName{Name: "generic", Namespace: namespace}, map[string][]byte{}) + h, err = helper.NewHelper(genericObject, k8sClient, kclient, testEnv.Scheme, ctrl.Log) + Expect(err).NotTo(HaveOccurred()) + Expect(h).NotTo(BeNil()) + // We still request the delete of the Namespace to properly cleanup if // we run the test in an existing cluster. DeferCleanup(th.DeleteNamespace, namespace) diff --git a/modules/common/test/helpers/service.go b/modules/common/test/helpers/service.go index 4ca62d92..b9f0ee1c 100644 --- a/modules/common/test/helpers/service.go +++ b/modules/common/test/helpers/service.go @@ -18,6 +18,7 @@ import ( corev1 "k8s.io/api/core/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -35,6 +36,27 @@ func (tc *TestHelper) GetService(name types.NamespacedName) *corev1.Service { return instance } +// CreateService creates a new k8s service resource with provided data. +// +// Example usage: +// +// secret := th.CreateService(types.NamespacedName{Name: "test-secret", Namespace: "test-namespace"}, map[string]string{}, corev1.ServiceSpec{...}) +func (tc *TestHelper) CreateService(name types.NamespacedName, labels map[string]string, svcSpec corev1.ServiceSpec) *corev1.Service { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name.Name, + Namespace: name.Namespace, + Labels: labels, + }, + Spec: svcSpec, + } + gomega.Eventually(func(g gomega.Gomega) { + g.Expect(tc.K8sClient.Create(tc.Ctx, svc)).Should(gomega.Succeed()) + }, tc.Timeout, tc.Interval).Should(gomega.Succeed()) + + return svc +} + // AssertServiceExists - asserts the existence of a Service resource in the Kubernetes cluster. // // Example usage: