From 1f2debf8f9a184c46358db17dd78219140c6e8d9 Mon Sep 17 00:00:00 2001 From: Camila Macedo <7708031+camilamacedo86@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:45:42 +0100 Subject: [PATCH] UPSTREAM: : [OTE] Add webhook tests - Add dumping of container logs and `kubectl describe pods` output for better diagnostics. - Include targeted certificate details dump (`tls.crt` parse) when failures occur. - Add additional check to verify webhook responsiveness after certificate rotation. This change is a refactor of code from openshift/origin#30059. Assisted-by: Gemini --- .../openshift_payload_olmv1.json | 40 ++ .../tests-extension/pkg/helpers/catalogs.go | 52 +++ .../tests-extension/pkg/helpers/commands.go | 83 ++++ openshift/tests-extension/pkg/helpers/dump.go | 113 ++++++ .../test/olmv1-incompatible.go | 26 +- openshift/tests-extension/test/webhooks.go | 384 ++++++++++++++++++ 6 files changed, 679 insertions(+), 19 deletions(-) create mode 100644 openshift/tests-extension/pkg/helpers/catalogs.go create mode 100644 openshift/tests-extension/pkg/helpers/commands.go create mode 100644 openshift/tests-extension/pkg/helpers/dump.go create mode 100644 openshift/tests-extension/test/webhooks.go diff --git a/openshift/tests-extension/.openshift-tests-extension/openshift_payload_olmv1.json b/openshift/tests-extension/.openshift-tests-extension/openshift_payload_olmv1.json index d23d9e581..4788d0e72 100644 --- a/openshift/tests-extension/.openshift-tests-extension/openshift_payload_olmv1.json +++ b/openshift/tests-extension/.openshift-tests-extension/openshift_payload_olmv1.json @@ -48,5 +48,45 @@ "source": "openshift:payload:olmv1", "lifecycle": "blocking", "environmentSelector": {} + }, + { + "name": "[sig-olmv1][OCPFeatureGate:NewOLMWebhookProviderOpenshiftServiceCA][Skipped:Disconnected][Serial] OLMv1 operator with webhooks should have a working validating webhook", + "labels": {}, + "resources": { + "isolation": {} + }, + "source": "openshift:payload:olmv1", + "lifecycle": "blocking", + "environmentSelector": {} + }, + { + "name": "[sig-olmv1][OCPFeatureGate:NewOLMWebhookProviderOpenshiftServiceCA][Skipped:Disconnected][Serial] OLMv1 operator with webhooks should have a working mutating webhook", + "labels": {}, + "resources": { + "isolation": {} + }, + "source": "openshift:payload:olmv1", + "lifecycle": "blocking", + "environmentSelector": {} + }, + { + "name": "[sig-olmv1][OCPFeatureGate:NewOLMWebhookProviderOpenshiftServiceCA][Skipped:Disconnected][Serial] OLMv1 operator with webhooks should have a working conversion webhook", + "labels": {}, + "resources": { + "isolation": {} + }, + "source": "openshift:payload:olmv1", + "lifecycle": "blocking", + "environmentSelector": {} + }, + { + "name": "[sig-olmv1][OCPFeatureGate:NewOLMWebhookProviderOpenshiftServiceCA][Skipped:Disconnected][Serial] OLMv1 operator with webhooks should be tolerant to tls secret deletion", + "labels": {}, + "resources": { + "isolation": {} + }, + "source": "openshift:payload:olmv1", + "lifecycle": "blocking", + "environmentSelector": {} } ] diff --git a/openshift/tests-extension/pkg/helpers/catalogs.go b/openshift/tests-extension/pkg/helpers/catalogs.go new file mode 100644 index 000000000..8409fed96 --- /dev/null +++ b/openshift/tests-extension/pkg/helpers/catalogs.go @@ -0,0 +1,52 @@ +package helpers + +import ( + "context" + "fmt" + "time" + + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/gomega" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + "github/operator-framework-operator-controller/openshift/tests-extension/pkg/env" +) + +// NewClusterCatalog returns a new ClusterCatalog object. +// It sets the image reference as source. +func NewClusterCatalog(name, imageRef string) *olmv1.ClusterCatalog { + return &olmv1.ClusterCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: olmv1.ClusterCatalogSpec{ + Source: olmv1.CatalogSource{ + Type: olmv1.SourceTypeImage, + Image: &olmv1.ImageSource{ + Ref: imageRef, + }, + }, + }, + } +} + +// ExpectCatalogToBeServing checks that the catalog with the given name is installed +func ExpectCatalogToBeServing(ctx context.Context, name string) { + k8sClient := env.Get().K8sClient + Eventually(func(g Gomega) { + var catalog olmv1.ClusterCatalog + err := k8sClient.Get(ctx, client.ObjectKey{Name: name}, &catalog) + g.Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get catalog %q", name)) + + conditions := catalog.Status.Conditions + g.Expect(conditions).NotTo(BeEmpty(), fmt.Sprintf("catalog %q has empty status.conditions", name)) + + g.Expect(meta.IsStatusConditionPresentAndEqual(conditions, olmv1.TypeServing, metav1.ConditionTrue)). + To(BeTrue(), fmt.Sprintf("catalog %q is not serving", name)) + }).WithTimeout(5 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) +} diff --git a/openshift/tests-extension/pkg/helpers/commands.go b/openshift/tests-extension/pkg/helpers/commands.go new file mode 100644 index 000000000..d74fc223d --- /dev/null +++ b/openshift/tests-extension/pkg/helpers/commands.go @@ -0,0 +1,83 @@ +package helpers + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/ginkgo/v2" +) + +// findK8sTool returns "oc" if available, otherwise "kubectl". +// If we are running locally we either prefer to use oc since some tests +// require it, or fallback to kubectl if oc is not available. +func findK8sTool() (string, error) { + tools := []string{"oc", "kubectl"} + for _, t := range tools { + // First check if the tool is available in the PATH. + if _, err := exec.LookPath(t); err != nil { + continue + } + // Verify that the tool is working by checking its version. + if err := exec.Command(t, "version", "--client").Run(); err == nil { + return t, nil + } + } + return "", fmt.Errorf("no Kubernetes CLI client found (tried %s)", + strings.Join(tools, ", ")) +} + +// RunK8sCommand runs a Kubernetes CLI command and returns ONLY stdout. +// If the command fails, stderr is included in the returned error (not mixed with stdout). +func RunK8sCommand(ctx context.Context, args ...string) ([]byte, error) { + tool, err := findK8sTool() + if err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, tool, args...) + out, err := cmd.Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + stderr := strings.TrimSpace(string(ee.Stderr)) + if stderr != "" { + return nil, fmt.Errorf("%s %s failed: %w\nstderr:\n%s", + tool, strings.Join(args, " "), err, stderr) + } + } + return nil, fmt.Errorf("%s %s failed: %w", + tool, strings.Join(args, " "), err) + } + return out, nil +} + +// RunAndPrint runs a `kubectl/oc` command via RunK8sCommand and writes both stdout and stderr +// to the GinkgoWriter. It also prints the exact command being run. +func RunAndPrint(ctx context.Context, args ...string) { + fmt.Fprintf(GinkgoWriter, "\n[diag] running: %s\n", + strings.Join(quoteArgs(args), " ")) + out, err := RunK8sCommand(ctx, args...) + if err != nil { + fmt.Fprintf(GinkgoWriter, "[diag] command failed: %v\n", err) + } + if len(out) > 0 { + fmt.Fprintf(GinkgoWriter, "%s\n", string(out)) + } +} + +func quoteArgs(args []string) []string { + quoted := make([]string, len(args)) + for i, a := range args { + // Add quotes only if whitespace or special chars are present + if strings.ContainsAny(a, " \t") { + quoted[i] = fmt.Sprintf("%q", a) + } else { + quoted[i] = a + } + } + return quoted +} diff --git a/openshift/tests-extension/pkg/helpers/dump.go b/openshift/tests-extension/pkg/helpers/dump.go new file mode 100644 index 000000000..ca97ba6eb --- /dev/null +++ b/openshift/tests-extension/pkg/helpers/dump.go @@ -0,0 +1,113 @@ +package helpers + +import ( + "context" + "fmt" + "strings" + + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/ginkgo/v2" +) + +func sectionHeader(format string, a ...any) { + fmt.Fprintf(GinkgoWriter, "\n=== %s ===\n", fmt.Sprintf(format, a...)) +} + +func subHeader(format string, a ...any) { + fmt.Fprintf(GinkgoWriter, "\n--- %s ---\n", fmt.Sprintf(format, a...)) +} + +// GetAllPodLogs prints logs for all containers in all pods in the given namespace. +func GetAllPodLogs(ctx context.Context, namespace string) { + sectionHeader("[pod-logs] namespace=%s", namespace) + + By("listing pods in namespace " + namespace) + namesOut, err := RunK8sCommand(ctx, "get", "pods", "-n", namespace, "-o", "name") + if err != nil { + fmt.Fprintf(GinkgoWriter, "failed to list pods: %v\n%s\n", err, string(namesOut)) + return + } + lines := strings.Fields(strings.TrimSpace(string(namesOut))) + if len(lines) == 0 { + fmt.Fprintln(GinkgoWriter, "(no pods found)") + return + } + + for _, res := range lines { + subHeader("logs for %s", res) + logsOut, err := RunK8sCommand( + ctx, + "logs", + "-n", namespace, + "--all-containers", + "--prefix", + "--timestamps", + res, + ) + if err != nil { + fmt.Fprintf(GinkgoWriter, "error fetching logs for %s: %v\n%s\n", res, err, string(logsOut)) + continue + } + _, _ = GinkgoWriter.Write(logsOut) // ignore write error by design + } + fmt.Fprintln(GinkgoWriter) +} + +// DescribePods prints the `kubectl/oc describe pods` output for all pods in a given namespace. +func DescribePods(ctx context.Context, namespace string) { + sectionHeader("[describe pods] namespace=%s", namespace) + RunAndPrint(ctx, "describe", "pods", "-n", namespace) +} + +// DescribeAllClusterCatalogs lists all ClusterCatalogs and runs `describe` on each. +func DescribeAllClusterCatalogs(ctx context.Context) { + sectionHeader("[cluster catalogs]") + + out, err := RunK8sCommand(ctx, "get", "clustercatalogs", "-o", "name") + if err != nil { + fmt.Fprintf(GinkgoWriter, "failed to list clustercatalogs: %v\n", err) + return + } + + catalogs := strings.Fields(strings.TrimSpace(string(out))) + if len(catalogs) == 0 { + fmt.Fprintln(GinkgoWriter, "(no clustercatalogs found)") + RunAndPrint(ctx, "get", "clustercatalogs") + return + } + + for _, catalog := range catalogs { + subHeader("describe %s", catalog) + RunAndPrint(ctx, "describe", catalog) + } + fmt.Fprintln(GinkgoWriter) +} + +// DescribeAllClusterExtensions describes every ClusterExtension in the given namespace. +func DescribeAllClusterExtensions(ctx context.Context, namespace string) { + if namespace == "" { + return + } + sectionHeader("[clusterextensions] namespace=%s", namespace) + + args := []string{"get", "clusterextensions", "-n", namespace, "-o", "name"} + out, err := RunK8sCommand(ctx, args...) + if err != nil { + fmt.Fprintf(GinkgoWriter, "failed to list clusterextensions: %v\n", err) + RunAndPrint(ctx, args...) + return + } + + names := strings.Fields(strings.TrimSpace(string(out))) + if len(names) == 0 { + fmt.Fprintln(GinkgoWriter, "(no clusterextensions found)") + RunAndPrint(ctx, args...) + return + } + + for _, n := range names { + subHeader("describe %s", n) + RunAndPrint(ctx, "describe", "clusterextension", strings.TrimPrefix(n, "clusterextension/"), "-n", namespace) + } + fmt.Fprintln(GinkgoWriter) +} diff --git a/openshift/tests-extension/test/olmv1-incompatible.go b/openshift/tests-extension/test/olmv1-incompatible.go index 2ada48893..3435204e9 100644 --- a/openshift/tests-extension/test/olmv1-incompatible.go +++ b/openshift/tests-extension/test/olmv1-incompatible.go @@ -60,10 +60,6 @@ var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 } By(fmt.Sprintf("testing against OCP %s", testVersion)) - By("finding a k8s client") - cmdLine, err := getK8sCommandLineClient() - Expect(err).To(Succeed()) - By("creating a new Namespace") nsCleanup := createNamespace(nsName) DeferCleanup(nsCleanup) @@ -85,7 +81,7 @@ var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 DeferCleanup(fileCleanup) By(fmt.Sprintf("created operator tarball %q", fileOperator)) - By(fmt.Sprintf("starting the operator build with %q via RAW URL", cmdLine)) + By("starting the operator build via RAW URL") opArgs := []string{ "create", "--raw", @@ -96,7 +92,7 @@ var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 "-f", fileOperator, } - buildOperator := startBuild(cmdLine, opArgs...) + buildOperator := startBuild(opArgs...) By(fmt.Sprintf("waiting for the build %q to finish", buildOperator.Name)) waitForBuildToFinish(ctx, buildOperator.Name, nsName) @@ -114,7 +110,7 @@ var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 DeferCleanup(fileCleanup) By(fmt.Sprintf("created catalog tarball %q", fileCatalog)) - By(fmt.Sprintf("starting the catalog build with %q via RAW URL", cmdLine)) + By("starting the catalog build via RAW URL") catalogArgs := []string{ "create", "--raw", @@ -125,7 +121,7 @@ var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 "-f", fileCatalog, } - buildCatalog := startBuild(cmdLine, catalogArgs...) + buildCatalog := startBuild(catalogArgs...) By(fmt.Sprintf("waiting for the build %q to finish", buildCatalog.Name)) waitForBuildToFinish(ctx, buildCatalog.Name, nsName) @@ -386,18 +382,10 @@ func waitForClusterOperatorUpgradable(ctx SpecContext, name string) { }).WithTimeout(5 * time.Minute).WithPolling(1 * time.Second).Should(Succeed()) } -func getK8sCommandLineClient() (string, error) { - s, err := exec.LookPath("kubectl") - if err != nil { - s, err = exec.LookPath("oc") - } - return s, err -} - -func startBuild(cmdLine string, args ...string) *buildv1.Build { - cmd := exec.Command(cmdLine, args...) - output, err := cmd.Output() +func startBuild(args ...string) *buildv1.Build { + output, err := helpers.RunK8sCommand(context.Background(), args...) Expect(err).To(Succeed(), printExitError(err)) + /* The output is JSON of a build.build.openshift.io resource */ build := &buildv1.Build{} Expect(json.Unmarshal(output, build)).To(Succeed(), "failed to unmarshal build") diff --git a/openshift/tests-extension/test/webhooks.go b/openshift/tests-extension/test/webhooks.go new file mode 100644 index 000000000..f6733a083 --- /dev/null +++ b/openshift/tests-extension/test/webhooks.go @@ -0,0 +1,384 @@ +package test + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" + "time" + + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/gomega" + + 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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/controller-runtime/pkg/client" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + "github/operator-framework-operator-controller/openshift/tests-extension/pkg/env" + "github/operator-framework-operator-controller/openshift/tests-extension/pkg/helpers" +) + +const ( + webhookCatalogName = "webhook-operator-catalog" + webhookOperatorPackageName = "webhook-operator" + webhookOperatorCRDName = "webhooktests.webhook.operators.coreos.io" + webhookServiceCert = "webhook-operator-webhook-service-cert" +) + +var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLMWebhookProviderOpenshiftServiceCA][Skipped:Disconnected][Serial] OLMv1 operator with webhooks", + Ordered, Serial, func() { + var ( + k8sClient client.Client + dynamicClient dynamic.Interface + webhookOperatorInstallNamespace string + cleanup func(ctx context.Context) + ) + + BeforeEach(func(ctx SpecContext) { + By("initializing Kubernetes client") + k8sClient = env.Get().K8sClient + restCfg := env.Get().RestCfg + var err error + dynamicClient, err = dynamic.NewForConfig(restCfg) + Expect(err).ToNot(HaveOccurred(), "failed to create dynamic client") + + By("requiring OLMv1 capability on OpenShift") + helpers.RequireOLMv1CapabilityOnOpenshift() + + By("ensuring no ClusterExtension and CRD from a previous run") + helpers.EnsureCleanupClusterExtension(ctx, webhookOperatorPackageName, webhookOperatorCRDName) + + By(fmt.Sprintf("checking if the %s exists", webhookCatalogName)) + catalog := &olmv1.ClusterCatalog{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: webhookCatalogName}, catalog) + if apierrors.IsNotFound(err) { + By(fmt.Sprintf("creating the webhook-operator catalog with name %s", webhookCatalogName)) + catalog = helpers.NewClusterCatalog(webhookCatalogName, "quay.io/operator-framework/webhook-operator-index:0.0.3") + err = k8sClient.Create(ctx, catalog) + Expect(err).ToNot(HaveOccurred()) + + By("waiting for the webhook-operator catalog to be serving") + helpers.ExpectCatalogToBeServing(ctx, webhookCatalogName) + } else { + By(fmt.Sprintf("webhook-operator catalog %s already exists, skipping creation", webhookCatalogName)) + } + webhookOperatorInstallNamespace = fmt.Sprintf("webhook-operator-%s", rand.String(5)) + cleanup = setupWebhookOperator(ctx, k8sClient, webhookOperatorInstallNamespace) + }) + + AfterEach(func(ctx SpecContext) { + if CurrentSpecReport().Failed() { + By("dumping pod logs for debugging") + helpers.GetAllPodLogs(ctx, webhookOperatorInstallNamespace) + helpers.DescribePods(ctx, webhookOperatorInstallNamespace) + helpers.DescribeAllClusterCatalogs(ctx) + helpers.DescribeAllClusterExtensions(ctx, webhookOperatorInstallNamespace) + By("dumping webhook diagnostics") + // Additional diagnostics specific for this test + helpers.RunAndPrint(ctx, "get", "mutatingwebhookconfigurations.admissionregistration.k8s.io", "-oyaml") + helpers.RunAndPrint(ctx, "get", "validatingwebhookconfigurations.admissionregistration.k8s.io", "-oyaml") + } + + By("performing webhook operator cleanup") + if cleanup != nil { + cleanup(ctx) + } + }) + + It("should have a working validating webhook", func(ctx SpecContext) { + By("creating a webhook test resource that will be rejected by the validating webhook") + Eventually(func() error { + name := fmt.Sprintf("validating-webhook-test-%s", rand.String(5)) + obj := newWebhookTest(name, webhookOperatorInstallNamespace, false) + + _, err := dynamicClient. + Resource(webhookTestV1). + Namespace(webhookOperatorInstallNamespace). + Create(ctx, obj, metav1.CreateOptions{}) + + switch { + case err == nil: + // Webhook not ready yet; clean up and keep polling. + _ = dynamicClient.Resource(webhookTestV1). + Namespace(webhookOperatorInstallNamespace). + Delete(ctx, name, metav1.DeleteOptions{}) + return fmt.Errorf("webhook not rejecting yet") + case strings.Contains(err.Error(), "Invalid value: false: Spec.Valid must be true"): + return nil // got the expected validating-webhook rejection + default: + return fmt.Errorf("unexpected error: %v", err) + } + }).WithTimeout(2 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + }) + + It("should have a working mutating webhook", func(ctx SpecContext) { + By("creating a valid webhook") + mutatingWebhookResourceName := "mutating-webhook-test" + resource := newWebhookTest(mutatingWebhookResourceName, webhookOperatorInstallNamespace, true) + Eventually(func(g Gomega) { + _, err := dynamicClient.Resource(webhookTestV1).Namespace(webhookOperatorInstallNamespace).Create(ctx, resource, metav1.CreateOptions{}) + g.Expect(err).ToNot(HaveOccurred()) + }).WithTimeout(1 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + + By("getting the created resource in v1 schema") + obj, err := dynamicClient.Resource(webhookTestV1).Namespace(webhookOperatorInstallNamespace).Get(ctx, mutatingWebhookResourceName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(obj).ToNot(BeNil()) + + By("validating the resource spec") + spec := obj.Object["spec"].(map[string]interface{}) + Expect(spec).To(Equal(map[string]interface{}{ + "valid": true, + "mutate": true, + })) + }) + + It("should have a working conversion webhook", func(ctx SpecContext) { + By("creating a conversion webhook test resource") + conversionWebhookResourceName := "conversion-webhook-test" + resourceV1 := newWebhookTest(conversionWebhookResourceName, webhookOperatorInstallNamespace, true) + Eventually(func(g Gomega) { + _, err := dynamicClient.Resource(webhookTestV1).Namespace(webhookOperatorInstallNamespace).Create(ctx, resourceV1, metav1.CreateOptions{}) + g.Expect(err).ToNot(HaveOccurred()) + }).WithTimeout(1 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + + By("getting the created resource in v2 schema") + obj, err := dynamicClient.Resource(webhookTestV2).Namespace(webhookOperatorInstallNamespace).Get(ctx, conversionWebhookResourceName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(obj).ToNot(BeNil()) + + By("validating the resource spec") + spec := obj.Object["spec"].(map[string]interface{}) + Expect(spec).To(Equal(map[string]interface{}{ + "conversion": map[string]interface{}{ + "valid": true, + "mutate": true, + }, + })) + }) + + It("should be tolerant to tls secret deletion", func(ctx SpecContext) { + certificateSecretName := webhookServiceCert + By("ensuring secret exists before deletion attempt") + Eventually(func(g Gomega) { + secret := &corev1.Secret{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: certificateSecretName, Namespace: webhookOperatorInstallNamespace}, secret) + g.Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get secret %s/%s", webhookOperatorInstallNamespace, certificateSecretName)) + }).WithTimeout(1 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + + By("checking webhook is responsive through secret recreation after manual deletion") + tlsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: certificateSecretName, + Namespace: webhookOperatorInstallNamespace, + }, + } + err := k8sClient.Delete(ctx, tlsSecret, client.PropagationPolicy(metav1.DeletePropagationBackground)) + Expect(client.IgnoreNotFound(err)).ToNot(HaveOccurred()) + + DeferCleanup(func() { + // Specific check for this test + if CurrentSpecReport().Failed() { + By("dumping certificate details for debugging") + secret := &corev1.Secret{} + if err := k8sClient.Get(ctx, client.ObjectKey{ + Name: webhookServiceCert, + Namespace: webhookOperatorInstallNamespace, + }, secret); err == nil { + if crt, ok := secret.Data["tls.crt"]; ok && len(crt) > 0 { + printTLSCertInfo(crt) + } else { + _, _ = GinkgoWriter.Write([]byte("[diag] tls.crt key not found or empty in secret\n")) + } + } else { + fmt.Fprintf(GinkgoWriter, "[diag] failed to get secret for cert dump: %v\n", err) + } + } + }) + + By("waiting for the webhook operator's service certificate secret to be recreated and populated") + Eventually(func(g Gomega) { + secret := &corev1.Secret{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: certificateSecretName, Namespace: webhookOperatorInstallNamespace}, secret) + if apierrors.IsNotFound(err) { + GinkgoLogr.Info(fmt.Sprintf("Secret %s/%s not found yet (still polling for recreation)", webhookOperatorInstallNamespace, certificateSecretName)) + return + } + g.Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get webhook service certificate secret %s/%s: %v", webhookOperatorInstallNamespace, certificateSecretName, err)) + g.Expect(secret.Data).ToNot(BeEmpty(), "expected webhook service certificate secret data to not be empty after recreation") + }).WithTimeout(5*time.Minute).WithPolling(10*time.Second).Should(Succeed(), "webhook service certificate secret did not get recreated and populated within timeout") + + Eventually(func(g Gomega) { + resourceName := fmt.Sprintf("tls-deletion-test-%s", rand.String(5)) + resource := newWebhookTest(resourceName, webhookOperatorInstallNamespace, true) + + _, err := dynamicClient.Resource(webhookTestV1).Namespace(webhookOperatorInstallNamespace).Create(ctx, resource, metav1.CreateOptions{}) + g.Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to create test resource %s: %v", resourceName, err)) + + err = dynamicClient.Resource(webhookTestV1).Namespace(webhookOperatorInstallNamespace).Delete(ctx, resource.GetName(), metav1.DeleteOptions{}) + g.Expect(client.IgnoreNotFound(err)).ToNot(HaveOccurred(), fmt.Sprintf("failed to delete test resource %s: %v", resourceName, err)) + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + }) + }) + +var webhookTestV1 = schema.GroupVersionResource{ + Group: "webhook.operators.coreos.io", + Version: "v1", + Resource: "webhooktests", +} + +var webhookTestV2 = schema.GroupVersionResource{ + Group: "webhook.operators.coreos.io", + Version: "v2", + Resource: "webhooktests", +} + +func newWebhookTest(name, namespace string, valid bool) *unstructured.Unstructured { + mutateValue := valid + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "webhook.operators.coreos.io/v1", + "kind": "WebhookTest", + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + }, + "spec": map[string]interface{}{ + "valid": valid, + "mutate": mutateValue, + }, + }, + } + return obj +} + +func setupWebhookOperator(ctx SpecContext, k8sClient client.Client, webhookOperatorInstallNamespace string) func(ctx context.Context) { + By(fmt.Sprintf("installing the webhook operator in namespace %s", webhookOperatorInstallNamespace)) + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: webhookOperatorInstallNamespace}, + } + err := k8sClient.Create(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + + saName := fmt.Sprintf("%s-installer", webhookOperatorInstallNamespace) + sa := helpers.NewServiceAccount(saName, webhookOperatorInstallNamespace) + err = k8sClient.Create(ctx, sa) + Expect(err).ToNot(HaveOccurred()) + helpers.ExpectServiceAccountExists(ctx, saName, webhookOperatorInstallNamespace) + + By("creating a ClusterRoleBinding to cluster-admin for the webhook operator") + operatorClusterRoleBindingName := fmt.Sprintf("%s-operator-crb", webhookOperatorInstallNamespace) + operatorClusterRoleBinding := helpers.NewClusterRoleBinding(operatorClusterRoleBindingName, "cluster-admin", saName, webhookOperatorInstallNamespace) + err = k8sClient.Create(ctx, operatorClusterRoleBinding) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to create ClusterRoleBinding %s", + operatorClusterRoleBindingName)) + helpers.ExpectClusterRoleBindingExists(ctx, operatorClusterRoleBindingName) + + ceName := webhookOperatorInstallNamespace + ce := helpers.NewClusterExtensionObject("webhook-operator", "0.0.1", ceName, saName, webhookOperatorInstallNamespace) + ce.Spec.Source.Catalog.Selector = &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "olm.operatorframework.io/metadata.name": webhookCatalogName, + }, + } + err = k8sClient.Create(ctx, ce) + Expect(err).ToNot(HaveOccurred()) + + By("waiting for the webhook operator to be installed") + helpers.ExpectClusterExtensionToBeInstalled(ctx, ceName) + + By("waiting for the webhook operator's service to be ready") + serviceName := "webhook-operator-webhook-service" // Standard name for the service created by the operator + Eventually(func(g Gomega) { + svc := &corev1.Service{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: serviceName, Namespace: webhookOperatorInstallNamespace}, svc) + g.Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get webhook service %s/%s: %v", webhookOperatorInstallNamespace, serviceName, err)) + g.Expect(svc.Spec.ClusterIP).ToNot(BeEmpty(), "expected webhook service to have a ClusterIP assigned") + g.Expect(svc.Spec.Ports).ToNot(BeEmpty(), "expected webhook service to have ports defined") + }).WithTimeout(1*time.Minute).WithPolling(5*time.Second).Should(Succeed(), "webhook service did not become ready within timeout") + + By("waiting for the webhook operator's service certificate secret to exist and be populated") + certificateSecretName := "webhook-operator-webhook-service-cert" // Fixed to use the static name + Eventually(func(g Gomega) { + secret := &corev1.Secret{} + // Force bypassing the client cache for this Get operation + err := k8sClient.Get(ctx, client.ObjectKey{Name: certificateSecretName, Namespace: webhookOperatorInstallNamespace}, secret) // Removed client.WithCacheDisabled + + if apierrors.IsNotFound(err) { + GinkgoLogr.Info(fmt.Sprintf("Secret %s/%s not found yet (still polling)", webhookOperatorInstallNamespace, certificateSecretName)) + return // Keep polling if not found + } + + g.Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get webhook service certificate secret %s/%s: %v", + webhookOperatorInstallNamespace, certificateSecretName, err)) + g.Expect(secret.Data).ToNot(BeEmpty(), "expected webhook service certificate secret data to not be empty") + }).WithTimeout(5*time.Minute).WithPolling(5*time.Second).Should(Succeed(), "webhook service certificate secret did not become available within timeout") + + return func(ctx context.Context) { + By(fmt.Sprintf("cleanup: deleting ClusterExtension %s", ce.Name)) + _ = k8sClient.Delete(ctx, ce, client.PropagationPolicy(metav1.DeletePropagationBackground)) + By(fmt.Sprintf("cleanup: deleting ClusterRoleBinding %s", operatorClusterRoleBinding.Name)) + _ = k8sClient.Delete(ctx, operatorClusterRoleBinding, client.PropagationPolicy(metav1.DeletePropagationBackground)) + By(fmt.Sprintf("cleanup: deleting ServiceAccount %s in namespace %s", sa.Name, sa.Namespace)) + _ = k8sClient.Delete(ctx, sa, client.PropagationPolicy(metav1.DeletePropagationBackground)) + By(fmt.Sprintf("cleanup: deleting namespace %s", ns.Name)) + _ = k8sClient.Delete(ctx, ns, client.PropagationPolicy(metav1.DeletePropagationForeground)) + + By(fmt.Sprintf("waiting for namespace %s to be fully deleted", webhookOperatorInstallNamespace)) + Eventually(func(g Gomega) { + ns := &corev1.Namespace{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: webhookOperatorInstallNamespace}, ns) + g.Expect(client.IgnoreNotFound(err)).To(Succeed()) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "namespace still exists") + }).WithTimeout(5 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + } +} + +// printTLSCertInfo parses a PEM-encoded TLS certificate and prints useful debug info. +// It shows validity period and SANs (DNS/IP) to help debug webhook cert issues. +func printTLSCertInfo(certPEM []byte) { + fmt.Fprintln(GinkgoWriter, "\n[diag] === TLS Certificate Info ===") + + block, _ := pem.Decode(certPEM) + if block == nil { + fmt.Fprintln(GinkgoWriter, "[diag] failed to decode PEM block from tls.crt") + return + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + fmt.Fprintf(GinkgoWriter, "[diag] failed to parse certificate: %v\n", err) + return + } + + fmt.Fprintf(GinkgoWriter, "[diag] Subject: %s\n", cert.Subject.String()) + fmt.Fprintf(GinkgoWriter, "[diag] Issuer: %s\n", cert.Issuer.String()) + fmt.Fprintf(GinkgoWriter, "[diag] Serial Number: %X\n", cert.SerialNumber) + fmt.Fprintf(GinkgoWriter, "[diag] Valid From: %s\n", cert.NotBefore.Format(time.RFC3339)) + fmt.Fprintf(GinkgoWriter, "[diag] Valid Until: %s\n", cert.NotAfter.Format(time.RFC3339)) + fmt.Fprintf(GinkgoWriter, "[diag] IsCA: %t\n", cert.IsCA) + + sans := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses)) + sans = append(sans, cert.DNSNames...) + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + if len(sans) > 0 { + fmt.Fprintf(GinkgoWriter, "[diag] SANs: %s\n", strings.Join(sans, ", ")) + } else { + fmt.Fprintln(GinkgoWriter, "[diag] SANs: ") + } + + fmt.Fprintln(GinkgoWriter, "[diag] ===========================") +}