Repository: kubeflow/spark-operator

Issue:
Use CertManager for webhook certificates
We've had great success in using certmanager for running our kubebuilder based webhooks. It can be configured to automatically provision the certificated needed for mutating or validating webhooks

https://book.kubebuilder.io/cronjob-tutorial/running-webhook.html

https://book.kubebuilder.io/cronjob-tutorial/cert-manager.html

related to: https://github.com/aws/amazon-eks-pod-identity-webhook/issues/94

Discussion:
@github-actions: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.@github-actions: This issue has been automatically closed because it has not had recent activity. Please comment "/reopen" to reopen it.

Solution patch:
diff --git a/charts/spark-operator-chart/README.md b/charts/spark-operator-chart/README.md
index 3db7492..1271373 100644
--- a/charts/spark-operator-chart/README.md
+++ b/charts/spark-operator-chart/README.md
@@ -175,6 +175,10 @@ See [helm uninstall](https://helm.sh/docs/helm/helm_uninstall) for command docum
 | prometheus.podMonitor.labels | object | `{}` | Pod monitor labels |
 | prometheus.podMonitor.jobLabel | string | `"spark-operator-podmonitor"` | The label to use to retrieve the job name from |
 | prometheus.podMonitor.podMetricsEndpoint | object | `{"interval":"5s","scheme":"http"}` | Prometheus metrics endpoint properties. `metrics.portName` will be used as a port |
+| certManager.enable | bool | `false` | Specifies whether to use [cert-manager](https://cert-manager.io) to generate certificate for webhook. `webhook.enable` must be set to `true` to enable cert-manager. |
+| certManager.issuerRef | object | A self-signed issuer will be created and used if not specified. | The reference to the issuer. |
+| certManager.duration | string | `2160h` (90 days) will be used if not specified. | The duration of the certificate validity (e.g. `2160h`). See [cert-manager.io/v1.Certificate](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.Certificate). |
+| certManager.renewBefore | string | 1/3 of issued certificate’s lifetime. | The duration before the certificate expiration to renew the certificate (e.g. `720h`). See [cert-manager.io/v1.Certificate](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.Certificate). |
 
 ## Maintainers
 
diff --git a/charts/spark-operator-chart/templates/_helpers.tpl b/charts/spark-operator-chart/templates/_helpers.tpl
index 82845b4..06afe08 100644
--- a/charts/spark-operator-chart/templates/_helpers.tpl
+++ b/charts/spark-operator-chart/templates/_helpers.tpl
@@ -74,5 +74,5 @@ app.kubernetes.io/instance: {{ .Release.Name }}
 Spark Operator image
 */}}
 {{- define "spark-operator.image" -}}
-{{ printf "%s/%s:%s" .Values.image.registry .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }}
+{{ printf "%s/%s:%s" .Values.image.registry .Values.image.repository (.Values.image.tag | default .Chart.AppVersion | toString) }}
 {{- end -}}
