diff --git a/PROJECT b/PROJECT index b7bf0d1..cd412b4 100644 --- a/PROJECT +++ b/PROJECT @@ -20,4 +20,7 @@ resources: kind: ManagedCRL path: github.com/scality/crl-operator/api/v1alpha1 version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/cmd/main.go b/cmd/main.go index b11db9d..14d6747 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -43,6 +43,7 @@ import ( crloperatorv1alpha1 "github.com/scality/crl-operator/api/v1alpha1" "github.com/scality/crl-operator/internal/controller" + webhookv1alpha1 "github.com/scality/crl-operator/internal/webhook/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -218,6 +219,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "ManagedCRL") os.Exit(1) } + // nolint:goconst + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err := webhookv1alpha1.SetupManagedCRLWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "ManagedCRL") + os.Exit(1) + } + } // +kubebuilder:scaffold:builder // Create a field index for ClusterIssuer, Issuer and Secret references diff --git a/config/certmanager/certificate-metrics.yaml b/config/certmanager/certificate-metrics.yaml new file mode 100644 index 0000000..2982d11 --- /dev/null +++ b/config/certmanager/certificate-metrics.yaml @@ -0,0 +1,20 @@ +# The following manifests contain a self-signed issuer CR and a metrics certificate CR. +# More document can be found at https://docs.cert-manager.io +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + app.kubernetes.io/name: crl-operator + app.kubernetes.io/managed-by: kustomize + name: metrics-certs # this name should match the one appeared in kustomizeconfig.yaml + namespace: system +spec: + dnsNames: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + # replacements in the config/default/kustomization.yaml file. + - SERVICE_NAME.SERVICE_NAMESPACE.svc + - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: metrics-server-cert diff --git a/config/certmanager/certificate-webhook.yaml b/config/certmanager/certificate-webhook.yaml new file mode 100644 index 0000000..254760b --- /dev/null +++ b/config/certmanager/certificate-webhook.yaml @@ -0,0 +1,20 @@ +# The following manifests contain a self-signed issuer CR and a certificate CR. +# More document can be found at https://docs.cert-manager.io +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + app.kubernetes.io/name: crl-operator + app.kubernetes.io/managed-by: kustomize + name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml + namespace: system +spec: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + # replacements in the config/default/kustomization.yaml file. + dnsNames: + - SERVICE_NAME.SERVICE_NAMESPACE.svc + - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: webhook-server-cert diff --git a/config/certmanager/issuer.yaml b/config/certmanager/issuer.yaml new file mode 100644 index 0000000..b82808a --- /dev/null +++ b/config/certmanager/issuer.yaml @@ -0,0 +1,13 @@ +# The following manifest contains a self-signed issuer CR. +# More information can be found at https://docs.cert-manager.io +# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + labels: + app.kubernetes.io/name: crl-operator + app.kubernetes.io/managed-by: kustomize + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 0000000..fcb7498 --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,7 @@ +resources: +- issuer.yaml +- certificate-webhook.yaml +- certificate-metrics.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 0000000..cf6f89e --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -0,0 +1,8 @@ +# This configuration is for teaching kustomize how to update name ref substitution +nameReference: +- kind: Issuer + group: cert-manager.io + fieldSpecs: + - kind: Certificate + group: cert-manager.io + path: spec/issuerRef/name diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 0f37a94..847ba02 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -20,9 +20,9 @@ resources: - ../manager # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- ../webhook +- ../webhook # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager +- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus # [METRICS] Expose the controller manager metrics service. @@ -50,141 +50,141 @@ patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- path: manager_webhook_patch.yaml -# target: -# kind: Deployment +- path: manager_webhook_patch.yaml + target: + kind: Deployment # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. # Uncomment the following replacements to add the cert-manager CA injection annotations -#replacements: -# - source: # Uncomment the following block to enable certificates for metrics -# kind: Service -# version: v1 -# name: controller-manager-metrics-service -# fieldPath: metadata.name -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: metrics-certs -# fieldPaths: -# - spec.dnsNames.0 -# - spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 0 -# create: true -# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor -# kind: ServiceMonitor -# group: monitoring.coreos.com -# version: v1 -# name: controller-manager-metrics-monitor -# fieldPaths: -# - spec.endpoints.0.tlsConfig.serverName -# options: -# delimiter: '.' -# index: 0 -# create: true -# -# - source: -# kind: Service -# version: v1 -# name: controller-manager-metrics-service -# fieldPath: metadata.namespace -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: metrics-certs -# fieldPaths: -# - spec.dnsNames.0 -# - spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 1 -# create: true -# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor -# kind: ServiceMonitor -# group: monitoring.coreos.com -# version: v1 -# name: controller-manager-metrics-monitor -# fieldPaths: -# - spec.endpoints.0.tlsConfig.serverName -# options: -# delimiter: '.' -# index: 1 -# create: true -# -# - source: # Uncomment the following block if you have any webhook -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.name # Name of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 0 -# create: true -# - source: -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.namespace # Namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 1 -# create: true -# -# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # This name should match the one in certificate.yaml -# fieldPath: .metadata.namespace # Namespace of the certificate CR -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert -# fieldPath: .metadata.name -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# +replacements: +- source: # Uncomment the following block to enable certificates for metrics + kind: Service + version: v1 + name: controller-manager-metrics-service + fieldPath: metadata.name + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + name: metrics-certs + fieldPaths: + - spec.dnsNames.0 + - spec.dnsNames.1 + options: + delimiter: '.' + index: 0 + create: true + - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor + kind: ServiceMonitor + group: monitoring.coreos.com + version: v1 + name: controller-manager-metrics-monitor + fieldPaths: + - spec.endpoints.0.tlsConfig.serverName + options: + delimiter: '.' + index: 0 + create: true + +- source: + kind: Service + version: v1 + name: controller-manager-metrics-service + fieldPath: metadata.namespace + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + name: metrics-certs + fieldPaths: + - spec.dnsNames.0 + - spec.dnsNames.1 + options: + delimiter: '.' + index: 1 + create: true + - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor + kind: ServiceMonitor + group: monitoring.coreos.com + version: v1 + name: controller-manager-metrics-monitor + fieldPaths: + - spec.endpoints.0.tlsConfig.serverName + options: + delimiter: '.' + index: 1 + create: true + +- source: # Uncomment the following block if you have any webhook + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.name # Name of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 0 + create: true +- source: + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.namespace # Namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 1 + create: true + +- source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # This name should match the one in certificate.yaml + fieldPath: .metadata.namespace # Namespace of the certificate CR + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true +- source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPath: .metadata.name + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + # - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) # kind: Certificate # group: cert-manager.io diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 0000000..963c8a4 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,31 @@ +# This patch ensures the webhook certificates are properly mounted in the manager container. +# It configures the necessary arguments, volumes, volume mounts, and container ports. + +# Add the --webhook-cert-path argument for configuring the webhook certificate path +- op: add + path: /spec/template/spec/containers/0/args/- + value: --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs + +# Add the volumeMount for the webhook certificates +- op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + mountPath: /tmp/k8s-webhook-server/serving-certs + name: webhook-certs + readOnly: true + +# Add the port configuration for the webhook server +- op: add + path: /spec/template/spec/containers/0/ports/- + value: + containerPort: 9443 + name: webhook-server + protocol: TCP + +# Add the volume configuration for the webhook certificates +- op: add + path: /spec/template/spec/volumes/- + value: + name: webhook-certs + secret: + secretName: webhook-server-cert diff --git a/config/network-policy/allow-webhook-traffic.yaml b/config/network-policy/allow-webhook-traffic.yaml new file mode 100644 index 0000000..aa34fb1 --- /dev/null +++ b/config/network-policy/allow-webhook-traffic.yaml @@ -0,0 +1,27 @@ +# This NetworkPolicy allows ingress traffic to your webhook server running +# as part of the controller-manager from specific namespaces and pods. CR(s) which uses webhooks +# will only work when applied in namespaces labeled with 'webhook: enabled' +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + app.kubernetes.io/name: crl-operator + app.kubernetes.io/managed-by: kustomize + name: allow-webhook-traffic + namespace: system +spec: + podSelector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: crl-operator + policyTypes: + - Ingress + ingress: + # This allows ingress traffic from any namespace with the label webhook: enabled + - from: + - namespaceSelector: + matchLabels: + webhook: enabled # Only from namespaces with this label + ports: + - port: 443 + protocol: TCP diff --git a/config/network-policy/kustomization.yaml b/config/network-policy/kustomization.yaml index ec0fb5e..0872bee 100644 --- a/config/network-policy/kustomization.yaml +++ b/config/network-policy/kustomization.yaml @@ -1,2 +1,3 @@ resources: +- allow-webhook-traffic.yaml - allow-metrics-traffic.yaml diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 0000000..9cf2613 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 0000000..206316e --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,22 @@ +# the following config is for teaching kustomize where to look at when substituting nameReference. +# It requires kustomize v2.1.0 or newer to work properly. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 0000000..82b6f00 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-crl-operator-scality-com-v1alpha1-managedcrl + failurePolicy: Fail + name: vmanagedcrl-v1alpha1.kb.io + rules: + - apiGroups: + - crl-operator.scality.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - managedcrls + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 0000000..e3b4b62 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: crl-operator + app.kubernetes.io/managed-by: kustomize + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager + app.kubernetes.io/name: crl-operator diff --git a/go.mod b/go.mod index e19f521..aa008ac 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.3 github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect diff --git a/internal/webhook/v1alpha1/managedcrl_webhook.go b/internal/webhook/v1alpha1/managedcrl_webhook.go new file mode 100644 index 0000000..e9f549d --- /dev/null +++ b/internal/webhook/v1alpha1/managedcrl_webhook.go @@ -0,0 +1,148 @@ +/* +Copyright 2025 Scality. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "fmt" + + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/go-logr/logr" + crloperatorv1alpha1 "github.com/scality/crl-operator/api/v1alpha1" +) + +// nolint:unused +// log is for logging in this package. +var managedcrllog = logf.Log.WithName("managedcrl-resource") + +// SetupManagedCRLWebhookWithManager registers the webhook for ManagedCRL in the manager. +func SetupManagedCRLWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr).For(&crloperatorv1alpha1.ManagedCRL{}). + WithValidator(&ManagedCRLCustomValidator{ + client: mgr.GetClient(), + }). + Complete() +} + +// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. +// NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here. +// Modifying the path for an invalid path can cause API server errors; failing to locate the webhook. +// +kubebuilder:webhook:path=/validate-crl-operator-scality-com-v1alpha1-managedcrl,mutating=false,failurePolicy=fail,sideEffects=None,groups=crl-operator.scality.com,resources=managedcrls,verbs=create;update,versions=v1alpha1,name=vmanagedcrl-v1alpha1.kb.io,admissionReviewVersions=v1 + +// ManagedCRLCustomValidator struct is responsible for validating the ManagedCRL resource +// when it is created, updated, or deleted. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as this struct is used only for temporary operations and does not need to be deeply copied. +type ManagedCRLCustomValidator struct { + client client.Client +} + +var _ webhook.CustomValidator = &ManagedCRLCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type ManagedCRL. +func (v *ManagedCRLCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + managedcrl, ok := obj.(*crloperatorv1alpha1.ManagedCRL) + if !ok { + return nil, fmt.Errorf("expected a ManagedCRL object but got %T", obj) + } + logger := managedcrllog.WithValues("name", managedcrl.GetName()).WithValues("namespace", managedcrl.GetNamespace()) + + logger.Info("Validation for ManagedCRL upon creation") + + return nil, validationManagedCRL(logger, ctx, v.client, managedcrl) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type ManagedCRL. +func (v *ManagedCRLCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + managedcrl, ok := newObj.(*crloperatorv1alpha1.ManagedCRL) + if !ok { + return nil, fmt.Errorf("expected a ManagedCRL object for the newObj but got %T", newObj) + } + logger := managedcrllog.WithValues("name", managedcrl.GetName()).WithValues("namespace", managedcrl.GetNamespace()) + logger.Info("Validation for ManagedCRL upon update") + + return nil, validationManagedCRL(logger, ctx, v.client, managedcrl) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type ManagedCRL. +func (v *ManagedCRLCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + managedcrl, ok := obj.(*crloperatorv1alpha1.ManagedCRL) + if !ok { + return nil, fmt.Errorf("expected a ManagedCRL object but got %T", obj) + } + managedcrllog.Info("Validation for ManagedCRL upon deletion", "name", managedcrl.GetName()) + + // TODO(user): fill in your validation logic upon object deletion. + + return nil, nil +} + +// validationManagedCRL validates the ManagedCRL fields. +func validationManagedCRL(logger logr.Logger, ctx context.Context, c client.Client, managedcrl *crloperatorv1alpha1.ManagedCRL) error { + managedcrl.WithDefaults() + if err := managedcrl.Validate(); err != nil { + logger.Error(err, "Validation failed") + return err + } + + // Ensure the specified Issuer or ClusterIssuer exists + issuerLogger := logger.WithValues("issuer_name", managedcrl.Spec.IssuerRef.Name, "issuer_kind", managedcrl.Spec.IssuerRef.Kind) + issuerRef := managedcrl.Spec.IssuerRef + switch issuerRef.Kind { + case "Issuer": + var issuer cmv1.Issuer + err := c.Get(ctx, client.ObjectKey{Namespace: managedcrl.Namespace, Name: issuerRef.Name}, &issuer) + if err != nil { + issuerLogger.Error(err, "Issuer not found") + return fmt.Errorf("issuer %s not found in namespace %s", issuerRef.Name, managedcrl.Namespace) + } + if issuer.Spec.CA == nil || issuer.Spec.CA.SecretName == "" { + err := fmt.Errorf("issuer %s in namespace %s is not a CA issuer", issuerRef.Name, managedcrl.Namespace) + issuerLogger.Error(err, "Issuer is not a CA issuer") + return err + } + case "ClusterIssuer": + var issuer cmv1.ClusterIssuer + err := c.Get(ctx, client.ObjectKey{Name: issuerRef.Name}, &issuer) + if err != nil { + issuerLogger.Error(err, "ClusterIssuer not found") + return fmt.Errorf("clusterissuer %s not found", issuerRef.Name) + } + if issuer.Spec.CA == nil || issuer.Spec.CA.SecretName == "" { + err := fmt.Errorf("clusterissuer %s is not a CA issuer", issuerRef.Name) + issuerLogger.Error(err, "Issuer is not a CA issuer") + return err + } + default: + err := fmt.Errorf("invalid IssuerRef kind: %s", issuerRef.Kind) + issuerLogger.Error(err, "IssuerRef kind must be either 'Issuer' or 'ClusterIssuer'") + return err + } + + logger.Info("Validation successful") + return nil +} diff --git a/internal/webhook/v1alpha1/managedcrl_webhook_test.go b/internal/webhook/v1alpha1/managedcrl_webhook_test.go new file mode 100644 index 0000000..eb45aef --- /dev/null +++ b/internal/webhook/v1alpha1/managedcrl_webhook_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2025 Scality. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + crloperatorv1alpha1 "github.com/scality/crl-operator/api/v1alpha1" + // TODO (user): Add any additional imports if needed +) + +var _ = Describe("ManagedCRL Webhook", func() { + var ( + obj *crloperatorv1alpha1.ManagedCRL + oldObj *crloperatorv1alpha1.ManagedCRL + validator ManagedCRLCustomValidator + ) + + BeforeEach(func() { + obj = &crloperatorv1alpha1.ManagedCRL{} + oldObj = &crloperatorv1alpha1.ManagedCRL{} + validator = ManagedCRLCustomValidator{} + Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + // TODO (user): Add any setup logic common to all tests + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating or updating ManagedCRL under Validating Webhook", func() { + // TODO (user): Add logic for validating webhooks + // Example: + // It("Should deny creation if a required field is missing", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "" + // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) + // }) + // + // It("Should admit creation if all required fields are present", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "valid_value" + // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) + // }) + // + // It("Should validate updates correctly", func() { + // By("simulating a valid update scenario") + // oldObj.SomeRequiredField = "updated_value" + // obj.SomeRequiredField = "updated_value" + // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) + // }) + }) + +}) diff --git a/internal/webhook/v1alpha1/webhook_suite_test.go b/internal/webhook/v1alpha1/webhook_suite_test.go new file mode 100644 index 0000000..d544afb --- /dev/null +++ b/internal/webhook/v1alpha1/webhook_suite_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2025 Scality. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + crloperatorv1alpha1 "github.com/scality/crl-operator/api/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + k8sClient client.Client + cfg *rest.Config + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = crloperatorv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, + }, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager. + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = SetupManagedCRLWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready. + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return err + } + + return conn.Close() + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 436ae77..59d7576 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -256,6 +256,30 @@ var _ = Describe("Manager", Ordered, func() { )) }) + It("should provisioned cert-manager", func() { + By("validating that cert-manager has the certificate Secret") + verifyCertManager := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secrets", "webhook-server-cert", "-n", namespace) + _, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + } + Eventually(verifyCertManager).Should(Succeed()) + }) + + It("should have CA injection for validating webhooks", func() { + By("checking CA injection for validating webhooks") + verifyCAInjection := func(g Gomega) { + cmd := exec.Command("kubectl", "get", + "validatingwebhookconfigurations.admissionregistration.k8s.io", + "crl-operator-validating-webhook-configuration", + "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") + vwhOutput, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(vwhOutput)).To(BeNumerically(">", 10)) + } + Eventually(verifyCAInjection).Should(Succeed()) + }) + // +kubebuilder:scaffold:e2e-webhooks-checks // TODO: Customize the e2e test suite with scenarios specific to your project. diff --git a/test/integration/managedcrl_controller_test.go b/test/integration/managedcrl_controller_test.go index 8dbb383..73f6bb0 100644 --- a/test/integration/managedcrl_controller_test.go +++ b/test/integration/managedcrl_controller_test.go @@ -21,6 +21,7 @@ import ( "crypto/x509" "fmt" "math/big" + "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -32,6 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -42,6 +44,7 @@ type mcrlTestCase struct { name string spec crloperatorv1alpha1.ManagedCRLSpec shouldError bool + errorMessage string shouldExposePod bool shouldExposeIngress bool shouldConfigureIssuer bool @@ -177,6 +180,61 @@ var ( shouldExposePod: true, shouldExposeIngress: true, shouldConfigureIssuer: true, + }, { + name: "error-invalid-issuer-kind", + spec: crloperatorv1alpha1.ManagedCRLSpec{ + IssuerRef: cmmetav1.IssuerReference{ + Kind: "InvalidKind", + }, + }, + shouldError: true, + errorMessage: "issuerRef kind must be either 'Issuer' or 'ClusterIssuer', got 'InvalidKind'", + }, { + name: "error-non-existent-issuer", + spec: crloperatorv1alpha1.ManagedCRLSpec{ + IssuerRef: cmmetav1.IssuerReference{ + Name: "non-existent-issuer", + }, + }, + shouldError: true, + errorMessage: "issuer non-existent-issuer not found", + }, { + name: "error-non-ca-issuer", + spec: crloperatorv1alpha1.ManagedCRLSpec{ + IssuerRef: cmmetav1.IssuerReference{ + Name: "test-issuer-non-ca", + }, + }, + shouldError: true, + errorMessage: "issuer test-issuer-non-ca .*is not a CA issuer", + }, { + name: "error-too-small-duration", + spec: crloperatorv1alpha1.ManagedCRLSpec{ + Duration: &metav1.Duration{Duration: time.Hour}, + }, + shouldError: true, + errorMessage: "duration must be at least 24h", + }, { + name: "error-invalid-serial-number", + spec: crloperatorv1alpha1.ManagedCRLSpec{ + Revocations: []crloperatorv1alpha1.RevocationSpec{ + { + SerialNumber: "invalid-serial", + }, + }, + }, + shouldError: true, + errorMessage: "invalid serial number: invalid-serial", + }, { + name: "error-empty-ingress", + spec: crloperatorv1alpha1.ManagedCRLSpec{ + Expose: &crloperatorv1alpha1.CRLExposeSpec{ + Enabled: true, + Ingress: &crloperatorv1alpha1.IngressSpec{}, + }, + }, + shouldError: true, + errorMessage: "invalid ingress configuration: either hostname or ipAddresses must be specified", }, } ) @@ -186,15 +244,19 @@ func toTableEntry(tcs []mcrlTestCase) []TableEntry { for _, tc := range tcs { // Always add one entry for Issuer and one for ClusterIssuer name := tc.name - tc.spec.IssuerRef.Name = "test-issuer" - - tc.name = fmt.Sprintf("%s-issuer", name) - tc.spec.IssuerRef.Kind = "Issuer" - entries = append(entries, Entry(fmt.Sprintf("ManagedCRL %s", tc.name), tc)) + if tc.spec.IssuerRef.Name == "" { + tc.spec.IssuerRef.Name = "test-issuer" + } + issuerKindToTest := []string{"Issuer", "ClusterIssuer"} + if tc.spec.IssuerRef.Kind != "" { + issuerKindToTest = []string{tc.spec.IssuerRef.Kind} + } - tc.name = fmt.Sprintf("%s-clusterissuer", name) - tc.spec.IssuerRef.Kind = "ClusterIssuer" - entries = append(entries, Entry(fmt.Sprintf("ManagedCRL %s", tc.name), tc)) + for _, kind := range issuerKindToTest { + tc.name = fmt.Sprintf("%s-%s", name, strings.ToLower(kind)) + tc.spec.IssuerRef.Kind = kind + entries = append(entries, Entry(fmt.Sprintf("ManagedCRL %s", tc.name), tc)) + } } return entries } @@ -231,6 +293,19 @@ var _ = Describe("ManagedCRL Controller", func() { }, }, )).To(Succeed()) + Expect(k8sClient.Create( + ctx, + &cmv1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-issuer-non-ca", + }, + Spec: cmv1.IssuerSpec{ + IssuerConfig: cmv1.IssuerConfig{ + SelfSigned: &cmv1.SelfSignedIssuer{}, + }, + }, + }, + )).To(Succeed()) Expect(k8sClient.Create( ctx, &corev1.Secret{ @@ -261,6 +336,20 @@ var _ = Describe("ManagedCRL Controller", func() { }, }, )).To(Succeed()) + Expect(k8sClient.Create( + ctx, + &cmv1.Issuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-issuer-non-ca", + Namespace: testNamespace, + }, + Spec: cmv1.IssuerSpec{ + IssuerConfig: cmv1.IssuerConfig{ + SelfSigned: &cmv1.SelfSignedIssuer{}, + }, + }, + }, + )).To(Succeed()) }) AfterEach(func() { @@ -275,6 +364,11 @@ var _ = Describe("ManagedCRL Controller", func() { Name: "test-issuer", }, })).To(Succeed()) + Expect(k8sClient.Delete(ctx, &cmv1.ClusterIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-issuer-non-ca", + }, + })).To(Succeed()) }) DescribeTableSubtree("should reconcile various ManagedCRL resources as expected", func(tc mcrlTestCase) { @@ -295,6 +389,7 @@ var _ = Describe("ManagedCRL Controller", func() { err := k8sClient.Create(ctx, managedcrl) if tc.shouldError { Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(MatchRegexp(tc.errorMessage))) return } Expect(err).ToNot(HaveOccurred()) diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 5506df2..1c1aa3f 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -24,6 +24,7 @@ import ( "os" "path/filepath" "testing" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -38,9 +39,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/webhook" crloperatorv1alpha1 "github.com/scality/crl-operator/api/v1alpha1" "github.com/scality/crl-operator/internal/controller" + webhookv1alpha1 "github.com/scality/crl-operator/internal/webhook/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -137,6 +140,9 @@ var _ = BeforeSuite(func() { filepath.Join("..", "..", "testdata", "crds"), }, ErrorIfCRDPathMissing: true, + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "config", "webhook")}, + }, } // Retrieve the first found binary directory to allow running tests from IDEs @@ -155,8 +161,14 @@ var _ = BeforeSuite(func() { k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme.Scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: testEnv.WebhookInstallOptions.LocalServingHost, + Port: testEnv.WebhookInstallOptions.LocalServingPort, + CertDir: testEnv.WebhookInstallOptions.LocalServingCertDir, + }), }) Expect(err).ToNot(HaveOccurred()) + Expect(webhookv1alpha1.SetupManagedCRLWebhookWithManager(k8sManager)).To(Succeed()) // Create default cert-manager namespace Expect(k8sClient.Create( @@ -234,6 +246,16 @@ var _ = BeforeSuite(func() { err = k8sManager.Start(ctx) Expect(err).ToNot(HaveOccurred()) }() + + By("waiting for webhook server to be ready") + Eventually(func() error { + webhookServer := k8sManager.GetWebhookServer() + err := webhookServer.StartedChecker()(nil) + if err != nil { + return fmt.Errorf("webhook server not started: %w", err) + } + return nil + }, 10*time.Second, time.Second).Should(Succeed()) }) var _ = AfterSuite(func() {