diff --git a/charts/spark-operator-chart/templates/certmanager/_helpers.tpl b/charts/spark-operator-chart/templates/certmanager/_helpers.tpl
new file mode 100644
index 0000000..a09196f
--- /dev/null
+++ b/charts/spark-operator-chart/templates/certmanager/_helpers.tpl
@@ -0,0 +1,29 @@
+{{- /*
+Copyright 2025 The Kubeflow 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
+
+    https://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.
+*/ -}}
+
+{{/*
+Create the name of the webhook certificate issuer.
+*/}}
+{{- define "spark-operator.certManager.issuer.name" -}}
+{{ include "spark-operator.name" . }}-self-signed-issuer
+{{- end -}}
+
+{{/*
+Create the name of the certificate to be used by webhook.
+*/}}
+{{- define "spark-operator.certManager.certificate.name" -}}
+{{ include "spark-operator.name" . }}-certificate
+{{- end -}}
diff --git a/charts/spark-operator-chart/templates/certmanager/certificate.yaml b/charts/spark-operator-chart/templates/certmanager/certificate.yaml
new file mode 100644
index 0000000..5f9d323
--- /dev/null
+++ b/charts/spark-operator-chart/templates/certmanager/certificate.yaml
@@ -0,0 +1,56 @@
+{{- /*
+Copyright 2025 The Kubeflow 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
+
+    https://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.
+*/ -}}
+
+{{- if .Values.webhook.enable }}
+{{- if .Values.certManager.enable }}
+{{- if not (.Capabilities.APIVersions.Has "cert-manager.io/v1/Certificate") }}
+{{- fail "The cluster does not support the required API version `cert-manager.io/v1` for `Certificate`." }}
+{{- end }}
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: {{ include "spark-operator.certManager.certificate.name" . }}
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "spark-operator.labels" . | nindent 4 }}
+spec:
+  secretName: {{ include "spark-operator.webhook.secretName" . }}
+  issuerRef:
+    {{- if not .Values.certManager.issuerRef }}
+    group: cert-manager.io
+    kind: Issuer
+    name: {{ include "spark-operator.certManager.issuer.name" . }}
+    {{- else }}
+    {{- toYaml .Values.certManager.issuerRef | nindent 4 }}
+    {{- end }}
+  commonName: {{ include "spark-operator.webhook.serviceName" . }}.{{ .Release.Namespace }}.svc
+  dnsNames:
+  - {{ include "spark-operator.webhook.serviceName" . }}.{{ .Release.Namespace }}.svc
+  - {{ include "spark-operator.webhook.serviceName" . }}.{{ .Release.Namespace }}.svc.cluster.local
+  subject:
+    organizationalUnits:
+     - spark-operator
+  usages:
+  - server auth
+  - client auth
+  {{- with .Values.certManager.duration }}
+  duration: {{ . }}
+  {{- end }}
+  {{- with .Values.certManager.renewBefore }}
+  renewBefore: {{ . }}
+  {{- end }}
+{{- end }}
+{{- end }}
diff --git a/charts/spark-operator-chart/templates/certmanager/issuer.yaml b/charts/spark-operator-chart/templates/certmanager/issuer.yaml
new file mode 100644
index 0000000..cd3fa88
--- /dev/null
+++ b/charts/spark-operator-chart/templates/certmanager/issuer.yaml
@@ -0,0 +1,34 @@
+{{- /*
+Copyright 2025 The Kubeflow 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
+
+    https://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.
+*/ -}}
+
+{{- if .Values.webhook.enable }}
+{{- if .Values.certManager.enable }}
+{{- if not .Values.certManager.issuerRef }}
+{{- if not (.Capabilities.APIVersions.Has "cert-manager.io/v1/Issuer") }}
+{{- fail "The cluster does not support the required API version `cert-manager.io/v1` for `Issuer`." }}
+{{- end }}
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: {{ include "spark-operator.certManager.issuer.name" . }}
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "spark-operator.labels" . | nindent 4 }}
+spec:
+  selfSigned: {}
+{{- end }}
+{{- end }}
+{{- end }}
diff --git a/charts/spark-operator-chart/templates/webhook/_helpers.tpl b/charts/spark-operator-chart/templates/webhook/_helpers.tpl
index 00ad4fc..d388742 100644
--- a/charts/spark-operator-chart/templates/webhook/_helpers.tpl
+++ b/charts/spark-operator-chart/templates/webhook/_helpers.tpl
@@ -83,7 +83,6 @@ Create the name of the secret to be used by webhook
 {{ include "spark-operator.webhook.name" . }}-certs
 {{- end -}}
 
-
 {{/*
 Create the name of the service to be used by webhook
 */}}
diff --git a/charts/spark-operator-chart/templates/webhook/deployment.yaml b/charts/spark-operator-chart/templates/webhook/deployment.yaml
index e1fbe08..90f7646 100644
--- a/charts/spark-operator-chart/templates/webhook/deployment.yaml
+++ b/charts/spark-operator-chart/templates/webhook/deployment.yaml
@@ -67,6 +67,9 @@ spec:
         {{- with .Values.webhook.resourceQuotaEnforcement.enable }}
         - --enable-resource-quota-enforcement=true
         {{- end }}
+        {{- if .Values.certManager.enable }}
+        - --enable-cert-manager=true
+        {{- end }}
         {{- if .Values.prometheus.metrics.enable }}
         - --enable-metrics=true
         - --metrics-bind-address=:{{ .Values.prometheus.metrics.port }}
diff --git a/charts/spark-operator-chart/templates/webhook/mutatingwebhookconfiguration.yaml b/charts/spark-operator-chart/templates/webhook/mutatingwebhookconfiguration.yaml
index 2d6a10a..310c199 100644
--- a/charts/spark-operator-chart/templates/webhook/mutatingwebhookconfiguration.yaml
+++ b/charts/spark-operator-chart/templates/webhook/mutatingwebhookconfiguration.yaml
@@ -21,6 +21,10 @@ metadata:
   name: {{ include "spark-operator.webhook.name" . }}
   labels:
     {{- include "spark-operator.webhook.labels" . | nindent 4 }}
+  {{- if .Values.certManager.enable }}
+  annotations:
+    cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "spark-operator.certManager.certificate.name" . }}
+  {{- end }}
 webhooks:
 - name: mutate--v1-pod.sparkoperator.k8s.io
   admissionReviewVersions: ["v1"]
diff --git a/charts/spark-operator-chart/templates/webhook/validatingwebhookconfiguration.yaml b/charts/spark-operator-chart/templates/webhook/validatingwebhookconfiguration.yaml
index 8cd3b11..937becb 100644
--- a/charts/spark-operator-chart/templates/webhook/validatingwebhookconfiguration.yaml
+++ b/charts/spark-operator-chart/templates/webhook/validatingwebhookconfiguration.yaml
@@ -21,6 +21,10 @@ metadata:
   name: {{ include "spark-operator.webhook.name" . }}
   labels:
     {{- include "spark-operator.webhook.labels" . | nindent 4 }}
+  {{- if .Values.certManager.enable }}
+  annotations:
+    cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "spark-operator.certManager.certificate.name" . }}
+  {{- end }}
 webhooks:
 - name: validate-sparkoperator-k8s-io-v1beta2-sparkapplication.sparkoperator.k8s.io
   admissionReviewVersions: ["v1"]
diff --git a/charts/spark-operator-chart/values.yaml b/charts/spark-operator-chart/values.yaml
index 68cfaee..e2df010 100644
--- a/charts/spark-operator-chart/values.yaml
+++ b/charts/spark-operator-chart/values.yaml
@@ -407,3 +407,22 @@ prometheus:
     podMetricsEndpoint:
       scheme: http
       interval: 5s
+
+certManager:
+  # -- Specifies whether to use [cert-manager](https://cert-manager.io) to generate certificate for webhook.
+  # `webhook.enable` must be set to `true` to enable cert-manager.
+  enable: false
+  # -- The reference to the issuer.
+  # @default -- A self-signed issuer will be created and used if not specified.
+  issuerRef: {}
+    # group: cert-manager.io
+    # kind: ClusterIssuer
+    # name: selfsigned
+  # -- The duration of the certificate validity (e.g. `2160h`).
+  # See [cert-manager.io/v1.Certificate](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.Certificate).
+  # @default -- `2160h` (90 days) will be used if not specified.
+  duration: ""
+  # -- The duration before the certificate expiration to renew the certificate (e.g. `720h`).
+  # See [cert-manager.io/v1.Certificate](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.Certificate).
+  # @default -- 1/3 of issued certificate’s lifetime.
+  renewBefore: ""
diff --git a/cmd/operator/webhook/start.go b/cmd/operator/webhook/start.go
index e85f0da..88ecef2 100644
--- a/cmd/operator/webhook/start.go
+++ b/cmd/operator/webhook/start.go
@@ -85,6 +85,9 @@ var (
 	webhookServiceName             string
 	webhookServiceNamespace        string
 
+	// Cert Manager
+	enableCertManager bool
+
 	// Leader election
 	enableLeaderElection        bool
 	leaderElectionLockName      string
@@ -127,11 +130,13 @@ func NewStartCommand() *cobra.Command {
 		},
 	}
 
+	// Controller
 	command.Flags().IntVar(&controllerThreads, "controller-threads", 10, "Number of worker threads used by the SparkApplication controller.")
 	command.Flags().StringSliceVar(&namespaces, "namespaces", []string{}, "The Kubernetes namespace to manage. Will manage custom resource objects of the managed CRD types for the whole cluster if unset or contains empty string.")
 	command.Flags().StringVar(&labelSelectorFilter, "label-selector-filter", "", "A comma-separated list of key=value, or key labels to filter resources during watch and list based on the specified labels.")
 	command.Flags().DurationVar(&cacheSyncTimeout, "cache-sync-timeout", 30*time.Second, "Informer cache sync timeout.")
 
+	// Webhook
 	command.Flags().StringVar(&webhookCertDir, "webhook-cert-dir", "/etc/k8s-webhook-server/serving-certs", "The directory that contains the webhook server key and certificate. "+
 		"When running as nonRoot, you must create and own this directory before running this command.")
 	command.Flags().StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The file name of webhook server certificate.")
@@ -145,6 +150,10 @@ func NewStartCommand() *cobra.Command {
 	command.Flags().StringVar(&webhookServiceNamespace, "webhook-svc-namespace", "spark-webhook", "The name of the Service for the webhook server.")
 	command.Flags().BoolVar(&enableResourceQuotaEnforcement, "enable-resource-quota-enforcement", false, "Whether to enable ResourceQuota enforcement for SparkApplication resources. Requires the webhook to be enabled.")
 
+	// Cert Manager
+	command.Flags().BoolVar(&enableCertManager, "enable-cert-manager", false, "Enable cert-manager to manage the webhook server's TLS certificate.")
+
+	// Leader election
 	command.Flags().BoolVar(&enableLeaderElection, "leader-election", false, "Enable leader election for controller manager. "+
 		"Enabling this will ensure there is only one active controller manager.")
 	command.Flags().StringVar(&leaderElectionLockName, "leader-election-lock-name", "spark-operator-lock", "Name of the ConfigMap for leader election.")
@@ -153,6 +162,7 @@ func NewStartCommand() *cobra.Command {
 	command.Flags().DurationVar(&leaderElectionRenewDeadline, "leader-election-renew-deadline", 14*time.Second, "Leader election renew deadline.")
 	command.Flags().DurationVar(&leaderElectionRetryPeriod, "leader-election-retry-period", 4*time.Second, "Leader election retry period.")
 
+	// Prometheus metrics
 	command.Flags().BoolVar(&enableMetrics, "enable-metrics", false, "Enable metrics.")
 	command.Flags().StringVar(&metricsBindAddress, "metrics-bind-address", "0", "The address the metric endpoint binds to. "+
 		"Use the port :8080. If not set, it will be 0 in order to disable the metrics server")
@@ -230,6 +240,7 @@ func start() {
 		client,
 		webhookServiceName,
 		webhookServiceNamespace,
+		enableCertManager,
 	)
 
 	if err := wait.ExponentialBackoff(
@@ -240,7 +251,6 @@ func start() {
 			Jitter:   0.1,
 		},
 		func() (bool, error) {
-			logger.Info("Syncing webhook secret", "name", webhookSecretName, "namespace", webhookSecretNamespace)
 			if err := certProvider.SyncSecret(context.TODO(), webhookSecretName, webhookSecretNamespace); err != nil {
 				if errors.IsAlreadyExists(err) || errors.IsConflict(err) {
 					return false, nil
@@ -260,22 +270,24 @@ func start() {
 		os.Exit(1)
 	}
 
-	if err := mutatingwebhookconfiguration.NewReconciler(
-		mgr.GetClient(),
-		certProvider,
-		mutatingWebhookName,
-	).SetupWithManager(mgr, controller.Options{}); err != nil {
-		logger.Error(err, "Failed to create controller", "controller", "MutatingWebhookConfiguration")
-		os.Exit(1)
-	}
+	if !enableCertManager {
+		if err := mutatingwebhookconfiguration.NewReconciler(
+			mgr.GetClient(),
+			certProvider,
+			mutatingWebhookName,
+		).SetupWithManager(mgr, controller.Options{}); err != nil {
+			logger.Error(err, "Failed to create controller", "controller", "MutatingWebhookConfiguration")
+			os.Exit(1)
+		}
 
-	if err := validatingwebhookconfiguration.NewReconciler(
-		mgr.GetClient(),
-		certProvider,
-		validatingWebhookName,
-	).SetupWithManager(mgr, controller.Options{}); err != nil {
-		logger.Error(err, "Failed to create controller", "controller", "ValidatingWebhookConfiguration")
-		os.Exit(1)
+		if err := validatingwebhookconfiguration.NewReconciler(
+			mgr.GetClient(),
+			certProvider,
+			validatingWebhookName,
+		).SetupWithManager(mgr, controller.Options{}); err != nil {
+			logger.Error(err, "Failed to create controller", "controller", "ValidatingWebhookConfiguration")
+			os.Exit(1)
+		}
 	}
 
 	if err := ctrl.NewWebhookManagedBy(mgr).
diff --git a/pkg/certificate/certificate.go b/pkg/certificate/certificate.go
index d5e3fec..7e96950 100644
--- a/pkg/certificate/certificate.go
+++ b/pkg/certificate/certificate.go
@@ -32,6 +32,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/util/cert"
+	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/kubeflow/spark-operator/pkg/common"
@@ -44,8 +45,10 @@ const (
 // Provider is a container of a X509 certificate file and a corresponding key file for the
 // webhook server, and a CA certificate file for the API server to verify the server certificate.
 type Provider struct {
-	client     client.Client
-	commonName string
+	client            client.Client
+	commonName        string
+	enableCertManager bool
+
 	caKey      *rsa.PrivateKey
 	caCert     *x509.Certificate
 	serverKey  *rsa.PrivateKey
@@ -53,31 +56,47 @@ type Provider struct {
 }
 
 // NewProvider creates a new Provider instance.
-func NewProvider(client client.Client, name, namespace string) *Provider {
+func NewProvider(client client.Client, name, namespace string, enableCertManager bool) *Provider {
 	commonName := fmt.Sprintf("%s.%s.svc", name, namespace)
 	certProvider := Provider{
-		client:     client,
-		commonName: commonName,
+		client:            client,
+		commonName:        commonName,
+		enableCertManager: enableCertManager,
 	}
 	return &certProvider
 }
 
 // SyncSecret syncs the secret containing the certificates to the given name and namespace.
 func (cp *Provider) SyncSecret(ctx context.Context, name, namespace string) error {
+	if cp.enableCertManager {
+		return cp.SyncCertManagerSecret(ctx, name, namespace)
+	}
+
+	return cp.syncInternalCertSecret(ctx, name, namespace)
+}
+
+func (cp *Provider) syncInternalCertSecret(ctx context.Context, name string, namespace string) error {
+	logger := ctrl.LoggerFrom(ctx, "name", name, "namespace", namespace)
+	logger.Info("Syncing webhook secret")
+
 	secret := &corev1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      name,
 			Namespace: namespace,
 		},
 	}
+
 	key := types.NamespacedName{
 		Name:      name,
 		Namespace: namespace,
 	}
+
 	if err := cp.client.Get(ctx, key, secret); err != nil {
 		if !errors.IsNotFound(err) {
 			return err
 		}
+
+		logger.Info("Creating secret as it does not exist")
 		if err := cp.client.Create(ctx, secret); err != nil {
 			if errors.IsAlreadyExists(err) {
 				return err
@@ -93,7 +112,7 @@ func (cp *Provider) SyncSecret(ctx context.Context, name, namespace string) erro
 		return cp.generateAndUpdateSecret(ctx, secret)
 	}
 
-	if err := cp.parseSecret(secret); err != nil {
+	if err := cp.parseInternalCertSecret(secret); err != nil {
 		return err
 	}
 
@@ -106,6 +125,34 @@ func (cp *Provider) SyncSecret(ctx context.Context, name, namespace string) erro
 	}); err != nil {
 		return cp.generateAndUpdateSecret(ctx, secret)
 	}
+	return nil
+}
+
+// SyncCertManagerSecret syncs the certificate secret generated by cert-manager.
+func (cp *Provider) SyncCertManagerSecret(ctx context.Context, name string, namespace string) error {
+	logger := ctrl.LoggerFrom(ctx, "name", name, "namespace", namespace)
+	logger.Info("Syncing webhook secret")
+
+	secret := &corev1.Secret{}
+
+	key := types.NamespacedName{
+		Name:      name,
+		Namespace: namespace,
+	}
+
+	if err := cp.client.Get(ctx, key, secret); err != nil {
+		return err
+	}
+
+	if len(secret.Data[common.CACert]) == 0 ||
+		len(secret.Data[common.TLSCert]) == 0 ||
+		len(secret.Data[common.TLSKey]) == 0 {
+		return fmt.Errorf("failed to sync cert manager certificate secret: %s, %s or %s is empty", common.CACert, common.TLSCert, common.TLSKey)
+	}
+
+	if err := cp.parseCertManagerSecret(secret); err != nil {
+		return err
+	}
 
 	return nil
 }
@@ -204,6 +251,9 @@ func (cp *Provider) WriteFile(path, certName, keyName string) error {
 }
 
 func (cp *Provider) Generate() error {
+	logger := ctrl.Log.WithName("")
+	logger.Info("Generating certificates.")
+
 	// Generate CA private caKey
 	caKey, err := NewPrivateKey()
 	if err != nil {
@@ -252,33 +302,41 @@ func (cp *Provider) Generate() error {
 	return nil
 }
 
-func (cp *Provider) parseSecret(secret *corev1.Secret) error {
+// parseInternalCertSecret parses the internal certificate secret generated by the operator.
+func (cp *Provider) parseInternalCertSecret(secret *corev1.Secret) error {
 	if secret == nil {
 		return fmt.Errorf("secret is nil")
 	}
-	caKeyPem, _ := pem.Decode(secret.Data[common.CAKeyPem])
+
 	caCertPem, _ := pem.Decode(secret.Data[common.CACertPem])
-	serverKeyPem, _ := pem.Decode(secret.Data[common.ServerKeyPem])
+	caKeyPem, _ := pem.Decode(secret.Data[common.CAKeyPem])
 	serverCertPem, _ := pem.Decode(secret.Data[common.ServerCertPem])
-	if caKeyPem == nil || caCertPem == nil || serverKeyPem == nil || serverCertPem == nil {
+	serverKeyPem, _ := pem.Decode(secret.Data[common.ServerKeyPem])
+
+	if caCertPem == nil || caKeyPem == nil || serverCertPem == nil || serverKeyPem == nil {
 		return fmt.Errorf("failed to decode secret data to pem block")
 	}
+
 	caKey, err := x509.ParsePKCS1PrivateKey(caKeyPem.Bytes)
 	if err != nil {
 		return fmt.Errorf("failed to parse CA private key: %v", err)
 	}
+
 	caCert, err := x509.ParseCertificate(caCertPem.Bytes)
 	if err != nil {
 		return fmt.Errorf("failed to prase CA certificate: %v", err)
 	}
+
 	serverKey, err := x509.ParsePKCS1PrivateKey(serverKeyPem.Bytes)
 	if err != nil {
 		return fmt.Errorf("failed to parse server private key: %v", err)
 	}
+
 	serverCert, err := x509.ParseCertificate(serverCertPem.Bytes)
 	if err != nil {
 		return fmt.Errorf("failed to parse server certificate: %v", err)
 	}
+
 	cp.caKey = caKey
 	cp.caCert = caCert
 	cp.serverKey = serverKey
@@ -286,23 +344,67 @@ func (cp *Provider) parseSecret(secret *corev1.Secret) error {
 	return nil
 }
 
+// parseCertManagerSecret parses the certificate secret generated by cert-manager.
+func (cp *Provider) parseCertManagerSecret(secret *corev1.Secret) error {
+	if secret == nil {
+		return fmt.Errorf("secret is nil")
+	}
+
+	caCertPem, _ := pem.Decode(secret.Data[common.CACert])
+	serverCertPem, _ := pem.Decode(secret.Data[common.TLSCert])
+	serverKeyPem, _ := pem.Decode(secret.Data[common.TLSKey])
+	if caCertPem == nil || serverCertPem == nil || serverKeyPem == nil {
+		return fmt.Errorf("failed to decode secret data to pem block")
+	}
+
+	caCert, err := x509.ParseCertificate(caCertPem.Bytes)
+	if err != nil {
+		return fmt.Errorf("failed to parse CA private key: %v", err)
+	}
+
+	serverCert, err := x509.ParseCertificate(serverCertPem.Bytes)
+	if err != nil {
+		return fmt.Errorf("failed to parse server certificate: %v", err)
+	}
+
+	serverKey, err := x509.ParsePKCS1PrivateKey(serverKeyPem.Bytes)
+	if err != nil {
+		return fmt.Errorf("failed to parse server private key: %v", err)
+	}
+
+	cp.caCert = caCert
+	cp.serverKey = serverKey
+	cp.serverCert = serverCert
+	return nil
+}
+
 func (cp *Provider) updateSecret(ctx context.Context, secret *corev1.Secret) error {
+	if secret == nil {
+		return fmt.Errorf("secret is nil")
+	}
+
 	caKey, err := cp.CAKey()
 	if err != nil {
 		return fmt.Errorf("failed to get CA key: %v", err)
 	}
+
 	caCert, err := cp.CACert()
 	if err != nil {
 		return fmt.Errorf("failed to get CA certificate: %v", err)
 	}
+
 	serverKey, err := cp.ServerKey()
 	if err != nil {
 		return fmt.Errorf("failed to get server key: %v", err)
 	}
+
 	serverCert, err := cp.ServerCert()
 	if err != nil {
 		return fmt.Errorf("failed to get server certificate: %v", err)
 	}
+
+	logger := ctrl.LoggerFrom(ctx, "name", secret.Name, "namespace", secret.Namespace)
+	logger.Info("Updating secret")
 	if secret.Data == nil {
 		secret.Data = make(map[string][]byte)
 	}
diff --git a/pkg/common/constants.go b/pkg/common/constants.go
index 59ffc87..f979c2a 100644
--- a/pkg/common/constants.go
+++ b/pkg/common/constants.go
@@ -30,10 +30,16 @@ const (
 )
 
 const (
+	// Internal Cert
 	CAKeyPem      = "ca-key.pem"
 	CACertPem     = "ca-cert.pem"
 	ServerKeyPem  = "server-key.pem"
 	ServerCertPem = "server-cert.pem"
+
+	// Cert Manager
+	CACert  = "ca.crt"
+	TLSCert = "tls.crt"
+	TLSKey  = "tls.key"
 )
 
 // Kubernetes volume types.



Test patch:
diff --git a/charts/spark-operator-chart/tests/certmanager/certificate_test.yaml b/charts/spark-operator-chart/tests/certmanager/certificate_test.yaml
new file mode 100644
index 0000000..fe0bb2e
--- /dev/null
+++ b/charts/spark-operator-chart/tests/certmanager/certificate_test.yaml
@@ -0,0 +1,134 @@
+#
+# Copyright 2025 The Kubeflow 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
+#
+#     https://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.
+#
+
+suite: Test CertManager Certificate
+
+templates:
+  - certmanager/certificate.yaml
+
+release:
+  name: spark-operator
+  namespace: spark-operator
+
+tests:
+  - it: Should not create Certificate if `webhook.enable` is `false`
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Certificate
+    set:
+      webhook:
+        enable: false
+      certManager:
+        enable: true
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: Should not create Certificate if `certManager.enable` is `false`
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Certificate
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: false
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: Should create Certificate if `webhook.enable` is `true` and `certManager.enable` is `true`
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Certificate
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+    asserts:
+      - containsDocument:
+          apiVersion: cert-manager.io/v1
+          kind: Certificate
+          name: spark-operator-certificate
+          namespace: spark-operator
+
+  - it: Should fail if the cluster does not support `cert-manager.io/v1/Certificate`
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+    asserts:
+      - failedTemplate:
+          errorMessage: "The cluster does not support the required API version `cert-manager.io/v1` for `Certificate`."
+
+  - it: Should use self signed issuer if `certManager.issuerRef` is not set
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Certificate
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+        issuerRef:
+          group: cert-manager.io
+          kind: Issuer
+          name: test-issuer
+    asserts:
+      - equal:
+          path: spec.issuerRef
+          value:
+            group: cert-manager.io
+            kind: Issuer
+            name: test-issuer
+
+  - it: Should use the specified issuer if `certManager.issuerRef` is set
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Certificate
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+        issuerRef:
+          group: cert-manager.io
+          kind: Issuer
+          name: test-issuer
+    asserts:
+      - equal:
+          path: spec.issuerRef
+          value:
+            group: cert-manager.io
+            kind: Issuer
+            name: test-issuer
+
+  - it: Should use the specified duration if `certManager.duration` is set
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Certificate
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+        duration: 8760h
+    asserts:
+      - equal:
+          path: spec.duration
+          value: 8760h
diff --git a/charts/spark-operator-chart/tests/certmanager/issuer_test.yaml b/charts/spark-operator-chart/tests/certmanager/issuer_test.yaml
new file mode 100644
index 0000000..b30450c
--- /dev/null
+++ b/charts/spark-operator-chart/tests/certmanager/issuer_test.yaml
@@ -0,0 +1,95 @@
+#
+# Copyright 2025 The Kubeflow 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
+#
+#     https://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.
+#
+
+suite: Test CertManager Issuer
+
+templates:
+  - certmanager/issuer.yaml
+
+release:
+  name: spark-operator
+  namespace: spark-operator
+
+tests:
+  - it: Should not create Issuer if `webhook.enable` is `false`
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Issuer
+    set:
+      webhook:
+        enable: false
+      certManager:
+        enable: true
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: Should not create Issuer if `certManager.enable` is `false`
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Issuer
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: false
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: Should not create Issuer if `certManager.issuerRef` is set
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Issuer
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+        issuerRef:
+          group: cert-manager.io
+          kind: Issuer
+          name: test-issuer
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: Should fail if the cluster does not support `cert-manager.io/v1/Issuer`
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+    asserts:
+      - failedTemplate:
+          errorMessage: "The cluster does not support the required API version `cert-manager.io/v1` for `Issuer`."
+
+  - it: Should create Issuer if `webhook.enable` is `true` and `certManager.enable` is `true`
+    capabilities:
+      apiVersions:
+        - cert-manager.io/v1/Issuer
+    set:
+      webhook:
+        enable: true
+      certManager:
+        enable: true
+        issuerRef: null
+    asserts:
+      - containsDocument:
+          apiVersion: cert-manager.io/v1
+          kind: Issuer
+          name: spark-operator-self-signed-issuer
+          namespace: spark-operator
diff --git a/pkg/certificate/certificate_test.go b/pkg/certificate/certificate_test.go
index 7550a7f..08812d3 100644
--- a/pkg/certificate/certificate_test.go
+++ b/pkg/certificate/certificate_test.go
@@ -43,7 +43,7 @@ var _ = Describe("Certificate Provider", func() {
 
 		BeforeEach(func() {
 			By("Creating a new cert provider")
-			cp = certificate.NewProvider(k8sClient, secretName, secretNamespace)
+			cp = certificate.NewProvider(k8sClient, secretName, secretNamespace, false)
 			Expect(cp).NotTo(BeNil())
 
 			By("Generating new certificates")
@@ -108,7 +108,7 @@ var _ = Describe("Certificate Provider", func() {
 
 		It("Should generate new certificates and update webhook secret", func() {
 			By("Creating a new CertProvider")
-			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace)
+			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace, false)
 			Expect(cp.SyncSecret(context.TODO(), secretName, secretNamespace)).To(Succeed())
 
 			By("Checking out whether the data of webhook secret is populated")
@@ -142,7 +142,7 @@ var _ = Describe("Certificate Provider", func() {
 			Expect(k8sClient.Create(ctx, secret)).To(Succeed())
 
 			By("Creating a new CertProvider and synchronize generated certificates to webhook secret")
-			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace)
+			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace, false)
 			Expect(cp.SyncSecret(context.TODO(), secretName, secretNamespace)).To(Succeed())
 
 			By("Creating a new webhook secret with data populated")
@@ -160,7 +160,7 @@ var _ = Describe("Certificate Provider", func() {
 
 		It("Should synchronize webhook certificates data", func() {
 			By("Creating a new cert provider and synchronize generated certificates to webhook secret")
-			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace)
+			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace, false)
 			Expect(cp.SyncSecret(context.TODO(), secretName, secretNamespace)).To(Succeed())
 
 			By("Checking out whether the webhook certificates is synchronized into the cert provider")
@@ -199,10 +199,10 @@ var _ = Describe("Certificate Provider", func() {
 			Expect(k8sClient.Update(ctx, secret)).To(Succeed())
 
 			By("Creating a new cert provider and synchronize should generate new certificates")
-			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace)
+			cp := certificate.NewProvider(k8sClient, secretName, secretNamespace, false)
 			Expect(cp.SyncSecret(context.TODO(), secretName, secretNamespace)).To(Succeed())
 
-			By("Checking out whether the webhook certificate changed after syncronize")
+			By("Checking out whether the webhook certificate changed after synchronize")
 			serverPEM, err := cp.ServerCert()
 			Expect(err).To(BeNil())
 			Expect(serverPEM).ShouldNot(Equal(expiredPEM))