From 7567232a72a6c9d74525d9315fb12a527e8afd1b Mon Sep 17 00:00:00 2001 From: Tim Ramlot <42113979+inteon@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:22:24 +0100 Subject: [PATCH 1/2] native client-go implementation Signed-off-by: Tim Ramlot <42113979+inteon@users.noreply.github.com> --- README.md | 128 ++- TODO | 11 +- diagrams/architecture.mmd | 20 + diagrams/architecture.svg | 1 + diagrams/rotation.mmd | 19 + diagrams/rotation.svg | 1 + examples/webhook-controller/README.md | 1 + .../deploy/01_namespace.yaml | 4 + .../webhook-controller/deploy/02_rbac.yaml | 60 ++ .../deploy/03_deployment.yaml | 40 + .../webhook-controller/deploy/04_service.yaml | 13 + .../deploy/05_validatingwebhook.yaml | 29 + .../deploy/kustomization.yaml | 6 + examples/webhook-controller/go.mod | 53 ++ examples/webhook-controller/go.sum | 120 +++ examples/webhook-controller/main.go | 175 ++++ .../webhook-controller/stress_test.sh | 14 +- go.mod | 18 +- go.sum | 71 -- internal/certificate/holder.go | 43 - internal/metrics/patch_counts.go | 58 ++ internal/pki/cert_pool.go | 20 + klone.yaml | 10 + make/00_mod.mk | 19 +- make/02_mod.mk | 37 +- make/_shared/kind/00_kind_image_versions.mk | 32 + make/_shared/kind/00_mod.mk | 33 + make/_shared/kind/01_mod.mk | 16 + make/_shared/kind/kind-image-preload.mk | 69 ++ make/_shared/kind/kind.mk | 86 ++ make/_shared/oci-build/00_mod.mk | 135 +++ make/_shared/oci-build/01_mod.mk | 83 ++ make/config/kind/cluster.yaml | 19 + make/test-smoke.mk | 35 + make/test-unit.mk | 23 +- pkg/authority/api/api.go | 40 +- pkg/authority/authority.go | 822 ++++++++++++++++-- pkg/authority/authority_test.go | 286 ++++++ pkg/authority/ca_secret_controller.go | 180 ---- pkg/authority/ca_secret_controller_test.go | 183 ---- pkg/authority/certinfo.go | 77 ++ pkg/authority/informerfactory/factory.go | 162 ++++ pkg/authority/injectable/injectable.go | 41 +- .../injectable/validating_webhook.go | 135 ++- pkg/authority/injectable_controller.go | 117 --- .../internal/autodetect/incluster.go | 47 + .../internal/autodetect/namespace.go | 40 + pkg/authority/internal/queuefix/queue_fix.go | 43 + .../internal/queuefix/queue_fix_test.go | 68 ++ pkg/authority/internal/ssa/client.go | 45 - pkg/authority/leaf_cert_controller.go | 90 -- pkg/authority/options.go | 160 +++- pkg/authority/reconciler.go | 51 -- pkg/authority/target_object.go | 69 ++ pkg/authority/target_object_test.go | 72 ++ pkg/runtime/types.go | 23 - test/ca_secret_controller_test.go | 143 --- test/combined_controller_test.go | 319 +++++++ test/delayed_watch.go | 147 ++++ test/go.mod | 38 +- test/go.sum | 129 +-- test/injectable_controller_test.go | 114 --- test/leaf_cert_controller_test.go | 115 --- test/suite_test.go | 77 -- test/util_test.go | 196 ++++- 65 files changed, 3925 insertions(+), 1606 deletions(-) create mode 100644 diagrams/architecture.mmd create mode 100644 diagrams/architecture.svg create mode 100644 diagrams/rotation.mmd create mode 100644 diagrams/rotation.svg create mode 100644 examples/webhook-controller/README.md create mode 100644 examples/webhook-controller/deploy/01_namespace.yaml create mode 100644 examples/webhook-controller/deploy/02_rbac.yaml create mode 100644 examples/webhook-controller/deploy/03_deployment.yaml create mode 100644 examples/webhook-controller/deploy/04_service.yaml create mode 100644 examples/webhook-controller/deploy/05_validatingwebhook.yaml create mode 100644 examples/webhook-controller/deploy/kustomization.yaml create mode 100644 examples/webhook-controller/go.mod create mode 100644 examples/webhook-controller/go.sum create mode 100644 examples/webhook-controller/main.go rename make/test-e2e.mk => examples/webhook-controller/stress_test.sh (76%) mode change 100644 => 100755 delete mode 100644 internal/certificate/holder.go create mode 100644 internal/metrics/patch_counts.go create mode 100755 make/_shared/kind/00_kind_image_versions.mk create mode 100644 make/_shared/kind/00_mod.mk create mode 100644 make/_shared/kind/01_mod.mk create mode 100644 make/_shared/kind/kind-image-preload.mk create mode 100644 make/_shared/kind/kind.mk create mode 100644 make/_shared/oci-build/00_mod.mk create mode 100644 make/_shared/oci-build/01_mod.mk create mode 100644 make/config/kind/cluster.yaml create mode 100644 make/test-smoke.mk create mode 100644 pkg/authority/authority_test.go delete mode 100644 pkg/authority/ca_secret_controller.go delete mode 100644 pkg/authority/ca_secret_controller_test.go create mode 100644 pkg/authority/certinfo.go create mode 100644 pkg/authority/informerfactory/factory.go delete mode 100644 pkg/authority/injectable_controller.go create mode 100644 pkg/authority/internal/autodetect/incluster.go create mode 100644 pkg/authority/internal/autodetect/namespace.go create mode 100644 pkg/authority/internal/queuefix/queue_fix.go create mode 100644 pkg/authority/internal/queuefix/queue_fix_test.go delete mode 100644 pkg/authority/internal/ssa/client.go delete mode 100644 pkg/authority/leaf_cert_controller.go delete mode 100644 pkg/authority/reconciler.go create mode 100644 pkg/authority/target_object.go create mode 100644 pkg/authority/target_object_test.go delete mode 100644 pkg/runtime/types.go delete mode 100644 test/ca_secret_controller_test.go create mode 100644 test/combined_controller_test.go create mode 100644 test/delayed_watch.go delete mode 100644 test/injectable_controller_test.go delete mode 100644 test/leaf_cert_controller_test.go delete mode 100644 test/suite_test.go diff --git a/README.md b/README.md index 30aa28e..05fc009 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,126 @@ -# cert-manager webhook-cert-lib +# webhook-cert-lib -Note: This project is under development and will be changed without notice. -Do NOT use in production! +`webhook-cert-lib` is a Golang library designed to simplify the management of certificates for Kubernetes Webhooks (Validating, Mutating, or Conversion webhooks). It handles the creation, rotation, and injection of Certificate Authorities (CA) and leaf certificates, ensuring your webhooks function securely and reliably. + +> **Note**: This project is under development and APIs may change. + +## Features + +- **Automatic CA Management**: Generates and rotates CA certificates automatically. +- **CA Injection**: Injects the CA bundle into `ValidatingWebhookConfiguration`, `MutatingWebhookConfiguration`, and `CustomResourceDefinition` (CRD) resources. +- **Leaf Certificate Management**: Issues and rotates leaf certificates for the webhook server. +- **Zero-Downtime Rotation**: Implements a pending-CA promotion strategy to ensure all clients have the new CA before it is used for signing. +- **High Availability Support**: Designed to run with multiple replicas of the controller, with little contention between instances. + +## Architecture + +The following diagram illustrates how the `Authority` controller interacts with Kubernetes resources to secure your webhook: + + + + +## Installation + +```bash +go get github.com/cert-manager/webhook-cert-lib +``` + +## Usage + +See [./examples/webhook-controller](./examples/webhook-controller) for a complete example. + +### 1. Initialize Authority + +Create an `Authority` instance using your Kubernetes config or clientset. + +```go +import ( + "time" + + "github.com/cert-manager/webhook-cert-lib/pkg/authority" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" +) + +func main() { + cfg, _ := rest.InClusterConfig() + + opts := authority.AuthorityOptions{ + AuthorityCertificate: authority.AuthorityCertificateOptions{ + SecretNamespacedName: types.NamespacedName{ + Namespace: "my-namespace", + Name: "my-webhook-ca", + }, + Duration: 1 * time.Hour, // Optional: Default 1h + }, + + // Optional: Default 2s + PromotionDelay: 2 * time.Second, + + ServerCertificate: authority.ServerCertificateOptions{ + DNSNames: []string{"my-service.my-namespace.svc"}, + Duration: 1 * time.Hour, // Optional: Default 1h + }, + } + + auth, err := authority.NewAuthorityForConfig(cfg, opts) + if err != nil { + panic(err) + } + + // ... +} +``` + +### 2. Configure TLS Server + +Use the `ServingCertificate` method to hook into your Go `http.Server` or `listener`. + +```go + tlsConfig := &tls.Config{} + auth.ServingCertificate(tlsConfig) + + server := &http.Server{ + Addr: ":9443", + TLSConfig: tlsConfig, + } +``` + +### 3. Run the Authority + +Run the authority in a separate goroutine or manage it with a context. + +```go + ctx := context.Background() + go func() { + if err := auth.Start(ctx); err != nil { + panic(err) + } + }() + + server.ListenAndServeTLS("", "") +``` + +## How the Controller Works + +### CA Rotation Lifecycle + +To ensure zero downtime, the controller uses a "Pending CA" strategy. This allows the new CA to be distributed to all clients (via the `ValidatingWebhookConfiguration` CA bundle) *before* it is used to sign the serving certificate. + + + + +1. **Issue Pending CA**: Generate a new CA (pending) and store it in the CA Secret alongside the current serving CA. +2. **Inject Bundle**: Update webhook resources (e.g., `ValidatingWebhookConfiguration`, `MutatingWebhookConfiguration`, CRDs) with the combined trust bundle (old + new). +3. **Promotion Delay**: Wait `PromotionDelay` (default 2s) to allow the new bundle to propagate. +4. **Promote CA**: Promote the pending CA to be the serving CA. +5. **Rotate Leaf Cert**: Issue new leaf certificate signed by the new serving CA. +6. **Cleanup**: Remove the old CA from the trust bundle and injected resources when it is no longer required. + +### Multi-Replica Coordination + +When the controller detects the serving CA needs renewal it attempts to create a pending CA; only one instance will succeed. Each instance has a unique `authorityID`. The instance that issues the pending CA is responsible for injecting the bundle and promoting the CA; other instances wait, and may take over if the issuer stalls. + +- **Leader first**: The controller that issued the pending CA can perform injection and promotion directly, while other controllers have to wait between 3-6s first. +- **Jitter**: Non-issuing controllers wait with randomized jitter (between 3 and 6 seconds) to avoid thundering-herd updates. +- **Renewal window**: The renewal timestamp is randomized to avoid all controllers renewing the certs at the same time. diff --git a/TODO b/TODO index 5ab831a..8b5ceb1 100644 --- a/TODO +++ b/TODO @@ -1,9 +1,4 @@ -MUST: -- make sure to re-reconcile certificates before they expire -- Scope down controller RBAC to single CA Secret resource. -- Use cli flags in the program to list the injectables, allowing use to scope down the injectable RBAC. - CONSIDER: -- maybe remove dependency on controller-runtime (use client-go directly instead) -- maybe support all controller-runtime Server types: webhook and metrics (might require rebranding) -- Can we make the solution leader-election-less? Does it make sense that we use the existing cr leader election or should we create a separate leader-election just for the logic in this library? +- add support for prometheus pod monitor: + - https://doc.crds.dev/github.com/prometheus-operator/prometheus-operator/monitoring.coreos.com/PodMonitor/v1@v0.76.0#spec-podMetricsEndpoints-tlsConfig-ca + - inject trust bundle into ConfigMap that we refer to using the PodMonitor object diff --git a/diagrams/architecture.mmd b/diagrams/architecture.mmd new file mode 100644 index 0000000..1c67c71 --- /dev/null +++ b/diagrams/architecture.mmd @@ -0,0 +1,20 @@ +graph TD + APIServer[API Server] + Webhook[Webhook Server] + VWC[ValidatingWebhookConfiguration] + + APIServer -->|Validation Request| Webhook + VWC .->|References webhook| Webhook + VWC .->|Read by| APIServer + + subgraph "webhook-cert-lib" + Authority[Authority Controller] + Secret[Secret] + + Authority -->|1. Manages pending & serving CAs and keys| Secret + Authority -->|2. Injects CAs| VWC + Authority -->|3. Issues Leaf Cert| Webhook + end + + style Secret fill:gray,stroke:#333 + style VWC fill:gray,stroke:#333 diff --git a/diagrams/architecture.svg b/diagrams/architecture.svg new file mode 100644 index 0000000..c0d2c91 --- /dev/null +++ b/diagrams/architecture.svg @@ -0,0 +1 @@ +webhook-cert-libValidation RequestReferences webhookRead by1. Manages pending & serving CAs and keys2. Injects CAs3. Issues Leaf CertAPI ServerWebhook ServerValidatingWebhookConfigurationAuthority ControllerSecret \ No newline at end of file diff --git a/diagrams/rotation.mmd b/diagrams/rotation.mmd new file mode 100644 index 0000000..0d5cfba --- /dev/null +++ b/diagrams/rotation.mmd @@ -0,0 +1,19 @@ +sequenceDiagram + participant Time + participant Auth as Authority + participant Secret as CA Secret + participant VWC as WebhookConfig + + Time->>Auth: Trigger renewal (60-70% of lifetime) + Auth->>Secret: 1. Generate new CA (pending) + Note right of Secret: Secret contains:- Serving CA (old)- Pending CA (new)- Trust bundle (old + new) + + Auth->>VWC: 2. Inject bundle (serving + pending) + Note right of VWC: Clients now trustBOTH old and new CAs + + Auth->>Auth: 3. Wait PromotionDelay (e.g. 2s) + + Auth->>Secret: 4. Promote pending to serving + Note right of Secret: Secret contains:- Serving CA (new)- Trust bundle (old + new) + + Auth->>Auth: 5. Issue new leaf certificate (signed by new serving CA) diff --git a/diagrams/rotation.svg b/diagrams/rotation.svg new file mode 100644 index 0000000..7fd59c9 --- /dev/null +++ b/diagrams/rotation.svg @@ -0,0 +1 @@ +WebhookConfigCA SecretAuthorityTimeWebhookConfigCA SecretAuthorityTimeSecret contains:- Serving CA (old)- Pending CA (new)- Trust bundle (old + new)Clients now trustBOTH old and new CAsSecret contains:- Serving CA (new)- Trust bundle (old + new)Trigger renewal (60-70% of lifetime)1. Generate new CA (pending)2. Inject bundle (serving + pending)3. Wait PromotionDelay (e.g. 2s)4. Promote pending to serving5. Issue new leaf certificate (signed by new serving CA) \ No newline at end of file diff --git a/examples/webhook-controller/README.md b/examples/webhook-controller/README.md new file mode 100644 index 0000000..bc7f1ca --- /dev/null +++ b/examples/webhook-controller/README.md @@ -0,0 +1 @@ +# Example webhook controller using webhook-cert-lib diff --git a/examples/webhook-controller/deploy/01_namespace.yaml b/examples/webhook-controller/deploy/01_namespace.yaml new file mode 100644 index 0000000..cb00268 --- /dev/null +++ b/examples/webhook-controller/deploy/01_namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: smoke-namespace diff --git a/examples/webhook-controller/deploy/02_rbac.yaml b/examples/webhook-controller/deploy/02_rbac.yaml new file mode 100644 index 0000000..a35a790 --- /dev/null +++ b/examples/webhook-controller/deploy/02_rbac.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: example-webhook-sa + namespace: smoke-namespace +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: example-webhook-role + namespace: smoke-namespace +rules: + # Allow managing the CA Secret + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "patch"] + resourceNames: ["example-webhook-ca"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: example-webhook-role-binding + namespace: smoke-namespace +subjects: + - kind: ServiceAccount + name: example-webhook-sa + namespace: smoke-namespace +roleRef: + kind: Role + name: example-webhook-role + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: example-webhook-clusterrole +rules: + # Allow listing webhook configurations to check if patching is needed + - apiGroups: ["admissionregistration.k8s.io"] + resources: ["validatingwebhookconfigurations"] + verbs: ["get", "list", "watch"] + + # Allow patching the webhook configuration to inject the CA bundle + - apiGroups: ["admissionregistration.k8s.io"] + resources: ["validatingwebhookconfigurations"] + verbs: ["patch"] + resourceNames: ["example-webhook-validating"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: example-webhook-clusterrole-binding +subjects: + - kind: ServiceAccount + name: example-webhook-sa + namespace: smoke-namespace +roleRef: + kind: ClusterRole + name: example-webhook-clusterrole + apiGroup: rbac.authorization.k8s.io diff --git a/examples/webhook-controller/deploy/03_deployment.yaml b/examples/webhook-controller/deploy/03_deployment.yaml new file mode 100644 index 0000000..1357808 --- /dev/null +++ b/examples/webhook-controller/deploy/03_deployment.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: example-webhook-controller + namespace: smoke-namespace +spec: + replicas: 1 + selector: + matchLabels: + app: example-webhook + template: + metadata: + labels: + app: example-webhook + spec: + serviceAccountName: example-webhook-sa + containers: + - name: webhook-controller + image: controller:latest + args: + - --service-name=example-webhook + - --validating-webhook-configuration-name=example-webhook-validating + - --addr=:8443 + ports: + - name: webhook + containerPort: 8443 + livenessProbe: + httpGet: + port: webhook + path: /healthz + scheme: HTTPS + initialDelaySeconds: 3 + periodSeconds: 7 + readinessProbe: + httpGet: + port: webhook + path: /healthz + scheme: HTTPS + initialDelaySeconds: 3 + periodSeconds: 7 diff --git a/examples/webhook-controller/deploy/04_service.yaml b/examples/webhook-controller/deploy/04_service.yaml new file mode 100644 index 0000000..1d80774 --- /dev/null +++ b/examples/webhook-controller/deploy/04_service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: example-webhook + namespace: smoke-namespace +spec: + selector: + app: example-webhook + ports: + - name: https + port: 443 + targetPort: webhook + protocol: TCP diff --git a/examples/webhook-controller/deploy/05_validatingwebhook.yaml b/examples/webhook-controller/deploy/05_validatingwebhook.yaml new file mode 100644 index 0000000..cfa6aba --- /dev/null +++ b/examples/webhook-controller/deploy/05_validatingwebhook.yaml @@ -0,0 +1,29 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: example-webhook-validating + labels: + cert-manager.io/inject-dynamic-ca-from-secret-name: "example-webhook-ca" + cert-manager.io/inject-dynamic-ca-from-secret-namespace: "smoke-namespace" +webhooks: + - name: example-webhook.k8s.io + admissionReviewVersions: + - v1 + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + clientConfig: + service: + name: example-webhook + namespace: smoke-namespace + path: "/validate" + port: 443 + # CA bundle will be injected by the Authority controller; keep empty here. + caBundle: "" + rules: + - apiGroups: [""] + apiVersions: ["v1"] + resources: ["configmaps"] + operations: ["CREATE"] + scope: Namespaced + timeoutSeconds: 10 diff --git a/examples/webhook-controller/deploy/kustomization.yaml b/examples/webhook-controller/deploy/kustomization.yaml new file mode 100644 index 0000000..affd223 --- /dev/null +++ b/examples/webhook-controller/deploy/kustomization.yaml @@ -0,0 +1,6 @@ +resources: + - 01_namespace.yaml + - 02_rbac.yaml + - 03_deployment.yaml + - 04_service.yaml + - 05_validatingwebhook.yaml diff --git a/examples/webhook-controller/go.mod b/examples/webhook-controller/go.mod new file mode 100644 index 0000000..ce7c5a0 --- /dev/null +++ b/examples/webhook-controller/go.mod @@ -0,0 +1,53 @@ +module webhook-controller + +go 1.25.0 + +replace github.com/cert-manager/webhook-cert-lib => ../../ + +require ( + github.com/cert-manager/webhook-cert-lib v0.0.0 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/examples/webhook-controller/go.sum b/examples/webhook-controller/go.sum new file mode 100644 index 0000000..0dae0aa --- /dev/null +++ b/examples/webhook-controller/go.sum @@ -0,0 +1,120 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/examples/webhook-controller/main.go b/examples/webhook-controller/main.go new file mode 100644 index 0000000..10d911e --- /dev/null +++ b/examples/webhook-controller/main.go @@ -0,0 +1,175 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 main + +import ( + "context" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/cert-manager/webhook-cert-lib/pkg/authority" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func loadKubeConfig(kubeconfig string) (*rest.Config, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfig != "" { + loadingRules.ExplicitPath = kubeconfig + } + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + &clientcmd.ConfigOverrides{}, + ).ClientConfig() +} + +func main() { + kubeconfig := flag.String("kubeconfig", "", "Path to kubeconfig (optional)") + addr := flag.String("addr", ":8443", "Address to serve HTTPS on") + serviceName := flag.String("service-name", "example-webhook", "will be used in the DNS name for the webhook server certificate (..svc)") + vwcName := flag.String("validating-webhook-configuration-name", "example-webhook-validating", "Name of the ValidatingWebhookConfiguration to patch with the CA bundle") + flag.Parse() + + cfg, err := loadKubeConfig(*kubeconfig) + if err != nil { + log.Fatalf("failed to load kubeconfig: %v", err) + } + + inClusterSettings, err := authority.DetectInClusterSettings() + if err != nil { + log.Fatalf("failed to detect in-cluster settings: %v", err) + } + + opts := authority.AuthorityOptions{ + AuthorityCertificate: authority.AuthorityCertificateOptions{ + SecretNamespacedName: inClusterSettings.SecretNamespacedName(*serviceName + "-ca"), + }, + Targets: authority.TargetsOptions{ + Objects: []authority.TargetObject{ + { + GroupKind: (injectable.ValidatingWebhookCaBundleInject{}). + GroupVersionKind(). + GroupKind(), + NamespacedName: types.NamespacedName{Name: *vwcName}, + }, + }, + }, + ServerCertificate: authority.ServerCertificateOptions{ + DNSNames: []string{ + inClusterSettings.ServiceDNSName(*serviceName), + }, + }, + } + + a, err := authority.NewAuthorityForConfig(cfg, opts) + if err != nil { + log.Fatalf("failed to create authority: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + if err := a.Start(ctx); err != nil { + log.Fatalf("authority exited with error: %v", err) + } + }() + + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + a.ServingCertificate(tlsCfg) + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + select { + case <-ctx.Done(): + http.Error(w, "shutting down", http.StatusServiceUnavailable) + return + default: + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + }) + mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) { + // Parse AdmissionReview request + var review admissionv1.AdmissionReview + if err := json.NewDecoder(r.Body).Decode(&review); err != nil { + log.Printf("/validate: failed to decode admission review: %v", err) + http.Error(w, "failed to decode admission review", http.StatusBadRequest) + return + } + + // Default: allow the request. In a real webhook, inspect review.Request and decide. + resp := admissionv1.AdmissionResponse{ + UID: review.Request.UID, + Allowed: true, + } + + review.Response = &resp + // Clear the request to reduce response size + review.Request = nil + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(review); err != nil { + log.Printf("/validate: failed to encode admission review response: %v", err) + } + }) + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + TLSConfig: tlsCfg, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + + go func() { + log.Printf("starting webhook server on %s", srv.Addr) + // TLS certificates are provided dynamically via tls.Config.GetCertificate + if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + <-ctx.Done() + + log.Printf("waiting for 2 seconds before shutting down server...") + + // give some time for kubernetes to remove this pod from service endpoints, disable + // keep-alives to speed up connection close + srv.SetKeepAlivesEnabled(false) + time.Sleep(2 * time.Second) + + log.Printf("shutting down server...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) +} diff --git a/make/test-e2e.mk b/examples/webhook-controller/stress_test.sh old mode 100644 new mode 100755 similarity index 76% rename from make/test-e2e.mk rename to examples/webhook-controller/stress_test.sh index 4b93ea1..d7424e2 --- a/make/test-e2e.mk +++ b/examples/webhook-controller/stress_test.sh @@ -1,4 +1,6 @@ -# Copyright 2025 The cert-manager Authors. +#!/bin/bash + +# Copyright 2026 The cert-manager Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,9 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. - -.PHONY: test-e2e -## Run end-to-end tests -## @category Testing -test-e2e: - # TODO: Create e2e-tests and make them run from here. \ No newline at end of file +while true; do + kubectl delete configmap test || true + kubectl create configmap test || true +done diff --git a/go.mod b/go.mod index 8378032..38de2eb 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,21 @@ module github.com/cert-manager/webhook-cert-lib go 1.25.0 require ( - github.com/stretchr/testify v1.11.1 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 + k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 - sigs.k8s.io/controller-runtime v0.22.4 ) require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch/v5 v5.9.11 // 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-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -35,28 +28,19 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2b0b259..0dae0aa 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,15 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= -github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -30,17 +18,11 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -49,16 +31,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -73,18 +49,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -97,63 +63,30 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -167,8 +100,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= @@ -179,8 +110,6 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= -sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/certificate/holder.go b/internal/certificate/holder.go deleted file mode 100644 index 29bd50f..0000000 --- a/internal/certificate/holder.go +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 certificate - -import ( - "crypto/tls" - "errors" - "sync/atomic" -) - -var ( - ErrCertNotAvailable = errors.New("no tls.Certificate available") -) - -type Holder struct { - certP atomic.Pointer[tls.Certificate] -} - -func (h *Holder) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert := h.certP.Load() - if cert == nil { - return nil, ErrCertNotAvailable - } - return cert, nil -} - -func (h *Holder) SetCertificate(cert *tls.Certificate) { - h.certP.Store(cert) -} diff --git a/internal/metrics/patch_counts.go b/internal/metrics/patch_counts.go new file mode 100644 index 0000000..ba4000f --- /dev/null +++ b/internal/metrics/patch_counts.go @@ -0,0 +1,58 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 metrics + +import "sync/atomic" + +type InternalMetrics struct { + targetPatches atomic.Int64 + secretPatches atomic.Int64 + + reconciliations atomic.Int64 +} + +// InternalMetricsReport is a snapshot of processed work item counts. +type InternalMetricsReport struct { + TotalPatches int64 + SecretPatches int64 + TargetPatches int64 + + Reconciliations int64 +} + +// PatchCounts returns a snapshot of how many work items were processed. +func (a *InternalMetrics) PatchCounts() InternalMetricsReport { + counts := InternalMetricsReport{ + SecretPatches: a.secretPatches.Load(), + TargetPatches: a.targetPatches.Load(), + Reconciliations: a.reconciliations.Load(), + } + counts.TotalPatches = counts.SecretPatches + counts.TargetPatches + return counts +} + +func (a *InternalMetrics) IncrementReconciliations() { + a.reconciliations.Add(1) +} + +func (a *InternalMetrics) IncrementTargetPatches() { + a.targetPatches.Add(1) +} + +func (a *InternalMetrics) IncrementSecretPatches() { + a.secretPatches.Add(1) +} diff --git a/internal/pki/cert_pool.go b/internal/pki/cert_pool.go index 06f520f..a4fdb25 100644 --- a/internal/pki/cert_pool.go +++ b/internal/pki/cert_pool.go @@ -20,10 +20,12 @@ import ( "bytes" "crypto/sha256" "crypto/x509" + "encoding/base32" "encoding/pem" "fmt" "maps" "slices" + "strings" "time" ) @@ -115,3 +117,21 @@ func (cp *CertPool) Certificates() []*x509.Certificate { } return orderedCertificates } + +func (cp *CertPool) HashString() string { + return HashString(CertificatesHash(cp.Certificates()...)) +} + +func HashString(hash [sha256.Size]byte) string { + return strings.TrimRight(base32.HexEncoding.EncodeToString(hash[:]), "=") +} + +func CertificatesHash(certs ...*x509.Certificate) [sha256.Size]byte { + hash := sha256.New() + for _, cert := range certs { + _, _ = hash.Write(cert.Raw) + } + var certsHash [sha256.Size]byte + _ = hash.Sum(certsHash[:0]) + return certsHash +} diff --git a/klone.yaml b/klone.yaml index 249ad4e..bf21702 100644 --- a/klone.yaml +++ b/klone.yaml @@ -26,11 +26,21 @@ targets: repo_ref: main repo_hash: 1a31120f64869aaa4837d109929cb7d12b9377a1 repo_path: modules/help + - folder_name: kind + repo_url: https://github.com/cert-manager/makefile-modules.git + repo_ref: main + repo_hash: 172770e538335a25b03ac29aa572660535bd8ad3 + repo_path: modules/kind - folder_name: klone repo_url: https://github.com/cert-manager/makefile-modules.git repo_ref: main repo_hash: 1a31120f64869aaa4837d109929cb7d12b9377a1 repo_path: modules/klone + - folder_name: oci-build + repo_url: https://github.com/cert-manager/makefile-modules.git + repo_ref: main + repo_hash: 172770e538335a25b03ac29aa572660535bd8ad3 + repo_path: modules/oci-build - folder_name: repository-base repo_url: https://github.com/cert-manager/makefile-modules.git repo_ref: main diff --git a/make/00_mod.mk b/make/00_mod.mk index 12e864e..845c6ef 100644 --- a/make/00_mod.mk +++ b/make/00_mod.mk @@ -1,4 +1,4 @@ -# Copyright 2023 The cert-manager Authors. +# Copyright 2026 The cert-manager Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,6 +14,19 @@ repo_name := github.com/cert-manager/webhook-cert-lib -golangci_lint_config := .golangci.yaml +kind_cluster_name := webhook-cert-lib +kind_cluster_config := $(bin_dir)/scratch/kind_cluster.yaml + +build_names := manager + +go_manager_main_dir := . +go_manager_mod_dir := ./examples/webhook-controller +go_manager_ldflags := -X main.Version=$(VERSION) +oci_manager_base_image_flavor := static +oci_manager_image_tag := $(VERSION) +oci_manager_image_name_development := cert-manager.local/webhook-controller -GINKGO_VERSION ?= $(shell awk '/ginkgo\/v2/ {print $$2}' test/go.mod) +deploy_name := webhook-cert-lib +deploy_namespace := cert-manager + +golangci_lint_config := .golangci.yaml diff --git a/make/02_mod.mk b/make/02_mod.mk index 7572110..d2dad7a 100644 --- a/make/02_mod.mk +++ b/make/02_mod.mk @@ -1,4 +1,4 @@ -# Copyright 2023 The cert-manager Authors. +# Copyright 2026 The cert-manager Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,5 +12,38 @@ # See the License for the specific language governing permissions and # limitations under the License. +$(kind_cluster_config): make/config/kind/cluster.yaml | $(bin_dir)/scratch + cat $< | \ + sed -e 's|{{KIND_IMAGES}}|$(CURDIR)/$(images_tar_dir)|g' \ + > $@ + include make/test-unit.mk -include make/test-e2e.mk +include make/test-smoke.mk + +.PHONY: generate-diagrams +# Generate architecture and rotation diagrams from mermaid source files. +# Is not part of the main build process, run manually when diagrams need updating. +# Requires Docker to be installed. +generate-diagrams: + docker run --rm \ + -u `id -u`:`id -g` \ + -v $(CURDIR)/diagrams:/data \ + ghcr.io/mermaid-js/mermaid-cli/mermaid-cli \ + -t dark -b transparent \ + -i architecture.mmd \ + -o architecture.svg + + docker run --rm \ + -u `id -u`:`id -g` \ + -v $(CURDIR)/diagrams:/data \ + ghcr.io/mermaid-js/mermaid-cli/mermaid-cli \ + -t dark -b transparent \ + -i rotation.mmd \ + -o rotation.svg + +.PHONY: test-e2e +test-e2e: test-smoke +test-e2e: # only defining this to make CI happy + +.PHONY: test-integration +test-integration: # only defining this to make CI happy diff --git a/make/_shared/kind/00_kind_image_versions.mk b/make/_shared/kind/00_kind_image_versions.mk new file mode 100755 index 0000000..e2f0f3a --- /dev/null +++ b/make/_shared/kind/00_kind_image_versions.mk @@ -0,0 +1,32 @@ +# Copyright 2024 The cert-manager 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 +# +# 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. + +# This file is auto-generated by the learn_kind_images.sh script in the makefile-modules repo. +# Do not edit manually. + +kind_image_kindversion := v0.31.0 + +kind_image_kube_1.31_amd64 := docker.io/kindest/node:v1.31.14@sha256:e360318c07a2bb22ced43884c6884208a82d3da24828c9f1329222dd517adc06 +kind_image_kube_1.31_arm64 := docker.io/kindest/node:v1.31.14@sha256:cb9072fa3db2b4aaa4fa146193064cd1ddd3fe00666c12c5189e80d3735027b5 +kind_image_kube_1.32_amd64 := docker.io/kindest/node:v1.32.11@sha256:831a3aa45e399a20b3aef41d6d8572cc6ff07b1f76cac1242ce26be0ccf86402 +kind_image_kube_1.32_arm64 := docker.io/kindest/node:v1.32.11@sha256:6c3e552f3046d9e4b3602f642a54797ebe8bfcd18f3720cac129ae90bf802365 +kind_image_kube_1.33_amd64 := docker.io/kindest/node:v1.33.7@sha256:eb929cd8aca88dd03836180c65f3892ba8ccc79d80de1cc6666bcb9a35c1334e +kind_image_kube_1.33_arm64 := docker.io/kindest/node:v1.33.7@sha256:09d327961491ceb25a987350e34c5335246f1e28aa48189d815f1905dea66079 +kind_image_kube_1.34_amd64 := docker.io/kindest/node:v1.34.3@sha256:babda82416d417f720a4d6dbd35deec5263af2a6c164c81c08cde0044c2b9f78 +kind_image_kube_1.34_arm64 := docker.io/kindest/node:v1.34.3@sha256:55cc745d5da0ef8c7a24a9f25f2df7cc6af0fadf85cf24bd639d2c2f02bacfab +kind_image_kube_1.35_amd64 := docker.io/kindest/node:v1.35.0@sha256:b7f5e1f621afb1156eb0f27f26c804e5265c07d8e9c55516d25d66400043629b +kind_image_kube_1.35_arm64 := docker.io/kindest/node:v1.35.0@sha256:0aa5e1a411b2c3197184286d7699424a123cd4d18c04c24317173dc5256c6110 + +kind_image_latest_amd64 := $(kind_image_kube_1.35_amd64) +kind_image_latest_arm64 := $(kind_image_kube_1.35_arm64) diff --git a/make/_shared/kind/00_mod.mk b/make/_shared/kind/00_mod.mk new file mode 100644 index 0000000..f8b1de0 --- /dev/null +++ b/make/_shared/kind/00_mod.mk @@ -0,0 +1,33 @@ +# Copyright 2023 The cert-manager 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 +# +# 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. + +include $(dir $(lastword $(MAKEFILE_LIST)))/00_kind_image_versions.mk + +images_amd64 ?= +images_arm64 ?= + +# K8S_VERSION can be used to specify a specific +# kubernetes version to use with Kind. +K8S_VERSION ?= +ifeq ($(K8S_VERSION),) +images_amd64 += $(kind_image_latest_amd64) +images_arm64 += $(kind_image_latest_arm64) +else +fatal_if_undefined = $(if $(findstring undefined,$(origin $1)),$(error $1 is not set)) +$(call fatal_if_undefined,kind_image_kube_$(K8S_VERSION)_amd64) +$(call fatal_if_undefined,kind_image_kube_$(K8S_VERSION)_arm64) + +images_amd64 += $(kind_image_kube_$(K8S_VERSION)_amd64) +images_arm64 += $(kind_image_kube_$(K8S_VERSION)_arm64) +endif diff --git a/make/_shared/kind/01_mod.mk b/make/_shared/kind/01_mod.mk new file mode 100644 index 0000000..a7eb1b2 --- /dev/null +++ b/make/_shared/kind/01_mod.mk @@ -0,0 +1,16 @@ +# Copyright 2023 The cert-manager 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 +# +# 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. + +include $(dir $(lastword $(MAKEFILE_LIST)))/kind.mk +include $(dir $(lastword $(MAKEFILE_LIST)))/kind-image-preload.mk diff --git a/make/_shared/kind/kind-image-preload.mk b/make/_shared/kind/kind-image-preload.mk new file mode 100644 index 0000000..a876bbd --- /dev/null +++ b/make/_shared/kind/kind-image-preload.mk @@ -0,0 +1,69 @@ +# Copyright 2023 The cert-manager 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 +# +# 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. + +ifndef bin_dir +$(error bin_dir is not set) +endif + +ifndef images_amd64 +$(error images_amd64 is not set) +endif + +ifndef images_arm64 +$(error images_arm64 is not set) +endif + +########################################## + +images := $(images_$(HOST_ARCH)) + +images_tar_dir := $(bin_dir)/downloaded/containers/$(HOST_ARCH) +images_tars := $(foreach image,$(images),$(images_tar_dir)/$(subst :,+,$(image)).tar) + +# Download the images as tarballs. After downloading the image using +# its digest, we use image-tool to modify the .[0].RepoTags[0] value in +# the manifest.json file to have the correct tag (instead of "i-was-a-digest" +# which is set when the image is pulled using its digest). This tag is used +# to reference the image after it has been imported using docker or kind. Otherwise, +# the image would be imported with the tag "i-was-a-digest" which is not very useful. +# We would have to use digests to reference the image everywhere which might +# not always be possible and does not match the default behavior of eg. our helm charts. +# NOTE: the tag is fully determined based on the input, we fully allow the remote +# tag to point to a different digest. This prevents CI from breaking due to upstream +# changes. However, it also means that we can incorrectly combine digests with tags, +# hence caution is advised. +$(images_tars): $(images_tar_dir)/%.tar: | $(NEEDS_IMAGE-TOOL) $(NEEDS_CRANE) $(NEEDS_GOJQ) + @$(eval full_image=$(subst +,:,$*)) + @$(eval bare_image=$(word 1,$(subst :, ,$(full_image)))) + @$(eval digest=$(word 2,$(subst @, ,$(full_image)))) + @$(eval tag=$(word 2,$(subst :, ,$(word 1,$(subst @, ,$(full_image)))))) + @mkdir -p $(dir $@) + $(CRANE) pull "$(bare_image)@$(digest)" $@ --platform=linux/$(HOST_ARCH) + $(IMAGE-TOOL) tag-docker-tar $@ "$(bare_image):$(tag)" + +# $1 = image +# $2 = image:tag@sha256:digest +define image_variables +$1.TAR := $(images_tar_dir)/$(subst :,+,$2).tar +$1.REPO := $1 +$1.TAG := $(word 2,$(subst :, ,$(word 1,$(subst @, ,$2)))) +$1.FULL := $(word 1,$(subst @, ,$2)) +endef + +$(foreach image,$(images),$(eval $(call image_variables,$(word 1,$(subst :, ,$(image))),$(image)))) + +.PHONY: images-preload +## Preload images. +## @category [shared] Kind cluster +images-preload: | $(images_tars) diff --git a/make/_shared/kind/kind.mk b/make/_shared/kind/kind.mk new file mode 100644 index 0000000..ebf8762 --- /dev/null +++ b/make/_shared/kind/kind.mk @@ -0,0 +1,86 @@ +# Copyright 2023 The cert-manager 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 +# +# 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. + +ifndef bin_dir +$(error bin_dir is not set) +endif + +ifndef kind_cluster_name +$(error kind_cluster_name is not set) +endif + +ifndef kind_cluster_config +$(error kind_cluster_config is not set) +endif + +########################################## + +kind_kubeconfig := $(bin_dir)/scratch/kube.config +absolute_kubeconfig := $(CURDIR)/$(kind_kubeconfig) + +$(bin_dir)/scratch/cluster-check: FORCE | $(NEEDS_KIND) $(bin_dir)/scratch + @if ! $(KIND) get clusters -q | grep -q "^$(kind_cluster_name)\$$"; then \ + echo "❌ cluster $(kind_cluster_name) not found. Starting ..."; \ + echo "trigger" > $@; \ + else \ + echo "✅ existing cluster $(kind_cluster_name) found"; \ + fi + $(eval export KUBECONFIG=$(absolute_kubeconfig)) + +kind_post_create_hook ?= +$(kind_kubeconfig): $(kind_cluster_config) $(bin_dir)/scratch/cluster-check | images-preload $(bin_dir)/scratch $(NEEDS_KIND) $(NEEDS_KUBECTL) $(NEEDS_CTR) + @[ -f "$(bin_dir)/scratch/cluster-check" ] && ( \ + $(KIND) delete cluster --name $(kind_cluster_name); \ + $(CTR) load -i $(docker.io/kindest/node.TAR); \ + $(KIND) create cluster \ + --image $(docker.io/kindest/node.FULL) \ + --name $(kind_cluster_name) \ + --config "$<"; \ + $(CTR) exec $(kind_cluster_name)-control-plane find /mounted_images/ -name "*.tar" -exec echo {} \; -exec ctr --namespace=k8s.io images import --all-platforms --no-unpack --digests {} \; ; \ + $(MAKE) --no-print-directory noop $(kind_post_create_hook); \ + $(KUBECTL) config use-context kind-$(kind_cluster_name); \ + ) || true + + $(KIND) get kubeconfig --name $(kind_cluster_name) > $@ + +.PHONY: kind-cluster +kind-cluster: $(kind_kubeconfig) + +.PHONY: kind-cluster-load +## Create Kind cluster and wait for nodes to be ready +## Load the kubeconfig into the default location so that +## it can be easily queried by kubectl. This target is +## meant to be used directly, NOT as a dependency. +## Use `kind-cluster` as a dependency instead. +## @category [shared] Kind cluster +kind-cluster-load: kind-cluster | $(NEEDS_KUBECTL) + mkdir -p ~/.kube + KUBECONFIG=~/.kube/config:$(kind_kubeconfig) $(KUBECTL) config view --flatten > ~/.kube/config + $(KUBECTL) config use-context kind-$(kind_cluster_name) + +.PHONY: kind-cluster-clean +## Delete the Kind cluster +## @category [shared] Kind cluster +kind-cluster-clean: $(NEEDS_KIND) + $(KIND) delete cluster --name $(kind_cluster_name) + rm -rf $(kind_kubeconfig) + $(MAKE) --no-print-directory noop $(kind_post_create_hook) + +.PHONY: kind-logs +## Get the Kind cluster +## @category [shared] Kind cluster +kind-logs: | kind-cluster $(NEEDS_KIND) $(ARTIFACTS) + rm -rf $(ARTIFACTS)/e2e-logs + mkdir -p $(ARTIFACTS)/e2e-logs + $(KIND) export logs $(ARTIFACTS)/e2e-logs --name=$(kind_cluster_name) diff --git a/make/_shared/oci-build/00_mod.mk b/make/_shared/oci-build/00_mod.mk new file mode 100644 index 0000000..a9c850f --- /dev/null +++ b/make/_shared/oci-build/00_mod.mk @@ -0,0 +1,135 @@ +# Copyright 2023 The cert-manager 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 +# +# 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. + +# Use distroless as minimal base image to package the manager binary +# To get latest SHA run "crane digest quay.io/jetstack/base-static:latest" +base_image_static := quay.io/jetstack/base-static@sha256:1da2e7de36c9d7a1931d765e8054a3c9fe7ed5126bacf728bb7429e923386146 + +# Use custom apko-built image as minimal base image to package the manager binary +# To get latest SHA run "crane digest quay.io/jetstack/base-static-csi:latest" +base_image_csi-static := quay.io/jetstack/base-static-csi@sha256:05ec9b9d5798fdd80680a54eab9eb69134d3cdaae948935bb1af07dadeb6e9be + +# Utility functions +fatal_if_undefined = $(if $(findstring undefined,$(origin $1)),$(error $1 is not set)) +fatal_if_deprecated_defined = $(if $(findstring undefined,$(origin $1)),,$(error $1 is deprecated, use $2 instead)) + +# Validate globals that are required +$(call fatal_if_undefined,build_names) + +# Set default config values +CGO_ENABLED ?= 0 +GOEXPERIMENT ?= # empty by default +oci_platforms ?= linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le + +# Default variables per build_names entry +# +# $1 - build_name +define default_per_build_variables +go_$1_cgo_enabled ?= $(CGO_ENABLED) +go_$1_goexperiment ?= $(GOEXPERIMENT) +go_$1_flags ?= -tags= +oci_$1_platforms ?= $(oci_platforms) +oci_$1_additional_layers ?= +oci_$1_linux_capabilities ?= +oci_$1_build_args ?= +endef + +$(foreach build_name,$(build_names),$(eval $(call default_per_build_variables,$(build_name)))) + +# Validate variables per build_names entry +# +# $1 - build_name +define check_per_build_variables +# Validate deprecated variables +$(call fatal_if_deprecated_defined,cgo_enabled_$1,go_$1_cgo_enabled) +$(call fatal_if_deprecated_defined,goexperiment_$1,go_$1_goexperiment) +$(call fatal_if_deprecated_defined,oci_additional_layers_$1,oci_$1_additional_layers) + +# Validate required config exists +$(call fatal_if_undefined,go_$1_ldflags) +$(call fatal_if_undefined,go_$1_main_dir) +$(call fatal_if_undefined,go_$1_mod_dir) +$(call fatal_if_undefined,oci_$1_base_image_flavor) +$(call fatal_if_undefined,oci_$1_image_name_development) + +# Validate we have valid base image config +ifeq ($(oci_$1_base_image_flavor),static) + oci_$1_base_image := $(base_image_static) +else ifeq ($(oci_$1_base_image_flavor),csi-static) + oci_$1_base_image := $(base_image_csi-static) +else ifeq ($(oci_$1_base_image_flavor),custom) + $$(call fatal_if_undefined,oci_$1_base_image) +else + $$(error oci_$1_base_image_flavor has unknown value "$(oci_$1_base_image_flavor)") +endif + +# Validate the config required to build the golang based images +ifneq ($(go_$1_main_dir:.%=.),.) +$$(error go_$1_main_dir "$(go_$1_main_dir)" should be a directory path that DOES start with ".") +endif +ifeq ($(go_$1_main_dir:%/=/),/) +$$(error go_$1_main_dir "$(go_$1_main_dir)" should be a directory path that DOES NOT end with "/") +endif +ifeq ($(go_$1_main_dir:%.go=.go),.go) +$$(error go_$1_main_dir "$(go_$1_main_dir)" should be a directory path that DOES NOT end with ".go") +endif +ifneq ($(go_$1_mod_dir:.%=.),.) +$$(error go_$1_mod_dir "$(go_$1_mod_dir)" should be a directory path that DOES start with ".") +endif +ifeq ($(go_$1_mod_dir:%/=/),/) +$$(error go_$1_mod_dir "$(go_$1_mod_dir)" should be a directory path that DOES NOT end with "/") +endif +ifeq ($(go_$1_mod_dir:%.go=.go),.go) +$$(error go_$1_mod_dir "$(go_$1_mod_dir)" should be a directory path that DOES NOT end with ".go") +endif +ifeq ($(wildcard $(go_$1_mod_dir)/go.mod),) +$$(error go_$1_mod_dir "$(go_$1_mod_dir)" does not contain a go.mod file) +endif +ifeq ($(wildcard $(go_$1_mod_dir)/$(go_$1_main_dir)/main.go),) +$$(error go_$1_main_dir "$(go_$1_mod_dir)/$(go_$1_main_dir)" does not contain a main.go file) +endif + +# Validate the config required to build OCI images +ifneq ($(words $(oci_$1_image_name_development)),1) +$$(error oci_$1_image_name_development "$(oci_$1_image_name_development)" should be a single image name) +endif + +# Validate that the build name does not end in __local +ifeq ($(1:%__local=__local),__local) +$$(error build_name "$1" SHOULD NOT end in __local) +endif +endef + +$(foreach build_name,$(build_names),$(eval $(call check_per_build_variables,$(build_name)))) + +# Create variables holding targets +# +# We create the following targets for each $(build_names) +# - oci-build-$(build_name) = build the oci directory (multi-arch) +# - oci-build-$(build_name)__local = build the oci directory (local arch: linux/$(HOST_ARCH)) +# - oci-load-$(build_name) = load the image into docker using the oci_$(build_name)_image_name_development variable +# - docker-tarball-$(build_name) = build a "docker load" compatible tarball of the image +oci_build_targets := $(build_names:%=oci-build-%) +oci_build_targets += $(build_names:%=oci-build-%__local) +oci_load_targets := $(build_names:%=oci-load-%) +docker_tarball_targets := $(build_names:%=docker-tarball-%) + +# Derive config based on user config +# +# - oci_layout_path_$(build_name) = path that the OCI image will be saved in OCI layout directory format +# - oci_digest_path_$(build_name) = path to the file that will contain the digests +# - docker_tarball_path_$(build_name) = path that the docker tarball that the docker-tarball-$(build_name) will produce +$(foreach build_name,$(build_names),$(eval oci_layout_path_$(build_name) := $(bin_dir)/scratch/image/oci-layout-$(build_name))) +$(foreach build_name,$(build_names),$(eval oci_digest_path_$(build_name) := $(CURDIR)/$(oci_layout_path_$(build_name)).digests)) +$(foreach build_name,$(build_names),$(eval docker_tarball_path_$(build_name) := $(CURDIR)/$(oci_layout_path_$(build_name)).docker.tar)) diff --git a/make/_shared/oci-build/01_mod.mk b/make/_shared/oci-build/01_mod.mk new file mode 100644 index 0000000..026e46b --- /dev/null +++ b/make/_shared/oci-build/01_mod.mk @@ -0,0 +1,83 @@ +# Copyright 2023 The cert-manager 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 +# +# 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. + +$(bin_dir)/scratch/image: + @mkdir -p $@ + +.PHONY: $(oci_build_targets) +## Build the OCI image. +## - oci-build-$(build_name) = build the oci directory (multi-arch) +## - oci-build-$(build_name)__local = build the oci directory (local arch: linux/$(HOST_ARCH)) +## @category [shared] Build +$(oci_build_targets): oci-build-%: | $(NEEDS_KO) $(NEEDS_GO) $(NEEDS_YQ) $(NEEDS_IMAGE-TOOL) $(bin_dir)/scratch/image + $(eval a := $(patsubst %__local,%,$*)) + $(eval is_local := $(if $(findstring $a__local,$*),true)) + $(eval layout_path := $(if $(is_local),$(oci_layout_path_$a).local,$(oci_layout_path_$a))) + $(eval digest_path := $(if $(is_local),$(oci_digest_path_$a).local,$(oci_digest_path_$a))) + + rm -rf $(CURDIR)/$(layout_path) + + echo '{}' | \ + $(YQ) '.defaultBaseImage = "$(oci_$a_base_image)"' | \ + $(YQ) '.builds[0].id = "$a"' | \ + $(YQ) '.builds[0].dir = "$(go_$a_mod_dir)"' | \ + $(YQ) '.builds[0].main = "$(go_$a_main_dir)"' | \ + $(YQ) '.builds[0].env[0] = "CGO_ENABLED=$(go_$a_cgo_enabled)"' | \ + $(YQ) '.builds[0].env[1] = "GOEXPERIMENT=$(go_$a_goexperiment)"' | \ + $(YQ) '.builds[0].ldflags[0] = "-s"' | \ + $(YQ) '.builds[0].ldflags[1] = "-w"' | \ + $(YQ) '.builds[0].ldflags[2] = "{{.Env.LDFLAGS}}"' | \ + $(YQ) '.builds[0].flags[0] = "$(go_$a_flags)"' | \ + $(YQ) '.builds[0].linux_capabilities = "$(oci_$a_linux_capabilities)"' \ + > $(CURDIR)/$(layout_path).ko_config.yaml + + GOWORK=off \ + KO_DOCKER_REPO=$(oci_$a_image_name_development) \ + KOCACHE=$(CURDIR)/$(bin_dir)/scratch/image/ko_cache \ + KO_CONFIG_PATH=$(CURDIR)/$(layout_path).ko_config.yaml \ + SOURCE_DATE_EPOCH=$(GITEPOCH) \ + KO_GO_PATH=$(GO) \ + LDFLAGS="$(go_$a_ldflags)" \ + $(KO) build $(go_$a_mod_dir)/$(go_$a_main_dir) \ + --platform=$(if $(is_local),linux/$(HOST_ARCH),$(oci_$a_platforms)) \ + $(oci_$a_build_args) \ + --oci-layout-path=$(layout_path) \ + --sbom-dir=$(CURDIR)/$(layout_path).sbom \ + --sbom=spdx \ + --push=false \ + --bare + + $(IMAGE-TOOL) append-layers \ + $(CURDIR)/$(layout_path) \ + $(oci_$a_additional_layers) + + $(IMAGE-TOOL) list-digests \ + $(CURDIR)/$(layout_path) \ + > $(digest_path) + +# Only include the oci-load target if kind is provided by the kind makefile-module +ifdef kind_cluster_name +.PHONY: $(oci_load_targets) +## Build OCI image for the local architecture and load +## it into the $(kind_cluster_name) kind cluster. +## @category [shared] Build +$(oci_load_targets): oci-load-%: docker-tarball-% | kind-cluster $(NEEDS_KIND) + $(KIND) load image-archive --name $(kind_cluster_name) $(docker_tarball_path_$*) +endif + +## Build Docker tarball image for the local architecture +## @category [shared] Build +.PHONY: $(docker_tarball_targets) +$(docker_tarball_targets): docker-tarball-%: oci-build-%__local | $(NEEDS_GO) $(NEEDS_IMAGE-TOOL) + $(IMAGE-TOOL) convert-to-docker-tar $(CURDIR)/$(oci_layout_path_$*).local $(docker_tarball_path_$*) $(oci_$*_image_name_development):$(oci_$*_image_tag) diff --git a/make/config/kind/cluster.yaml b/make/config/kind/cluster.yaml new file mode 100644 index 0000000..507f1f9 --- /dev/null +++ b/make/config/kind/cluster.yaml @@ -0,0 +1,19 @@ +apiVersion: kind.x-k8s.io/v1alpha4 +kind: Cluster +kubeadmConfigPatches: + - | + kind: ClusterConfiguration + metadata: + name: config + etcd: + local: + extraArgs: + unsafe-no-fsync: "true" + networking: + serviceSubnet: 10.0.0.0/16 +nodes: +- role: control-plane + + extraMounts: + - hostPath: {{KIND_IMAGES}} + containerPath: /mounted_images diff --git a/make/test-smoke.mk b/make/test-smoke.mk new file mode 100644 index 0000000..eb928e4 --- /dev/null +++ b/make/test-smoke.mk @@ -0,0 +1,35 @@ +# Copyright 2026 The cert-manager 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 +# +# 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. + +$(bin_dir)/scratch/yaml/kustomization.yaml: FORCE | $(NEEDS_KUSTOMIZE) + rm -rf $(bin_dir)/scratch/yaml + mkdir -p $(bin_dir)/scratch/yaml + + cd $(bin_dir)/scratch/yaml; \ + $(KUSTOMIZE) create \ + --resources ../../../examples/webhook-controller/deploy/ + + cd $(bin_dir)/scratch/yaml; \ + $(KUSTOMIZE) edit set image "controller:latest=$(oci_manager_image_name_development):$(oci_manager_image_tag)" + + +.PHONY: smoke-setup-example +smoke-setup-example: | oci-load-manager $(bin_dir)/scratch/yaml/kustomization.yaml kind-cluster $(NEEDS_KUBECTL) + $(KUBECTL) apply -k $(bin_dir)/scratch/yaml/ + +.PHONY: test-smoke +## Run smoke test +## @category Testing +test-smoke: | smoke-setup-example $(NEEDS_KUBECTL) $(ARTIFACTS) + $(KUBECTL) get pods -n smoke-namespace diff --git a/make/test-unit.mk b/make/test-unit.mk index 59d84e0..8423fd4 100644 --- a/make/test-unit.mk +++ b/make/test-unit.mk @@ -1,4 +1,4 @@ -# Copyright 2023 The cert-manager Authors. +# Copyright 2026 The cert-manager Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,16 +20,15 @@ test-unit: | $(NEEDS_GOTESTSUM) $(ARTIFACTS) --junitfile=$(ARTIFACTS)/junit-go-e2e.xml \ -- \ -coverprofile=$(ARTIFACTS)/filtered.cov \ - ./... \ - -- \ - -ldflags $(go_manager_ldflags) + ./... -.PHONY: test-integration -## Integration tests +.PHONY: test-fake +## Fake tests (using testing/synctest) ## @category Testing -test-integration: | $(NEEDS_GINKGO) $(NEEDS_ETCD) $(NEEDS_KUBE-APISERVER) $(NEEDS_KUBECTL) $(ARTIFACTS) - KUBEBUILDER_ASSETS=$(CURDIR)/$(bin_dir)/tools \ - $(GINKGO) \ - --output-dir=$(ARTIFACTS) \ - --junit-report=junit-go-e2e.xml \ - ./test/ +test-fake: | $(ARTIFACTS) + cd ./test/ && \ + $(GOTESTSUM) \ + --junitfile=$(CURDIR)/$(ARTIFACTS)/junit-go-e2e.xml \ + -- \ + -coverprofile=$(CURDIR)/$(ARTIFACTS)/filtered.cov \ + ./... diff --git a/pkg/authority/api/api.go b/pkg/authority/api/api.go index 99e69c6..4b8fdd2 100644 --- a/pkg/authority/api/api.go +++ b/pkg/authority/api/api.go @@ -16,6 +16,8 @@ limitations under the License. package api +import corev1 "k8s.io/api/core/v1" + const ( // DynamicAuthoritySecretLabel will - if set to "true" - make the dynamic // authority CA controller inject and maintain a dynamic CA. @@ -35,16 +37,30 @@ const ( // Must be used in conjunction with WantInjectFromSecretNamespaceLabel. WantInjectFromSecretNameLabel = "cert-manager.io/inject-dynamic-ca-from-secret-name" //#nosec G101 - This is not credentials - // TLSCABundleKey is used as a data key in Secret resources to store a CA - // certificate bundle. - TLSCABundleKey = "ca-bundle.crt" - - // RenewCertificateSecretAnnotation is an annotation that can be set to - // an arbitrary value on a certificate secret to trigger a renewal of the - // certificate managed in the secret. - RenewCertificateSecretAnnotation = "renew.cert-manager.io/requestedAt" //#nosec G101 - This is not credentials - // RenewHandledCertificateSecretAnnotation is an annotation that will be set on a - // certificate secret whenever a new certificate is renewed using the - // RenewCertificateSecretAnnotation annotation. - RenewHandledCertificateSecretAnnotation = "renew.cert-manager.io/lastRequestedAt" //#nosec G101 - This is not credentials + // TLSPendingCertKey stores a pending (new) CA cert PEM while it is being + // propagated to targets. It will be promoted to `tls_serving.crt` after + // `Options.PropagationDelay` has elapsed since the rotation timestamp. + TLSPendingCertKey = corev1.TLSCertKey + + // TLSPendingPrivateKeyKey stores the private key corresponding to the + // pending cert in `TLSPendingCertKey`. + TLSPendingPrivateKeyKey = corev1.TLSPrivateKeyKey + + TLSServingCertKey = "tls_serving.crt" + TLSServingPrivateKeyKey = "tls_serving.key" + + TLSAllTrustedCertsKey = "all_trusted_certs.crt" + + IssuingAuthorityIDAnnotation = "cert-manager.io/issuing-authority-id" + + // InjectedAtTimestampAnnotation marks the time the pending cert has been added + // to all trust stores. It is used to decide when it can be promoted to serving. + // It corresponds to the version specified in InjectedLastVersionAnnotation. + InjectedAtTimestampAnnotation = "cert-manager.io/injected-at-timestamp" + + // InjectedLastVersionAnnotation marks the last version of the secret that + // has been injected into all targets. It is updated at the same time as + // InjectedAtTimestampAnnotation. It is the hash of the bundle that was + // injected. + InjectedLastVersionAnnotation = "cert-manager.io/injected-last-version" ) diff --git a/pkg/authority/authority.go b/pkg/authority/authority.go index 31c9e31..4a6531a 100644 --- a/pkg/authority/authority.go +++ b/pkg/authority/authority.go @@ -17,123 +17,805 @@ limitations under the License. package authority import ( + "bytes" + "context" + "crypto" "crypto/tls" + "crypto/x509" "errors" + "fmt" + "maps" + "slices" + "strings" + "sync" + "sync/atomic" "time" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/wait" + applyconfigurationscorev1 "k8s.io/client-go/applyconfigurations/core/v1" + coreinformers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog/v2" "github.com/cert-manager/webhook-cert-lib/internal/certificate" + internalmetrics "github.com/cert-manager/webhook-cert-lib/internal/metrics" + "github.com/cert-manager/webhook-cert-lib/internal/pki" "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/informerfactory" "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/internal/queuefix" ) +// Authority wires together CA renewal, injection, and serving without controller-runtime. type Authority struct { - Options Options + Options AuthorityOptions - certificateHolder *certificate.Holder + // id used to identify this Authority instance in logs and field managers + // is used to reduce conflicts when multiple Authorities manage the same Secret + // only the authority with the ID matching the one that issued the pending CA + // is allowed to inject it into targets and promote it to serving CA during the + // first 5 seconds after issuing the pending CA. + authorityID string + + // Merged Manager fields (manager owns and serves the current leaf certificate) + leafDNSNames []string + leafDuration time.Duration + leaf atomic.Pointer[tlsCertWithExpiry] + + // clients + clientset kubernetes.Interface + + // informers + factory informerfactory.Factory + secretLister corelisters.SecretLister + injectableListPatchers map[schema.GroupVersionKind]injectable.ListPatcher + + // workqueue for handling reinjection tasks + newQueue func() workqueue.TypedRateLimitingInterface[queueKey] + queue workqueue.TypedRateLimitingInterface[queueKey] + + // reconcile metrics + metrics internalmetrics.InternalMetrics +} + +type tlsCertWithExpiry struct { + cert tls.Certificate + renewalPeriod certificate.TriggerWindow + validUntil time.Time + ca []byte } -func (o *Authority) ServingCertificate() func(config *tls.Config) { - if o.certificateHolder == nil { - o.certificateHolder = &certificate.Holder{} +type queueKey struct { + reconcileType string + + // for target reconciliation (don't use this field for other reconcile types) + gvk schema.GroupVersionKind + key types.NamespacedName +} + +var reconcilePendingCAKey = queueKey{reconcileType: "reconcile-pending-ca"} +var reconcileServingCAPromotionKey = queueKey{reconcileType: "reconcile-serving-ca-promotion"} +var reconcileLeafCertificateKey = queueKey{reconcileType: "reconcile-leaf-certificate"} +var reconcileAllTargetsKey = queueKey{reconcileType: "reconcile-all-targets"} +var reconcileSecretTargetAnnotationKey = queueKey{reconcileType: "reconcile-secret-target-annotation"} + +func reconcileSingleTargetKey(gvk schema.GroupVersionKind, key types.NamespacedName) queueKey { + return queueKey{reconcileType: "reconcile-single-target", gvk: gvk, key: key} +} + +var ErrCertNotAvailable = errors.New("leaf certificate not available") + +// GetCertificate is a tls.Config.GetCertificate hook that returns the current leaf certificate. +func (a *Authority) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert := a.leaf.Load() + if cert == nil { + return nil, ErrCertNotAvailable } - return func(config *tls.Config) { - config.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { - return o.certificateHolder.GetCertificate(info) - } + return &cert.cert, nil +} + +// NewAuthorityForConfig creates and prepares the Authority; call Start to run it. +func NewAuthorityForConfig(cfg *rest.Config, options AuthorityOptions) (*Authority, error) { + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, err } + return NewAuthorityForClient(cs, options) } -func (o *Authority) SetupWithManager(mgr ctrl.Manager) error { - if o.certificateHolder == nil { - return errors.New("ServingCertificate not invoked") +// NewAuthorityForClient creates an Authority using a provided kubernetes clientset; call Start to run it. +func NewAuthorityForClient(cs kubernetes.Interface, options AuthorityOptions) (*Authority, error) { + options.ApplyDefaults() + if err := options.Validate(); err != nil { + return nil, err } - if o.Options.CAOptions.Duration == 0 { - o.Options.CAOptions.Duration = 7 * 24 * time.Hour + factory := informerfactory.NewInformerFactory() + a := &Authority{ + Options: options, + authorityID: rand.String(10), + clientset: cs, + factory: factory, + newQueue: func() workqueue.TypedRateLimitingInterface[queueKey] { + return queuefix.FixQueue( + workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[queueKey](), + workqueue.TypedRateLimitingQueueConfig[queueKey]{ + Name: "authority", + }, + ), + ) + }, + injectableListPatchers: make(map[schema.GroupVersionKind]injectable.ListPatcher, len(options.Targets.SupportedKinds)), + leafDNSNames: options.ServerCertificate.DNSNames, + leafDuration: options.ServerCertificate.Duration, } - if o.Options.LeafOptions.Duration == 0 { - o.Options.LeafOptions.Duration = 1 * 24 * time.Hour + + // Secret informer for CA Secret + secretInf := factory.InformerFor(&corev1.Secret{}, func() cache.SharedIndexInformer { + return coreinformers.NewFilteredSecretInformer( + cs, + options.AuthorityCertificate.SecretNamespacedName.Namespace, + 0, + cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, + func(lo *metav1.ListOptions) { + // Namespace is already provided to the informer constructor; only match by name here. + lo.FieldSelector = fields.Set{ + "metadata.name": options.AuthorityCertificate.SecretNamespacedName.Name, + }.String() + }, + ) + }) + if _, err := secretInf.AddEventHandlerWithOptions(cache.ResourceEventHandlerDetailedFuncs{ + AddFunc: func(obj any, isInInitialList bool) { + a.onCASecretChange(extractNamespacedName(obj)) + }, + UpdateFunc: func(_, newObj any) { + a.onCASecretChange(extractNamespacedName(newObj)) + }, + DeleteFunc: func(obj any) { + a.onCASecretChange(extractNamespacedName(obj)) + }, + }, cache.HandlerOptions{}); err != nil { + return nil, err } - if len(o.Options.Injectables) == 0 { - o.Options.Injectables = []injectable.Injectable{ - &injectable.ValidatingWebhookCaBundleInject{}, + a.secretLister = corelisters.NewSecretLister(secretInf.GetIndexer()) + + // VWC informer for injectable updates + for _, inj := range options.Targets.SupportedKinds { + gvk := inj.GroupVersionKind() + + informer, listPatcher := inj.NewInformerAndListPatcher( + cs, 0, + cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, + func(lo *metav1.ListOptions) { + lo.LabelSelector = labels.Set{ + api.WantInjectFromSecretNameLabel: a.Options.AuthorityCertificate.SecretNamespacedName.Name, + api.WantInjectFromSecretNamespaceLabel: a.Options.AuthorityCertificate.SecretNamespacedName.Namespace, + }.String() + }, + ) + + // add informer to factory + _ = factory.InformerFor(inj.ExampleObject(), func() cache.SharedIndexInformer { + return informer + }) + if _, err := informer.AddEventHandlerWithOptions(cache.ResourceEventHandlerDetailedFuncs{ + AddFunc: func(obj any, isInInitialList bool) { + a.onInjectableChange(gvk, extractNamespacedName(obj)) + }, + UpdateFunc: func(oldObj, newObj any) { + a.onInjectableChange(gvk, extractNamespacedName(newObj)) + }, + }, cache.HandlerOptions{}); err != nil { + return nil, err } + + a.injectableListPatchers[gvk] = listPatcher } - cacheByObject := map[client.Object]cache.ByObject{ - &corev1.Secret{}: { - Namespaces: map[string]cache.Config{ - o.Options.CAOptions.Namespace: {}, - }, - Field: fields.SelectorFromSet(fields.Set{ - "metadata.name": o.Options.CAOptions.Name, - "metadata.namespace": o.Options.CAOptions.Namespace, - }), - Label: labels.SelectorFromSet(labels.Set{ - api.DynamicAuthoritySecretLabel: "true", - }), - }, + return a, nil +} + +func extractNamespacedName(rawObj any) types.NamespacedName { + objName, err := cache.DeletionHandlingObjectToName(rawObj) + if err != nil { + panic(fmt.Sprintf("PROGRAMMER ERROR: could not extract namespaced name from object: %v", err)) + } + + return objName.AsNamespacedName() +} + +// ServingCertificate returns a mutator that wires GetCertificate to the Authority's manager. +func (a *Authority) ServingCertificate(config *tls.Config) { + config.GetCertificate = a.GetCertificate +} + +// Start runs informers and controllers until the context is cancelled. +func (a *Authority) Start(ctx context.Context) error { + klog.FromContext(ctx).Info( + "Starting webhook certificate authority", + "ca_secret", a.Options.AuthorityCertificate.SecretNamespacedName, + "ca_duration", a.Options.AuthorityCertificate.Duration, + ) + + a.queue = a.newQueue() + + a.factory.Start(ctx) + a.factory.WaitForCacheSync(ctx) + + // force the ca to be created if it does not exist yet + a.queue.Add(reconcilePendingCAKey) + + // Start worker to process the queue + var wg sync.WaitGroup + + const workerCount = 5 + for range workerCount { + wg.Go(func() { + wait.UntilWithContext(ctx, func(ctx context.Context) { + for a.processNextWorkItem(ctx) { + } + }, time.Second) + }) + } + + <-ctx.Done() + a.factory.Shutdown() + a.queue.ShutDown() + wg.Wait() + return nil +} + +func (a *Authority) isIssuer() bool { + secret, err := a.secretLister.Secrets(a.Options.AuthorityCertificate.SecretNamespacedName.Namespace).Get(a.Options.AuthorityCertificate.SecretNamespacedName.Name) + if err != nil { + return false + } + + issuerID, ok := secret.Annotations[api.IssuingAuthorityIDAnnotation] + return ok && issuerID == a.authorityID +} + +func (a *Authority) onCASecretChange(namespacedName types.NamespacedName) { + if namespacedName.Namespace != a.Options.AuthorityCertificate.SecretNamespacedName.Namespace || + namespacedName.Name != a.Options.AuthorityCertificate.SecretNamespacedName.Name { + return + } + + a.queue.Add(reconcileLeafCertificateKey) + a.queue.Add(reconcilePendingCAKey) + isIssuer := a.isIssuer() + a.queue.AddAfter(reconcileAllTargetsKey, collisionAvoidanceDelay(isIssuer)) // enqueue, but let issuer go first + a.queue.AddAfter(reconcileServingCAPromotionKey, collisionAvoidanceDelay(isIssuer)) // enqueue, but let issuer go first +} + +func (a *Authority) onInjectableChange(gvk schema.GroupVersionKind, key types.NamespacedName) { + // When targets change, enqueue reinjection based on current CA secret + isIssuer := a.isIssuer() + a.queue.AddAfter(reconcileSingleTargetKey(gvk, key), collisionAvoidanceDelay(isIssuer)) // enqueue, but let issuer go first + a.queue.AddAfter(reconcileSecretTargetAnnotationKey, collisionAvoidanceDelay(isIssuer)) // enqueue, but let issuer go first +} + +func (a *Authority) processNextWorkItem(ctx context.Context) bool { + item, shutdown := a.queue.Get() + if shutdown { + return false + } + defer a.queue.Done(item) + + a.metrics.IncrementReconciliations() + + var err error + switch { + case item == reconcilePendingCAKey: + err = a.reconcilePendingCA(ctx) + + case item == reconcileAllTargetsKey: + err = a.reconcileAllTargets(ctx) + + case item == reconcileSecretTargetAnnotationKey: + err = a.reconcileSecretTargetAnnotation(ctx) + + case item == reconcileServingCAPromotionKey: + err = a.reconcileServingCAPromotion(ctx) + + case item == reconcileLeafCertificateKey: + err = a.reconcileLeafCertificate(ctx) + + case item.reconcileType == reconcileSingleTargetKey(schema.GroupVersionKind{}, types.NamespacedName{}).reconcileType: + err = a.reconcileSingleTarget(ctx, item.gvk, item.key) + + default: + // Unknown work item + klog.FromContext(ctx).Error( + fmt.Errorf("processNextWorkItem: unknown work item, forgetting"), + "error processing queue item", + "item", item, + ) + return true + } + + if err != nil { + klog.FromContext(ctx).Error( + err, + "error processing queue item", + "item", item, + ) + a.queue.AddRateLimited(item) + return true + } + + a.queue.Forget(item) // successful processing, reset rate limiter + return true +} + +type caInSecret struct { + certKey string + keyKey string +} + +var servingCAInSecret = caInSecret{ + certKey: api.TLSServingCertKey, + keyKey: api.TLSServingPrivateKeyKey, +} + +var pendingCAInSecret = caInSecret{ + certKey: api.TLSPendingCertKey, + keyKey: api.TLSPendingPrivateKeyKey, +} + +type caInfo struct { + renewalPeriod certificate.TriggerWindow + validUntil time.Time + cert *x509.Certificate + certPEM []byte + privateKey crypto.Signer +} + +func (i caInfo) shouldRenew(now time.Time) bool { + return !now.Before(i.renewalPeriod.Start) +} + +func (config caInSecret) check(secret *corev1.Secret) (*caInfo, error) { + if secret == nil { + return nil, fmt.Errorf("secret not found") + } + + if secret.Type != corev1.SecretTypeTLS { + return nil, fmt.Errorf("secret %s/%s is not of type kubernetes.io/tls", secret.Namespace, secret.Name) + } + + keyPEM, ok := secret.Data[config.keyKey] + if !ok || len(keyPEM) == 0 { + return nil, fmt.Errorf("secret %s/%s is missing %q", secret.Namespace, secret.Name, config.keyKey) + } + caPrivateKey, err := pki.DecodePrivateKeyBytes(keyPEM) + if err != nil { + return nil, fmt.Errorf("failed to parse CA private key: %w", err) + } + + caCertPEM, ok := secret.Data[config.certKey] + if !ok || len(caCertPEM) == 0 { + return nil, fmt.Errorf("secret %s/%s is missing %q", secret.Namespace, secret.Name, config.certKey) + } + caCert, err := pki.DecodeCertificateFromPEM(caCertPEM) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + equal, err := pki.PublicKeysEqual(caPrivateKey.Public(), caCert.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed comparing CA public keys: %w", err) + } + if !equal { + return nil, fmt.Errorf("private key does not match public key") + } + + if time.Now().After(caCert.NotAfter) { + return nil, fmt.Errorf("CA certificate has expired") + } + + return &caInfo{ + renewalPeriod: certificate.RenewTriggerWindow(caCert), + validUntil: caCert.NotAfter, + cert: caCert, + certPEM: caCertPEM, + privateKey: caPrivateKey, + }, nil +} + +func (a *Authority) reconcilePendingCA(ctx context.Context) error { + sec, err := a.secretLister.Secrets(a.Options.AuthorityCertificate.SecretNamespacedName.Namespace).Get(a.Options.AuthorityCertificate.SecretNamespacedName.Name) + notFound := apierrors.IsNotFound(err) + if err != nil && !notFound { + return err } - for _, i := range o.Options.Injectables { - cacheByObject[injectable.NewUnstructured(i)] = cache.ByObject{ - Label: labels.SelectorFromSet(labels.Set{ - api.WantInjectFromSecretNameLabel: o.Options.CAOptions.Name, - api.WantInjectFromSecretNamespaceLabel: o.Options.CAOptions.Namespace, - }), + + if !notFound { + if servingInfo, err := servingCAInSecret.check(sec); err == nil && !servingInfo.shouldRenew(time.Now()) { + // enqueue next renewal based on serving CA + a.queue.AddAfter(reconcilePendingCAKey, time.Until(servingInfo.renewalPeriod.Random())) + + return nil // serving CA is valid, not expired & does not need rotation + } + + // if the pending CA is different from the serving CA, and is valid & not expired, + // we can wait for it to be promoted + if !bytes.Equal(sec.Data[api.TLSPendingCertKey], sec.Data[api.TLSServingCertKey]) && + !bytes.Equal(sec.Data[api.TLSPendingPrivateKeyKey], sec.Data[api.TLSServingPrivateKeyKey]) { + if pendingInfo, err := pendingCAInSecret.check(sec); err == nil { + // enqueue new pending CA generation based on expiry of pending CA + a.queue.AddAfter(reconcilePendingCAKey, time.Until(pendingInfo.validUntil)) + + return nil // pending CA is already generated, is valid & not expired, wait for it to be promoted + } } } - controllerCache, err := cache.New(mgr.GetConfig(), cache.Options{ - HTTPClient: mgr.GetHTTPClient(), - Scheme: mgr.GetScheme(), - Mapper: mgr.GetRESTMapper(), - ReaderFailOnMissingInformer: true, - ByObject: cacheByObject, - }) + + // Generate new pending CA cert and key + caCert, caPK, err := certificate.GenerateCA(a.Options.AuthorityCertificate.Duration) if err != nil { return err } - if err := mgr.Add(controllerCache); err != nil { + certPEM, err := pki.EncodeCertificateAsPEM(caCert) + if err != nil { return err } - - // Uncached client, used for patching only. - controllerClient, err := client.New(mgr.GetConfig(), client.Options{ - HTTPClient: mgr.GetHTTPClient(), - Scheme: mgr.GetScheme(), - Mapper: mgr.GetRESTMapper(), - }) + keyPEM, err := pki.EncodePrivateKey(caPK) if err != nil { return err } - r := Reconciler{ - Patcher: controllerClient, - Cache: controllerCache, - Options: o.Options, + applySecret := applyconfigurationscorev1. + Secret(a.Options.AuthorityCertificate.SecretNamespacedName.Name, a.Options.AuthorityCertificate.SecretNamespacedName.Namespace). + WithType(corev1.SecretTypeTLS). + WithLabels(map[string]string{ + api.DynamicAuthoritySecretLabel: "true", + }). + WithAnnotations(map[string]string{ + api.IssuingAuthorityIDAnnotation: a.authorityID, + }). + WithData(map[string][]byte{ + api.TLSPendingCertKey: certPEM, + api.TLSPendingPrivateKeyKey: keyPEM, + }) + applyOptions := metav1.ApplyOptions{ + FieldManager: "webhook-cert-lib/pending-ca-reconciler-" + a.authorityID, + } + + // A) If the secret does not exist yet (according to lister), we use a patch without force + // to create it. We use a unique fieldmanager to force conflicts if the secret was created + // between our list and patch. + // B) If the secret exists (according to lister), we use its resource version to + // avoid overwriting a pending CA that was created after we listed but before we patched. + if !notFound && sec != nil { + applySecret = applySecret.WithResourceVersion(sec.ResourceVersion) + applyOptions = metav1.ApplyOptions{ + Force: true, + FieldManager: "webhook-cert-lib/pending-ca-reconciler", + } + } + + a.metrics.IncrementSecretPatches() + sec, err = a.clientset.CoreV1(). + Secrets(a.Options.AuthorityCertificate.SecretNamespacedName.Namespace). + Apply(ctx, applySecret, applyOptions) + if err != nil && !apierrors.IsConflict(err) { + return err + } else if apierrors.IsConflict(err) { + // someone else created / updated the secret before us, likely with a pending CA + return nil // wait for updated secret to trigger another reconcile } - controllers := []dynamicAuthorityController{ - &CASecretReconciler{Reconciler: r}, - &LeafCertReconciler{Reconciler: r, CertificateHolder: o.certificateHolder}, + + klog.FromContext(ctx).Info( + "Generated new pending CA certificate and key", + api.TLSPendingCertKey, certInfoFromPEM(sec.Data[api.TLSPendingCertKey]), + api.TLSServingCertKey, certInfoFromPEM(sec.Data[api.TLSServingCertKey]), + api.TLSAllTrustedCertsKey, certsInfoFromPEM(sec.Data[api.TLSAllTrustedCertsKey]), + "bundle_version", trustBundle(sec).HashString()[:8], + ) + + return nil +} + +// trustBundle combines the certificates in the secret into a bundle which should +// be injected in all targets. +func trustBundle(cert *corev1.Secret) *pki.CertPool { + certPool := pki.NewCertPool( + pki.WithFilteredExpiredCerts(true), + ) + + if cert != nil && cert.Data != nil { + _ = certPool.AddCertificatesFromPEM(cert.Data[api.TLSServingCertKey]) + _ = certPool.AddCertificatesFromPEM(cert.Data[api.TLSPendingCertKey]) + _ = certPool.AddCertificatesFromPEM(cert.Data[api.TLSAllTrustedCertsKey]) } - for _, i := range o.Options.Injectables { - controllers = append(controllers, &InjectableReconciler{Reconciler: r, Injectable: i}) + + return certPool +} + +func (a *Authority) reconcileWithSecretInfo(fn func(secret *corev1.Secret, bundle *pki.CertPool) error) error { + secret, err := a.secretLister.Secrets(a.Options.AuthorityCertificate.SecretNamespacedName.Namespace).Get(a.Options.AuthorityCertificate.SecretNamespacedName.Name) + notFound := apierrors.IsNotFound(err) + if err != nil && !notFound { + return err } - for _, c := range controllers { - if err := c.SetupWithManager(mgr); err != nil { + + if notFound || secret == nil || secret.Data == nil { + return nil // no secret yet, pending CA reconciler will create it + } + + bundle := trustBundle(secret) + + return fn(secret, bundle) +} + +func (a *Authority) reconcileSingleTarget(ctx context.Context, gvk schema.GroupVersionKind, key types.NamespacedName) error { + return a.reconcileWithSecretInfo(func(secret *corev1.Secret, bundle *pki.CertPool) error { + didPatch, err := a.injectableListPatchers[gvk].PatchObject(ctx, key, bundle.PEM(), metav1.ApplyOptions{ + Force: true, + FieldManager: "webhook-cert-lib/target-reconciler", + }) + if err != nil { + return err + } + + if !didPatch { + return nil // already up-to-date + } + + a.metrics.IncrementTargetPatches() + klog.FromContext(ctx).Info( + "Injected trust bundle into target", + "target_gk", gvk.GroupKind(), + "target_key", key, + "bundle_version", bundle.HashString()[:8], + ) + + return nil + }) +} + +func (a *Authority) reconcileAllTargets(_ context.Context) error { + return a.reconcileWithSecretInfo(func(secret *corev1.Secret, bundle *pki.CertPool) error { + for gvk, listPatcher := range a.injectableListPatchers { + objList, err := listPatcher.ListObjects(bundle.PEM()) + if err != nil { + return err + } + + for key, isUpToDate := range objList { + if isUpToDate { + continue // already up-to-date + } + + a.queue.Add(reconcileSingleTargetKey(gvk, key)) + } + } + + return nil // wait for informer to notice updated targets and add annotations + }) +} + +func (a *Authority) reconcileSecretTargetAnnotation(ctx context.Context) error { + return a.reconcileWithSecretInfo(func(secret *corev1.Secret, bundle *pki.CertPool) error { + expectedTargets := make(map[TargetObject]struct{}, len(a.Options.Targets.Objects)) + for _, obj := range a.Options.Targets.Objects { + expectedTargets[obj] = struct{}{} + } + + for gvk, listPatcher := range a.injectableListPatchers { + vwcList, err := listPatcher.ListObjects(bundle.PEM()) + if err != nil { + return err + } + + for key, isUpToDate := range vwcList { + if !isUpToDate { + // Not all targets are updated yet + return nil // wait for individual target reconciles to complete + } + + delete(expectedTargets, TargetObject{ + GroupKind: gvk.GroupKind(), + NamespacedName: key, + }) + } + } + + if len(expectedTargets) > 0 { + klog.FromContext(ctx).Info( + "Some targets were not found during annotation reconciliation", + "missing_targets", slices.SortedFunc(maps.Keys(expectedTargets), func(a, b TargetObject) int { + return strings.Compare(a.String(), b.String()) + }), + ) + + // Not all targets are updated yet + return nil // wait for individual target reconciles to complete + } + + if secret.Annotations[api.InjectedLastVersionAnnotation] == bundle.HashString() { + return nil // annotation already up-to-date + } + + injectionCompletedAt := time.Now().UTC().Format(time.RFC3339Nano) + + klog.FromContext(ctx).Info( + "All targets were updated with trust bundles", + "injection_completed_at", injectionCompletedAt, + "bundle_version", bundle.HashString()[:8], + ) + + applySecret := applyconfigurationscorev1. + Secret(a.Options.AuthorityCertificate.SecretNamespacedName.Name, a.Options.AuthorityCertificate.SecretNamespacedName.Namespace). + WithAnnotations(map[string]string{ + api.InjectedAtTimestampAnnotation: injectionCompletedAt, + api.InjectedLastVersionAnnotation: bundle.HashString(), + }) + + a.metrics.IncrementSecretPatches() + _, err := a.clientset.CoreV1(). + Secrets(a.Options.AuthorityCertificate.SecretNamespacedName.Namespace). + Apply(ctx, applySecret, metav1.ApplyOptions{ + Force: true, + FieldManager: "webhook-cert-lib/target-reconciler", + }) + if err != nil { return err } + + a.queue.AddAfter(reconcileServingCAPromotionKey, a.Options.PromotionDelay) + + return nil + }) +} + +func shouldPromotePendingCA(secret *corev1.Secret, promotionDelay time.Duration) (bool, time.Time) { + if _, err := pendingCAInSecret.check(secret); err != nil { + return false, time.Time{} // pending CA not present, invalid or expired } - return nil + if bytes.Equal(secret.Data[api.TLSPendingCertKey], secret.Data[api.TLSServingCertKey]) && + bytes.Equal(secret.Data[api.TLSPendingPrivateKeyKey], secret.Data[api.TLSServingPrivateKeyKey]) { + return false, time.Time{} // pending CA is identical to serving CA, no need to promote + } + + injectedVersionHex, hasVersionAnnotation := secret.Annotations[api.InjectedLastVersionAnnotation] + injectedAtTimestamp, hasInjectedAt := secret.Annotations[api.InjectedAtTimestampAnnotation] + + if !hasVersionAnnotation || !hasInjectedAt { + return false, time.Time{} // never injected + } + + bundle := trustBundle(secret) + if injectedVersionHex != bundle.HashString() { + return false, time.Time{} // bundle has changed since last injection + } + + injectedAt, err := time.Parse(time.RFC3339Nano, injectedAtTimestamp) + if err != nil { + return false, time.Time{} // invalid timestamp + } + + return true, injectedAt.Add(promotionDelay) } -type dynamicAuthorityController interface { - SetupWithManager(ctrl.Manager) error +func (a *Authority) reconcileServingCAPromotion(ctx context.Context) error { + return a.reconcileWithSecretInfo(func(secret *corev1.Secret, bundle *pki.CertPool) error { + shouldPromote, promoteAt := shouldPromotePendingCA(secret, a.Options.PromotionDelay) + if !shouldPromote { + return nil // not ready for promotion yet + } + + if time.Now().Before(promoteAt) { + a.queue.AddAfter(reconcileServingCAPromotionKey, time.Until(promoteAt)) + + return nil // wait until promotion delay has passed + } + + // promote pending to serving + + applySecret := applyconfigurationscorev1. + Secret(a.Options.AuthorityCertificate.SecretNamespacedName.Name, a.Options.AuthorityCertificate.SecretNamespacedName.Namespace). + WithType(corev1.SecretTypeTLS). + WithData(map[string][]byte{ + api.TLSServingCertKey: secret.Data[api.TLSPendingCertKey], + api.TLSServingPrivateKeyKey: secret.Data[api.TLSPendingPrivateKeyKey], + api.TLSAllTrustedCertsKey: bundle.PEM(), + }) + + a.metrics.IncrementSecretPatches() + sec, err := a.clientset.CoreV1(). + Secrets(a.Options.AuthorityCertificate.SecretNamespacedName.Namespace). + Apply(ctx, applySecret, metav1.ApplyOptions{ + Force: true, + FieldManager: "webhook-cert-lib/serving-ca-reconciler", + }) + if err != nil { + return err + } + + klog.FromContext(ctx).Info( + "Promoted pending CA to serving CA", + api.TLSPendingCertKey, certInfoFromPEM(sec.Data[api.TLSPendingCertKey]), + api.TLSServingCertKey, certInfoFromPEM(sec.Data[api.TLSServingCertKey]), + api.TLSAllTrustedCertsKey, certsInfoFromPEM(sec.Data[api.TLSAllTrustedCertsKey]), + "bundle_version", trustBundle(sec).HashString()[:8], + ) + + // trigger leaf issuance promptly after promotion + a.queue.Add(reconcileLeafCertificateKey) + + return nil // promotion done + }) +} + +func (a *Authority) reconcileLeafCertificate(ctx context.Context) error { + return a.reconcileWithSecretInfo(func(secret *corev1.Secret, bundle *pki.CertPool) error { + servingInfo, err := servingCAInSecret.check(secret) + if err != nil { + return nil // nolint:nilerr // serving CA not present or invalid, wait for promotion + } + + // if current leaf cert is valid and signed by current serving CA, and does not need renewal, we don't issue a new leaf + if leaf := a.leaf.Load(); leaf != nil && bytes.Equal(leaf.ca, servingInfo.certPEM) { + if !leaf.validUntil.Before(servingInfo.validUntil) { + // leaf already lives until the ca cert expires + + return nil // we wait until the ca is rotated + } + + if time.Now().Before(leaf.renewalPeriod.Start) { + // leaf still valid and does not need renewal + // enqueue next renewal based on leaf cert + a.queue.AddAfter(reconcileLeafCertificateKey, time.Until(leaf.renewalPeriod.Random())) + + return nil // leaf cert is valid and signed by current serving CA, no action needed + } + + // stored leaf needs renewal, continue to issue a new one + } + + leafCert, leafPK, err := certificate.GenerateLeaf(a.leafDNSNames, a.leafDuration, servingInfo.cert, servingInfo.privateKey) + if err != nil { + return fmt.Errorf("failed generating leaf certificate: %w", err) + } + + tlsCert, err := pki.ToTLSCertificate(leafCert, leafPK) + if err != nil { + return fmt.Errorf("failed assembling tls.Certificate: %w", err) + } + withExpiry := tlsCertWithExpiry{ + cert: tlsCert, + renewalPeriod: certificate.RenewTriggerWindow(leafCert), + validUntil: leafCert.NotAfter, + ca: servingInfo.certPEM, + } + a.leaf.Store(&withExpiry) + + klog.FromContext(ctx).Info( + "New leaf certificate issued signed by current serving CA and available for serving", + api.TLSServingCertKey, certInfoFromPEM(secret.Data[api.TLSServingCertKey]), + "leaf_certificate", certInfo(leafCert), + ) + + // enqueue next renewal based on leaf cert + a.queue.AddAfter(reconcileLeafCertificateKey, time.Until(withExpiry.renewalPeriod.Random())) + + return nil + }) } diff --git a/pkg/authority/authority_test.go b/pkg/authority/authority_test.go new file mode 100644 index 0000000..29ce912 --- /dev/null +++ b/pkg/authority/authority_test.go @@ -0,0 +1,286 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 authority + +import ( + "testing" + "time" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + admissionregistrationlisters "k8s.io/client-go/listers/admissionregistration/v1" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + "github.com/cert-manager/webhook-cert-lib/internal/certificate" + "github.com/cert-manager/webhook-cert-lib/internal/pki" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" +) + +func requireNoError(t *testing.T, err error) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func stringsEqual(t *testing.T, expected, actual string) { + t.Helper() + + if expected != actual { + t.Fatalf("strings not equal:\nexpected: %q\nactual: %q", expected, actual) + } +} + +func bytesNotEmpty(t *testing.T, notEmpty []byte) { + t.Helper() + + if len(notEmpty) == 0 { + t.Fatalf("expected byte slice to be not empty") + } +} + +// newSecretLister returns a SecretLister backed by a fresh indexer and a helper +// function to add Secrets to it. +func newSecretLister(t *testing.T) (corelisters.SecretLister, func(obj *corev1.Secret)) { + t.Helper() + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + add := func(obj *corev1.Secret) { + requireNoError(t, indexer.Add(obj)) + } + return corelisters.NewSecretLister(indexer), add +} + +// newVWCLister returns a ValidatingWebhookConfigurationLister backed by a fresh indexer and a helper +// function to add VWCs to it. +func newVWCLister(t *testing.T) (admissionregistrationlisters.ValidatingWebhookConfigurationLister, func(obj *admissionregistrationv1.ValidatingWebhookConfiguration)) { + t.Helper() + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + add := func(obj *admissionregistrationv1.ValidatingWebhookConfiguration) { + requireNoError(t, indexer.Add(obj)) + } + return admissionregistrationlisters.NewValidatingWebhookConfigurationLister(indexer), add +} + +func TestProcessReinjectUpdatesWebhookCABundle(t *testing.T) { + t.Parallel() + + cs := fake.NewClientset() + + // Prepare listers + secretLister, addSecret := newSecretLister(t) + vwcLister, addVWC := newVWCLister(t) + + // CA secret with bundle: generate a real CA cert and key so checkCA() can parse it. + caNS, caName := "ns", "ca" + caCert, caKey, err := certificate.GenerateCA(24 * time.Hour) + requireNoError(t, err) + caCertPEM, err := pki.EncodeCertificateAsPEM(caCert) + requireNoError(t, err) + caKeyPEM, err := pki.EncodePrivateKey(caKey) + requireNoError(t, err) + caBundle := caCertPEM + sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: caNS, Name: caName}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{api.TLSServingCertKey: caBundle, api.TLSServingPrivateKeyKey: caKeyPEM}} + addSecret(sec) + + // Existing VWC in client with wrong CABundle, and present in lister + vwc := &admissionregistrationv1.ValidatingWebhookConfiguration{ObjectMeta: metav1.ObjectMeta{Name: "vwc1", Labels: map[string]string{ + api.WantInjectFromSecretNamespaceLabel: caNS, + api.WantInjectFromSecretNameLabel: caName, + }}, Webhooks: []admissionregistrationv1.ValidatingWebhook{{ + Name: "wh1", + ClientConfig: admissionregistrationv1.WebhookClientConfig{CABundle: []byte("old")}, + }, { + Name: "wh2", + ClientConfig: admissionregistrationv1.WebhookClientConfig{CABundle: []byte("old")}, + }}} + _, err = cs.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(t.Context(), vwc.DeepCopy(), metav1.CreateOptions{}) + requireNoError(t, err) + addVWC(vwc) + + a := &Authority{ + Options: AuthorityOptions{ + AuthorityCertificate: AuthorityCertificateOptions{ + SecretNamespacedName: typesNN(caNS, caName), + }, + PromotionDelay: 1 * time.Millisecond, + }, + clientset: cs, + secretLister: secretLister, + injectableListPatchers: map[schema.GroupVersionKind]injectable.ListPatcher{ + admissionregistrationv1.SchemeGroupVersion.WithKind("ValidatingWebhookConfiguration"): &injectable.ValidatingWebhookCaBundleInjectListPatcher{ + Client: cs, + Lister: vwcLister, + }, + }, + newQueue: func() workqueue.TypedRateLimitingInterface[queueKey] { return nil }, + } + // Provide queue same as in production + a.newQueue = newTypedQueue + a.queue = a.newQueue() + + // Enqueue reinjection and process + a.queue.Add(reconcileAllTargetsKey) + _ = a.processNextWorkItem(t.Context()) // reconcile secret and enqueue targets + _ = a.processNextWorkItem(t.Context()) // reconcile single target + + // Assert CABundle updated in cluster + got, err := cs.AdmissionregistrationV1().ValidatingWebhookConfigurations().Get(t.Context(), vwc.Name, metav1.GetOptions{}) + requireNoError(t, err) + + if len(got.Webhooks) != 2 { + t.Fatalf("expected 2 webhooks, got %d", len(got.Webhooks)) + } + for i := range got.Webhooks { + stringsEqual(t, string(caBundle), string(got.Webhooks[i].ClientConfig.CABundle)) + } +} + +// Ensure that when a pending cert exists but not all targets have been +// injected, reconcile does not promote it. +func TestPendingNotPromotedIfTargetsNotInjected(t *testing.T) { + t.Parallel() + + cs := fake.NewClientset() + secretLister, addSecret := newSecretLister(t) + + // seed secret with pending cert and different CABundle on VWC + caCert, caPK, err := certificate.GenerateCA(40 * time.Hour) + requireNoError(t, err) + certPEM, err := pki.EncodeCertificateAsPEM(caCert) + requireNoError(t, err) + keyPEM, err := pki.EncodePrivateKey(caPK) + requireNoError(t, err) + sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "ca"}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{ + api.TLSPendingCertKey: certPEM, + api.TLSPendingPrivateKeyKey: keyPEM, + }} + addSecret(sec) + _, err = cs.CoreV1().Secrets("ns").Create(t.Context(), sec.DeepCopy(), metav1.CreateOptions{}) + requireNoError(t, err) + + // prepare a VWC lister with a webhook that does not have the pending bundle + vwcLister, addVWC := newVWCLister(t) + vwc := &admissionregistrationv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "vwc1"}, + Webhooks: []admissionregistrationv1.ValidatingWebhook{{ + Name: "wh1", + ClientConfig: admissionregistrationv1.WebhookClientConfig{CABundle: []byte("other")}, + }}, + } + addVWC(vwc) + + a := &Authority{ + Options: AuthorityOptions{ + AuthorityCertificate: AuthorityCertificateOptions{ + SecretNamespacedName: typesNN("ns", "ca"), + }, + }, + clientset: cs, + secretLister: secretLister, + injectableListPatchers: map[schema.GroupVersionKind]injectable.ListPatcher{ + admissionregistrationv1.SchemeGroupVersion.WithKind("ValidatingWebhookConfiguration"): &injectable.ValidatingWebhookCaBundleInjectListPatcher{ + Client: cs, + Lister: vwcLister, + }, + }, + newQueue: newTypedQueue, + } + a.queue = a.newQueue() + + a.queue.Add(reconcilePendingCAKey) + _ = a.processNextWorkItem(t.Context()) + + s, err := cs.CoreV1().Secrets("ns").Get(t.Context(), "ca", metav1.GetOptions{}) + requireNoError(t, err) + // pending should still be present and not promoted + stringsEqual(t, string(certPEM), string(s.Data[api.TLSPendingCertKey])) + stringsEqual(t, string(keyPEM), string(s.Data[api.TLSPendingPrivateKeyKey])) +} + +// If a Secret exists but is not type TLS, reconcile should still write +// pending fields (treat as unhealthy CA) so rotation proceeds. +// +// TODO: I believe the fake clientset does not enforce immutability of Secret.Type, +// so this test may not be fully valid. +func TestSecretWrongTypeCreatesPending(t *testing.T) { + t.Parallel() + + cs := fake.NewClientset() + secretLister, addSecret := newSecretLister(t) + + // create a non-TLS secret in lister and client + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "ca"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + addSecret(sec) + _, err := cs.CoreV1().Secrets("ns").Create(t.Context(), sec.DeepCopy(), metav1.CreateOptions{}) + requireNoError(t, err) + + // create a no-op VWC lister so reconcileCASecret doesn't nil-deref it + vwcLister, _ := newVWCLister(t) + + a := &Authority{ + Options: AuthorityOptions{ + AuthorityCertificate: AuthorityCertificateOptions{ + SecretNamespacedName: typesNN("ns", "ca"), + }, + }, + clientset: cs, + secretLister: secretLister, + injectableListPatchers: map[schema.GroupVersionKind]injectable.ListPatcher{ + admissionregistrationv1.SchemeGroupVersion.WithKind("ValidatingWebhookConfiguration"): &injectable.ValidatingWebhookCaBundleInjectListPatcher{ + Client: cs, + Lister: vwcLister, + }, + }, + newQueue: newTypedQueue, + } + a.queue = a.newQueue() + + a.queue.Add(reconcilePendingCAKey) + _ = a.processNextWorkItem(t.Context()) + + s, err := cs.CoreV1().Secrets("ns").Get(t.Context(), "ca", metav1.GetOptions{}) + requireNoError(t, err) + // ensure pending fields were written + bytesNotEmpty(t, s.Data[api.TLSPendingCertKey]) + bytesNotEmpty(t, s.Data[api.TLSPendingPrivateKeyKey]) +} + +// Minimal typed wrappers so we can use the same workqueue types as production code without +// importing generics directly in test code. This keeps the test decoupled from queue construction details. +func newTypedQueue() workqueue.TypedRateLimitingInterface[queueKey] { + return workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[queueKey](), + workqueue.TypedRateLimitingQueueConfig[queueKey]{Name: "authority-test"}, + ) +} + +// typesNN is a small helper to build a NamespacedName inline without importing the type in test code. +func typesNN(ns, name string) types.NamespacedName { + return types.NamespacedName{Namespace: ns, Name: name} +} diff --git a/pkg/authority/ca_secret_controller.go b/pkg/authority/ca_secret_controller.go deleted file mode 100644 index 2a129b4..0000000 --- a/pkg/authority/ca_secret_controller.go +++ /dev/null @@ -1,180 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 authority - -import ( - "context" - "crypto" - "crypto/tls" - "crypto/x509" - "time" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - corev1ac "k8s.io/client-go/applyconfigurations/core/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/source" - - "github.com/cert-manager/webhook-cert-lib/internal/certificate" - "github.com/cert-manager/webhook-cert-lib/internal/pki" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/internal/ssa" -) - -// CASecretReconciler reconciles a CA Secret object -type CASecretReconciler struct { - Reconciler - events chan event.TypedGenericEvent[*corev1.Secret] -} - -// SetupWithManager sets up the controller with the Manager. -func (r *CASecretReconciler) SetupWithManager(mgr ctrl.Manager) error { - r.events = make(chan event.TypedGenericEvent[*corev1.Secret], 1) - r.events <- event.TypedGenericEvent[*corev1.Secret]{Object: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: r.CAOptions.Namespace, - Name: r.CAOptions.Name, - }, - }} - - return ctrl.NewControllerManagedBy(mgr). - Named("cert_ca_secret"). - WatchesRawSource(r.caSecretSource(&handler.TypedEnqueueRequestForObject[*corev1.Secret]{})). - WatchesRawSource(source.Channel(r.events, &handler.TypedEnqueueRequestForObject[*corev1.Secret]{})). - Complete(r) -} - -func (r *CASecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - secret := &corev1.Secret{} - if err := r.Cache.Get(ctx, req.NamespacedName, secret); err != nil { - if !errors.IsNotFound(err) { - return ctrl.Result{}, err - } - // Secret does not exist - let's create it by setting namespace/name - secret.Namespace = req.Namespace - secret.Name = req.Name - } - - caCert, err := r.reconcileSecret(ctx, secret) - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{RequeueAfter: time.Until(certificate.RenewTriggerWindow(caCert).Random())}, nil -} - -func (r *CASecretReconciler) reconcileSecret(ctx context.Context, secret *corev1.Secret) (caCert *x509.Certificate, err error) { - var caPk crypto.Signer - - if required, reason := caRequiresRegeneration(secret); required { - log.FromContext(ctx).Info("Will regenerate CA", "reason", reason) - - caCert, caPk, err = certificate.GenerateCA(r.CAOptions.Duration) - if err != nil { - return caCert, err - } - } else { - caCert, err = pki.DecodeCertificateFromPEM(secret.Data[corev1.TLSCertKey]) - if err != nil { - return caCert, err - } - caPk, err = pki.DecodePrivateKeyBytes(secret.Data[corev1.TLSPrivateKeyKey]) - if err != nil { - return caCert, err - } - } - - certBytes, err := pki.EncodeCertificateAsPEM(caCert) - if err != nil { - return caCert, err - } - pkBytes, err := pki.EncodePrivateKey(caPk) - if err != nil { - return caCert, err - } - - caBundleBytes := addCertToCABundle(ctx, secret.Data[api.TLSCABundleKey], caCert) - - ac := corev1ac.Secret(secret.Name, secret.Namespace). - WithLabels(map[string]string{ - api.DynamicAuthoritySecretLabel: "true", - }). - WithType(corev1.SecretTypeTLS). - WithData(map[string][]byte{ - corev1.TLSCertKey: certBytes, - corev1.TLSPrivateKeyKey: pkBytes, - api.TLSCABundleKey: caBundleBytes, - }) - - if v, ok := secret.Annotations[api.RenewCertificateSecretAnnotation]; ok { - ac.WithAnnotations(map[string]string{ - api.RenewHandledCertificateSecretAnnotation: v, - }) - } - - return caCert, r.Patcher.Patch(ctx, secret, ssa.NewApplyPatch(ac), client.ForceOwnership, ssa.FieldOwner) -} - -func addCertToCABundle(ctx context.Context, caBundleBytes []byte, caCert *x509.Certificate) []byte { - certPool := pki.NewCertPool(pki.WithFilteredExpiredCerts(true)) - - if err := certPool.AddCertificatesFromPEM(caBundleBytes); err != nil { - log.FromContext(ctx).Error(err, "failed to re-use existing CAs in new set of CAs") - } - // TODO: handle AddCertificate returning false? I expect this will never happen. - _ = certPool.AddCertificate(caCert) - - return certPool.PEM() -} - -// caRequiresRegeneration will check data in a Secret resource and return true -// if the CA needs to be regenerated for any reason. -func caRequiresRegeneration(s *corev1.Secret) (bool, string) { - if s.Annotations[api.RenewCertificateSecretAnnotation] != s.Annotations[api.RenewHandledCertificateSecretAnnotation] { - return true, "Forced renewal." - } - - if s.Data == nil { - return true, "Missing data in CA secret." - } - pkData := s.Data[corev1.TLSPrivateKeyKey] - certData := s.Data[corev1.TLSCertKey] - if len(pkData) == 0 || len(certData) == 0 { - return true, "Missing data in CA secret." - } - cert, err := tls.X509KeyPair(certData, pkData) - if err != nil { - return true, "Failed to parse data in CA secret." - } - - x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return true, "Internal error parsing x509 certificate." - } - if !x509Cert.IsCA { - return true, "Stored certificate is not marked as a CA." - } - if !time.Now().Before(certificate.RenewTriggerWindow(x509Cert).Start) { - return true, "CA certificate is nearing expiry." - } - - return false, "" -} diff --git a/pkg/authority/ca_secret_controller_test.go b/pkg/authority/ca_secret_controller_test.go deleted file mode 100644 index e93ce57..0000000 --- a/pkg/authority/ca_secret_controller_test.go +++ /dev/null @@ -1,183 +0,0 @@ -/* -Copyright 2020 The cert-manager 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 - - 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 authority - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "math/big" - "testing" - "time" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - - "github.com/cert-manager/webhook-cert-lib/internal/pki" -) - -var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 128) - -func Test__caRequiresRegeneration(t *testing.T) { - generateSecretData := func(mod func(*x509.Certificate)) map[string][]byte { - // Generate a certificate and private key pair - pk, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) - assert.NoError(t, err) - pkBytes, err := pki.EncodePrivateKey(pk) - assert.NoError(t, err) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - assert.NoError(t, err) - cert := &x509.Certificate{ - Version: 3, - BasicConstraintsValid: true, - SerialNumber: serialNumber, - PublicKeyAlgorithm: x509.ECDSA, - Subject: pkix.Name{ - CommonName: "cert-manager-webhook-ca", - }, - IsCA: true, - NotBefore: time.Now(), - NotAfter: time.Now().Add(5 * time.Minute), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, - } - if mod != nil { - mod(cert) - } - cert, err = pki.SignCertificate(cert, cert, pk.Public(), pk) - assert.NoError(t, err) - certBytes, err := pki.EncodeCertificateAsPEM(cert) - assert.NoError(t, err) - - return map[string][]byte{ - "tls.crt": certBytes, - "ca.crt": certBytes, - "tls.key": pkBytes, - } - } - - tests := []struct { - name string - secret *corev1.Secret - expect bool - expectReason string - }{ - { - name: "Missing data in CA secret (nil data)", - secret: &corev1.Secret{ - Data: nil, - }, - expect: true, - expectReason: "Missing data in CA secret.", - }, - { - name: "Missing data in CA secret (missing ca.crt)", - secret: &corev1.Secret{ - Data: map[string][]byte{ - "tls.key": []byte("private key"), - }, - }, - expect: true, - expectReason: "Missing data in CA secret.", - }, - { - name: "Failed to parse data in CA secret", - secret: &corev1.Secret{ - Data: map[string][]byte{ - "tls.crt": []byte("cert"), - "ca.crt": []byte("cert"), - "tls.key": []byte("secret"), - }, - }, - expect: true, - expectReason: "Failed to parse data in CA secret.", - }, - { - name: "Stored certificate is not marked as a CA", - secret: &corev1.Secret{ - Data: generateSecretData( - func(cert *x509.Certificate) { - cert.IsCA = false - }, - ), - }, - expect: true, - expectReason: "Stored certificate is not marked as a CA.", - }, - { - name: "Root CA certificate is JUST nearing expiry", - secret: &corev1.Secret{ - Data: generateSecretData( - func(cert *x509.Certificate) { - lifetime := 10 * time.Hour - cert.NotBefore = time.Now().Add(-6*lifetime/10 - 1*time.Minute) - cert.NotAfter = cert.NotBefore.Add(lifetime) - }, - ), - }, - expect: true, - expectReason: "CA certificate is nearing expiry.", - }, - { - name: "Root CA certificate is ALMOST nearing expiry", - secret: &corev1.Secret{ - Data: generateSecretData( - func(cert *x509.Certificate) { - lifetime := 10 * time.Hour - cert.NotBefore = time.Now().Add(-6*lifetime/10 + 1*time.Minute) - cert.NotAfter = cert.NotBefore.Add(lifetime) - }, - ), - }, - expect: false, - }, - { - name: "Root CA certificate is expired", - secret: &corev1.Secret{ - Data: generateSecretData( - func(cert *x509.Certificate) { - cert.NotBefore = time.Now().Add(-1 * time.Hour) - cert.NotAfter = time.Now().Add(-1 * time.Minute) - }, - ), - }, - expect: true, - expectReason: "CA certificate is nearing expiry.", - }, - { - name: "Ok", - secret: &corev1.Secret{ - Data: generateSecretData(nil), - }, - expect: false, - expectReason: "", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - required, reason := caRequiresRegeneration(test.secret) - if required != test.expect { - t.Errorf("Expected %v, but got %v", test.expect, required) - } - if reason != test.expectReason { - t.Errorf("Expected %q, but got %q", test.expectReason, reason) - } - }) - } -} diff --git a/pkg/authority/certinfo.go b/pkg/authority/certinfo.go new file mode 100644 index 0000000..a9627b7 --- /dev/null +++ b/pkg/authority/certinfo.go @@ -0,0 +1,77 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 authority + +import ( + "crypto/x509" + "fmt" + "time" + + "github.com/cert-manager/webhook-cert-lib/internal/pki" +) + +type CertInfo struct { + Hash string `json:"hash"` + ValidUntil time.Time `json:"validUntil"` +} + +func (c *CertInfo) String() string { + return c.Hash + " (valid until " + c.ValidUntil.Format(time.RFC3339) + ")" +} + +func certInfo(cert *x509.Certificate) CertInfo { + return CertInfo{ + Hash: pki.HashString(pki.CertificatesHash(cert)), + ValidUntil: cert.NotAfter, + } +} + +func certInfoFromPEM(certPEM []byte) any { + if len(certPEM) == 0 { + return "" + } + + cert, err := pki.DecodeCertificateFromPEM(certPEM) + if err != nil { + return fmt.Errorf("failed to parse certificate PEM: %w", err) + } + + return certInfo(cert) +} + +func certsInfoFromPEM(certsPEM []byte) any { + if len(certsPEM) == 0 { + return "" + } + + certPool := pki.NewCertPool() + if err := certPool.AddCertificatesFromPEM(certsPEM); err != nil { + return fmt.Errorf("failed to parse certificates PEM: %w", err) + } + + certs := certPool.Certificates() + + certsInfo := make([]CertInfo, 0, len(certs)) + for _, cert := range certs { + certsInfo = append(certsInfo, CertInfo{ + Hash: pki.HashString(pki.CertificatesHash(cert)), + ValidUntil: cert.NotAfter, + }) + } + + return certsInfo +} diff --git a/pkg/authority/informerfactory/factory.go b/pkg/authority/informerfactory/factory.go new file mode 100644 index 0000000..d9b65c6 --- /dev/null +++ b/pkg/authority/informerfactory/factory.go @@ -0,0 +1,162 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 informerfactory + +import ( + "context" + reflect "reflect" + sync "sync" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + cache "k8s.io/client-go/tools/cache" +) + +type informerFactory struct { + lock sync.Mutex + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool + // wg tracks how many goroutines were started. + wg sync.WaitGroup + // shuttingDown is true when Shutdown has been called. It may still be running + // because it needs to wait for goroutines. + shuttingDown bool +} + +var _ Factory = &informerFactory{} + +// NewInformerFactory constructs a new instance of a Factory with additional options. +func NewInformerFactory() Factory { + factory := &informerFactory{ + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } + + return factory +} + +func (f *informerFactory) Start(ctx context.Context) { + f.lock.Lock() + defer f.lock.Unlock() + + if f.shuttingDown { + return + } + + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + continue + } + + f.wg.Go(func() { + informer.RunWithContext(ctx) + }) + f.startedInformers[informerType] = true + } +} + +func (f *informerFactory) Shutdown() { + f.lock.Lock() + f.shuttingDown = true + f.lock.Unlock() + + // Will return immediately if there is nothing to wait for. + f.wg.Wait() +} + +func (f *informerFactory) WaitForCacheSync(ctx context.Context) bool { + hasSyncedChecks := func() []cache.InformerSynced { + f.lock.Lock() + defer f.lock.Unlock() + + hasSyncedChecks := make([]cache.InformerSynced, 0, len(f.informers)) + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + hasSyncedChecks = append(hasSyncedChecks, informer.HasSynced) + } + } + return hasSyncedChecks + }() + + return waitForCacheSync(ctx, hasSyncedChecks...) +} + +func waitForCacheSync(ctx context.Context, cacheSyncs ...cache.InformerSynced) bool { + const syncedPollPeriod = 100 * time.Nanosecond + + if err := wait.PollUntilContextCancel(ctx, syncedPollPeriod, true, func(ctx context.Context) (bool, error) { + for _, syncFunc := range cacheSyncs { + if !syncFunc() { + return false, nil + } + } + return true, nil + }); err != nil { + return false + } + + return true +} + +// InformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *informerFactory) InformerFor(obj runtime.Object, newFunc func() cache.SharedIndexInformer) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + + informer = newFunc() + f.informers[informerType] = informer + + return informer +} + +type Factory interface { + // Start initializes all requested informers. They are handled in goroutines + // which run until the stop channel gets closed. + // Warning: Start does not block. When run in a go-routine, it will race with a later WaitForCacheSync. + Start(ctx context.Context) + + // Shutdown marks a factory as shutting down. At that point no new + // informers can be started anymore and Start will return without + // doing anything. + // + // In addition, Shutdown blocks until all goroutines have terminated. For that + // to happen, the close channel(s) that they were started with must be closed, + // either before Shutdown gets called or while it is waiting. + // + // Shutdown may be called multiple times, even concurrently. All such calls will + // block until all goroutines have terminated. + Shutdown() + + // WaitForCacheSync blocks until all started informers' caches were synced + // or the stop channel gets closed. + WaitForCacheSync(ctx context.Context) bool + + // InformerFor returns the SharedIndexInformer for obj using an internal + // client. + InformerFor(obj runtime.Object, newFunc func() cache.SharedIndexInformer) cache.SharedIndexInformer +} diff --git a/pkg/authority/injectable/injectable.go b/pkg/authority/injectable/injectable.go index edac68b..0bb69dc 100644 --- a/pkg/authority/injectable/injectable.go +++ b/pkg/authority/injectable/injectable.go @@ -17,25 +17,38 @@ limitations under the License. package injectable import ( - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" + "context" + "iter" + "time" - "github.com/cert-manager/webhook-cert-lib/pkg/runtime" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/informers/internalinterfaces" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" ) -type Injectable interface { +type InjectableKind interface { GroupVersionKind() schema.GroupVersionKind - InjectCA(obj *unstructured.Unstructured, caBundle []byte) (runtime.ApplyConfiguration, error) + ExampleObject() runtime.Object + NewInformerAndListPatcher( + client kubernetes.Interface, + resyncPeriod time.Duration, + indexers cache.Indexers, + tweakListOptions internalinterfaces.TweakListOptionsFunc, + ) (cache.SharedIndexInformer, ListPatcher) } -func NewUnstructured(injectable Injectable) *unstructured.Unstructured { - obj := &unstructured.Unstructured{} - obj.SetGroupVersionKind(injectable.GroupVersionKind()) - return obj -} +type IsUpToDate bool + +const ( + UpToDate IsUpToDate = true + NeedsUpdate IsUpToDate = false +) -func NewUnstructuredList(injectable Injectable) *unstructured.UnstructuredList { - obj := &unstructured.UnstructuredList{} - obj.SetGroupVersionKind(injectable.GroupVersionKind()) - return obj +type ListPatcher interface { + ListObjects(caBundle []byte) (iter.Seq2[types.NamespacedName, IsUpToDate], error) + PatchObject(ctx context.Context, key types.NamespacedName, caBundle []byte, applyOptions metav1.ApplyOptions) (bool, error) } diff --git a/pkg/authority/injectable/validating_webhook.go b/pkg/authority/injectable/validating_webhook.go index d851967..3e46c92 100644 --- a/pkg/authority/injectable/validating_webhook.go +++ b/pkg/authority/injectable/validating_webhook.go @@ -17,48 +17,145 @@ limitations under the License. package injectable import ( - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "bytes" + "context" + "iter" + "time" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" admissionregistrationv1ac "k8s.io/client-go/applyconfigurations/admissionregistration/v1" - - "github.com/cert-manager/webhook-cert-lib/pkg/runtime" + admissionregistrationinformers "k8s.io/client-go/informers/admissionregistration/v1" + "k8s.io/client-go/informers/internalinterfaces" + "k8s.io/client-go/kubernetes" + admissionregistrationlisters "k8s.io/client-go/listers/admissionregistration/v1" + "k8s.io/client-go/tools/cache" ) type ValidatingWebhookCaBundleInject struct { } -var _ Injectable = &ValidatingWebhookCaBundleInject{} +var _ InjectableKind = &ValidatingWebhookCaBundleInject{} + +func (i ValidatingWebhookCaBundleInject) GroupVersionKind() schema.GroupVersionKind { + return admissionregistrationv1. + SchemeGroupVersion. + WithKind("ValidatingWebhookConfiguration") +} + +func (i *ValidatingWebhookCaBundleInject) ExampleObject() runtime.Object { + return &admissionregistrationv1. + ValidatingWebhookConfiguration{} +} + +func (i *ValidatingWebhookCaBundleInject) NewInformerAndListPatcher( + client kubernetes.Interface, + resyncPeriod time.Duration, + indexers cache.Indexers, + tweakListOptions internalinterfaces.TweakListOptionsFunc, +) (cache.SharedIndexInformer, ListPatcher) { + informer := admissionregistrationinformers.NewFilteredValidatingWebhookConfigurationInformer( + client, resyncPeriod, indexers, tweakListOptions, + ) + _ = informer.SetTransform(func(obj any) (any, error) { + vwc := obj.(*admissionregistrationv1.ValidatingWebhookConfiguration) + + // Only retain the fields we care about for CABundle injection + vwc.ObjectMeta = metav1.ObjectMeta{ + Name: vwc.Name, + Namespace: vwc.Namespace, + UID: vwc.UID, + ResourceVersion: vwc.ResourceVersion, + } + + for index, webhook := range vwc.Webhooks { + vwc.Webhooks[index] = admissionregistrationv1.ValidatingWebhook{ + Name: webhook.Name, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + CABundle: webhook.ClientConfig.CABundle, + }, + } + } -func (i *ValidatingWebhookCaBundleInject) GroupVersionKind() schema.GroupVersionKind { - return schema.GroupVersionKind{ - Group: "admissionregistration.k8s.io", - Version: "v1", - Kind: "ValidatingWebhookConfiguration", + return vwc, nil + }) + return informer, &ValidatingWebhookCaBundleInjectListPatcher{ + Client: client, + Lister: admissionregistrationlisters.NewValidatingWebhookConfigurationLister(informer.GetIndexer()), } } -func (i *ValidatingWebhookCaBundleInject) InjectCA(obj *unstructured.Unstructured, caBundle []byte) (runtime.ApplyConfiguration, error) { - // TODO: Can we generalize this function for any resource based on a JSON path? +type ValidatingWebhookCaBundleInjectListPatcher struct { + Client kubernetes.Interface + Lister admissionregistrationlisters.ValidatingWebhookConfigurationLister +} - ac := admissionregistrationv1ac.ValidatingWebhookConfiguration(obj.GetName()) +var _ ListPatcher = &ValidatingWebhookCaBundleInjectListPatcher{} - webhooks, _, err := unstructured.NestedSlice(obj.Object, "webhooks") +func (i *ValidatingWebhookCaBundleInjectListPatcher) ListObjects(caBundle []byte) (iter.Seq2[types.NamespacedName, IsUpToDate], error) { + vwcs, err := i.Lister.List(labels.Everything()) if err != nil { return nil, err } - for _, w := range webhooks { - name, _, err := unstructured.NestedString(w.(map[string]any), "name") - if err != nil { - return nil, err + + return func(yield func(types.NamespacedName, IsUpToDate) bool) { + for _, vwc := range vwcs { + isUpToDate := true + for i := range vwc.Webhooks { + if !bytes.Equal(vwc.Webhooks[i].ClientConfig.CABundle, caBundle) { + isUpToDate = false + break + } + } + + if !yield(types.NamespacedName{Name: vwc.Name}, IsUpToDate(isUpToDate)) { + return + } + } + }, nil +} + +func (i *ValidatingWebhookCaBundleInjectListPatcher) PatchObject( + ctx context.Context, key types.NamespacedName, caBundle []byte, + applyOptions metav1.ApplyOptions, +) (bool, error) { + vwc, err := i.Lister.Get(key.Name) + if err != nil { + return false, err + } + + // If the current object already contains the desired CABundle for all + // webhooks, there's no need to call Apply. + { + needsPatch := false + for idx := range vwc.Webhooks { + if !bytes.Equal(vwc.Webhooks[idx].ClientConfig.CABundle, caBundle) { + needsPatch = true + break + } + } + if !needsPatch { + return false, nil } + } + + ac := admissionregistrationv1ac. + ValidatingWebhookConfiguration(vwc.Name) + + for _, w := range vwc.Webhooks { ac.WithWebhooks( admissionregistrationv1ac.ValidatingWebhook(). - WithName(name). + WithName(w.Name). WithClientConfig(admissionregistrationv1ac.WebhookClientConfig(). WithCABundle(caBundle...), ), ) } - return ac, nil + _, err = i.Client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Apply(ctx, ac, applyOptions) + return true, err } diff --git a/pkg/authority/injectable_controller.go b/pkg/authority/injectable_controller.go deleted file mode 100644 index df36fc0..0000000 --- a/pkg/authority/injectable_controller.go +++ /dev/null @@ -1,117 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 authority - -import ( - "context" - "strings" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" - - "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/internal/ssa" -) - -// InjectableReconciler injects CA bundle into resources -type InjectableReconciler struct { - Reconciler - Injectable injectable.Injectable -} - -// SetupWithManager sets up the controllers with the Manager. -func (r *InjectableReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - Named(strings.ToLower(r.Injectable.GroupVersionKind().Kind)). - WatchesRawSource( - source.Kind( - r.Cache, - injectable.NewUnstructured(r.Injectable), - &handler.TypedEnqueueRequestForObject[*unstructured.Unstructured]{}, - predicate.NewTypedPredicateFuncs(func(obj *unstructured.Unstructured) bool { - return obj.GetLabels()[api.WantInjectFromSecretNamespaceLabel] == r.CAOptions.Namespace && - obj.GetLabels()[api.WantInjectFromSecretNameLabel] == r.CAOptions.Name - }), - ), - ). - WatchesRawSource( - r.caSecretSource( - handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, _ *corev1.Secret) []reconcile.Request { - objList := injectable.NewUnstructuredList(r.Injectable) - if err := r.Cache.List(ctx, objList, client.MatchingLabels(map[string]string{ - api.WantInjectFromSecretNamespaceLabel: r.CAOptions.Namespace, - api.WantInjectFromSecretNameLabel: r.CAOptions.Name, - })); err != nil { - log.FromContext(ctx).Error(err, "when listing injectables") - return nil - } - - requests := make([]reconcile.Request, len(objList.Items)) - for i, obj := range objList.Items { - req := reconcile.Request{} - req.Namespace = obj.GetNamespace() - req.Name = obj.GetName() - requests[i] = req - } - return requests - }), - ), - ). - Complete(r) -} - -func (r *InjectableReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) - - secret := &corev1.Secret{} - if err := r.Cache.Get(ctx, r.CAOptions.NamespacedName, secret); err != nil { - if errors.IsNotFound(err) { - log.FromContext(ctx).V(1).Info("CA secret not yet found, requeueing request...") - return ctrl.Result{Requeue: true}, nil - } - return ctrl.Result{}, err - } - - return ctrl.Result{}, r.reconcileInjectable(ctx, req, secret.Data[api.TLSCABundleKey]) -} - -func (r *InjectableReconciler) reconcileInjectable(ctx context.Context, req ctrl.Request, caBundle []byte) error { - obj := injectable.NewUnstructured(r.Injectable) - if err := r.Cache.Get(ctx, req.NamespacedName, obj); err != nil { - return err - } - - ac, err := r.Injectable.InjectCA(obj, caBundle) - if err != nil { - return err - } - - if err := r.Patcher.Patch(ctx, obj, ssa.NewApplyPatch(ac), client.ForceOwnership, ssa.FieldOwner); err != nil { - return err - } - - return nil -} diff --git a/pkg/authority/internal/autodetect/incluster.go b/pkg/authority/internal/autodetect/incluster.go new file mode 100644 index 0000000..59c7480 --- /dev/null +++ b/pkg/authority/internal/autodetect/incluster.go @@ -0,0 +1,47 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 autodetect + +import ( + "k8s.io/apimachinery/pkg/types" +) + +func DetectInClusterSettings() (InClusterSettings, error) { + detectedNamespace, err := inClusterDetectNamespace() + if err != nil { + return InClusterSettings{}, err + } + + return InClusterSettings{ + Namespace: detectedNamespace, + }, nil +} + +type InClusterSettings struct { + Namespace string +} + +func (setting InClusterSettings) SecretNamespacedName(secretName string) types.NamespacedName { + return types.NamespacedName{ + Namespace: setting.Namespace, + Name: secretName, + } +} + +func (setting InClusterSettings) ServiceDNSName(serviceName string) string { + return serviceName + "." + setting.Namespace + ".svc" +} diff --git a/pkg/authority/internal/autodetect/namespace.go b/pkg/authority/internal/autodetect/namespace.go new file mode 100644 index 0000000..7a1d823 --- /dev/null +++ b/pkg/authority/internal/autodetect/namespace.go @@ -0,0 +1,40 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 autodetect + +import ( + "fmt" + "os" + "strings" +) + +// based on https://github.com/kubernetes/client-go/blob/65de5216f10c2cb18014377e6cffdfcb03f849ce/tools/clientcmd/client_config.go#L646 + +const ( + saNamespaceFilePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" +) + +func inClusterDetectNamespace() (string, error) { + // Fall back to the namespace associated with the service account token, if available + if data, err := os.ReadFile(saNamespaceFilePath); err == nil { + if ns := strings.TrimSpace(string(data)); len(ns) > 0 { + return ns, nil + } + } + + return "", fmt.Errorf("file %q not found, we might not be running inside a cluster", saNamespaceFilePath) +} diff --git a/pkg/authority/internal/queuefix/queue_fix.go b/pkg/authority/internal/queuefix/queue_fix.go new file mode 100644 index 0000000..3bf3d7d --- /dev/null +++ b/pkg/authority/internal/queuefix/queue_fix.go @@ -0,0 +1,43 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 queuefix + +import ( + "time" + + "k8s.io/client-go/util/workqueue" +) + +// FIX: queue that clears timed reconcile if .Add is called +// +// see https://github.com/kubernetes/kubernetes/issues/126027 +type cleanQueue[T comparable] struct { + workqueue.TypedRateLimitingInterface[T] +} + +func FixQueue[T comparable](q workqueue.TypedRateLimitingInterface[T]) workqueue.TypedRateLimitingInterface[T] { + return cleanQueue[T]{TypedRateLimitingInterface: q} +} + +func (q cleanQueue[T]) AddAfter(item T, duration time.Duration) { + duration = max(1*time.Millisecond, duration) + q.TypedRateLimitingInterface.AddAfter(item, duration) +} + +func (q cleanQueue[T]) Add(item T) { + q.TypedRateLimitingInterface.AddAfter(item, 1*time.Millisecond) +} diff --git a/pkg/authority/internal/queuefix/queue_fix_test.go b/pkg/authority/internal/queuefix/queue_fix_test.go new file mode 100644 index 0000000..3b51f91 --- /dev/null +++ b/pkg/authority/internal/queuefix/queue_fix_test.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 queuefix + +import ( + "testing" + "testing/synctest" + "time" + + "k8s.io/client-go/util/workqueue" + testingclock "k8s.io/utils/clock/testing" +) + +func Test_cleanqueue(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + fakeClock := testingclock.NewFakeClock(time.Now()) + upstreamQueue := workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[string](), + workqueue.TypedRateLimitingQueueConfig[string]{ + Clock: fakeClock, + }, + ) + // if you use the upstream queue directly, this test fails + q := cleanQueue[string]{TypedRateLimitingInterface: upstreamQueue} + defer q.ShutDown() + + first := "foo" + + q.AddAfter(first, 0*time.Millisecond) + q.AddAfter(first, 50*time.Millisecond) + + synctest.Wait() + + // step past the first block, we should receive now + fakeClock.Step(10 * time.Millisecond) + + synctest.Wait() + + if q.Len() != 1 { + t.Error("should have added") + } + item, _ := q.Get() + q.Done(item) + + // step past the second add + fakeClock.Step(50 * time.Millisecond) + + synctest.Wait() + + if q.Len() != 0 { + t.Errorf("should not have added") + } + }) +} diff --git a/pkg/authority/internal/ssa/client.go b/pkg/authority/internal/ssa/client.go deleted file mode 100644 index 2f4ac8f..0000000 --- a/pkg/authority/internal/ssa/client.go +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 ssa - -import ( - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/json" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/cert-manager/webhook-cert-lib/pkg/runtime" -) - -const ( - FieldOwner = client.FieldOwner("cert-manager-dynamic-authority") -) - -func NewApplyPatch(ac runtime.ApplyConfiguration) ApplyPatch { - return ApplyPatch{ac: ac} -} - -type ApplyPatch struct { - ac runtime.ApplyConfiguration -} - -func (p ApplyPatch) Type() types.PatchType { - return types.ApplyPatchType -} - -func (p ApplyPatch) Data(_ client.Object) ([]byte, error) { - return json.Marshal(p.ac) -} diff --git a/pkg/authority/leaf_cert_controller.go b/pkg/authority/leaf_cert_controller.go deleted file mode 100644 index 50c2258..0000000 --- a/pkg/authority/leaf_cert_controller.go +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 authority - -import ( - "context" - "crypto" - "crypto/x509" - "time" - - corev1 "k8s.io/api/core/v1" - "k8s.io/utils/ptr" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/handler" - - "github.com/cert-manager/webhook-cert-lib/internal/certificate" - "github.com/cert-manager/webhook-cert-lib/internal/pki" -) - -// LeafCertReconciler reconciles the leaf/serving certificate -type LeafCertReconciler struct { - Reconciler - CertificateHolder *certificate.Holder - Options Options -} - -// SetupWithManager sets up the controller with the Manager. -func (r *LeafCertReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - Named("cert_leaf"). - WatchesRawSource(r.caSecretSource(&handler.TypedEnqueueRequestForObject[*corev1.Secret]{})). - // Disable leader election since all replicas need a serving certificate - WithOptions(controller.TypedOptions[ctrl.Request]{NeedLeaderElection: ptr.To(false)}). - Complete(r) -} - -func (r *LeafCertReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - caSecret := &corev1.Secret{} - if err := r.Cache.Get(ctx, req.NamespacedName, caSecret); err != nil { - return ctrl.Result{}, err - } - - cert, pk, err := r.generateCertificate(caSecret) - if err != nil { - return ctrl.Result{}, err - } - tlsCert, err := pki.ToTLSCertificate(cert, pk) - if err != nil { - return ctrl.Result{}, err - } - r.CertificateHolder.SetCertificate(&tlsCert) - - return ctrl.Result{RequeueAfter: time.Until(certificate.RenewTriggerWindow(cert).Random())}, nil -} - -func (r *LeafCertReconciler) generateCertificate(caSecret *corev1.Secret) (cert *x509.Certificate, pk crypto.Signer, err error) { - caCert, err := pki.DecodeCertificateFromPEM(caSecret.Data[corev1.TLSCertKey]) - if err != nil { - return cert, pk, err - } - caPk, err := pki.DecodePrivateKeyBytes(caSecret.Data[corev1.TLSPrivateKeyKey]) - if err != nil { - return cert, pk, err - } - - cert, pk, err = certificate.GenerateLeaf( - r.LeafOptions.DNSNames, - r.LeafOptions.Duration, - caCert, caPk, - ) - if err != nil { - return cert, pk, err - } - return cert, pk, err -} diff --git a/pkg/authority/options.go b/pkg/authority/options.go index 950d044..9cf1197 100644 --- a/pkg/authority/options.go +++ b/pkg/authority/options.go @@ -17,34 +17,174 @@ limitations under the License. package authority import ( + "fmt" "time" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/internal/autodetect" ) -type Options struct { - CAOptions CAOptions - LeafOptions LeafOptions +func collisionAvoidanceDelay(isIssuer bool) time.Duration { + if isIssuer { + // the controller that issued the pending CA can instantly + // reconcile the targets and promote the CA. We give it a leeway + // of 3 seconds before letting other non-issuer controllers perform + // the same work, to avoid sending too many requests to the apiserver. + return 0 + } - Injectables []injectable.Injectable + // non-issuers wait 3 seconds plus a random delay up to 3 seconds + // to avoid collision when multiple controllers are running + // and trying to reconcile the same targets at the same time. + // this helps to spread out the load on the apiserver. + // This should only happen when the issuer controller is down or + // unable to perform the reconciliation. + return wait.Jitter(time.Second*3, 1.0) } -type CAOptions struct { +type AuthorityOptions struct { + // AuthorityCertificate contains options for the CA certificate + AuthorityCertificate AuthorityCertificateOptions + + // Targets contains options for the targets to inject the trusted + // CA certificates into. + Targets TargetsOptions + + // PromotionDelay is the amount of time to wait after all targets have + // been reconciled before promoting the pending CA to be the serving CA. + // + // This delay is necessary to ensure that all webhook configurations + // using the CA have had time to observe the new CA certificate before + // it is promoted to be the serving CA. + // + // If this value is set too low, you might see a "certificate signed by unknown authority" error when using your webhook: + // error: failed to create configmap: Internal error occurred: failed calling + // webhook "example-webhook.k8s.io": failed to call webhook: Post "https://example-webhook-example-webhook.my-namespace.svc:443/validate?timeout=10s": + // tls: failed to verify certificate: x509: certificate signed by unknown authority + // (possibly because of "x509: ECDSA verification failure" while trying to verify + // candidate authority certificate "cert-manager-dynamic-ca") + // + // Defaults to 2s. + PromotionDelay time.Duration + + // ServerCertificate contains options for the webhook server leaf certificate + ServerCertificate ServerCertificateOptions +} + +type AuthorityCertificateOptions struct { // The namespaced name of the Secret used to store CA certificates. - types.NamespacedName + SecretNamespacedName types.NamespacedName // The amount of time the root CA certificate will be valid for. - // This must be greater than LeafDuration. + // This must be greater than or equal to LeafDuration. + // Defaults to 1 hour. Duration time.Duration } -type LeafOptions struct { +type TargetsOptions struct { + // InjectableKinds is a list of injectable.InjectableKind implementations + // that will be used to inject the CA certificate into target resources. + // + // Defaults to [ValidatingWebhookCaBundleInject]. + SupportedKinds []injectable.InjectableKind + + // Objects is a list of target objects to inject the CA certificate into. + // All these targets need to exist and have been patched with the trust bundle + // before the CA can be promoted to be the serving CA. + Objects []TargetObject +} + +type ServerCertificateOptions struct { + // The DNS names to be included in the webhook server certificate. DNSNames []string - // The amount of time leaf certificates signed by this authority will be + // The amount of time server certificates signed by this authority will be // valid for. - // This must be less than CADuration. + // This must be: + // - at least 60 seconds + // - at least 10 times the PromotionDelay + // + // Defaults to 1 hour. Duration time.Duration } + +func (opts *AuthorityOptions) ApplyDefaults() { + if opts.AuthorityCertificate.Duration == 0 { + opts.AuthorityCertificate.Duration = 1 * time.Hour + } + if opts.ServerCertificate.Duration == 0 { + opts.ServerCertificate.Duration = 1 * time.Hour + } + if opts.PromotionDelay == 0 { + opts.PromotionDelay = 2 * time.Second + } + if len(opts.Targets.SupportedKinds) == 0 { + opts.Targets.SupportedKinds = []injectable.InjectableKind{ + &injectable.ValidatingWebhookCaBundleInject{}, + } + } +} + +func (opts *AuthorityOptions) Validate() error { + if opts.AuthorityCertificate.Duration <= 0 { + return fmt.Errorf("CA.Duration must be greater than zero") + } + if opts.ServerCertificate.Duration <= 0 { + return fmt.Errorf("WebServer.Duration must be greater than zero") + } + if opts.PromotionDelay < 0 { + return fmt.Errorf("PromotionDelay must be greater than or equal to zero") + } + if len(opts.Targets.Objects) == 0 { + return fmt.Errorf("at least one target object must be specified in Targets.Objects") + } + + supportedGroupKinds := make(map[schema.GroupKind]struct{}) + for _, kind := range opts.Targets.SupportedKinds { + supportedGroupKinds[kind.GroupVersionKind().GroupKind()] = struct{}{} + } + for _, obj := range opts.Targets.Objects { + if _, ok := supportedGroupKinds[obj.GroupKind]; !ok { + return fmt.Errorf("target object %s has unsupported GroupKind %s", obj.String(), obj.GroupKind.String()) + } + } + + // since the validity of the leaf certificate is capped by the CA certificate, + // ensure that the CA duration is larger than the leaf duration + if opts.AuthorityCertificate.Duration < opts.ServerCertificate.Duration { + return fmt.Errorf("CA.Duration (%s) must be greater than WebServer.Duration (%s)", opts.AuthorityCertificate.Duration, opts.ServerCertificate.Duration) + } + + // the CA certificate will be renewed between 6/10 and 7/10 of its lifetime, so + // worst case we have left 3/10 of its lifetime to propagate the new CA to all targets + // and promote it to serving before the CA certificate expires. For that reason, + // we limit the promotion delay to be at most 1/10 of the CA certificate lifetime + // to leave some time (2/10) to inject the trust bundle into all targets. + // + // 6/10 7/10 8/10 9/10 10/10 + // ------[==================X==]----------------------------X.....................X---------------| + // New CA Injected Promoted + // + // <- trigger renewal -> <- promotion delay -> + // <- time to inject -> max 1/10 lifetime + // new CA into all targets + // + if opts.AuthorityCertificate.Duration < 10*opts.PromotionDelay { + return fmt.Errorf("CA.Duration (%s) must be greater than 10 * PromotionDelay (%s)", opts.AuthorityCertificate.Duration, 10*opts.PromotionDelay) + } + + // ensure that the CA certificate is valid for at least 60 seconds. worst case, + // this leaves us with a renewal window of 6 seconds (6/10 to 7/10) and 12 seconds + // to inject the CA into all targets. With a promotion delay of maximum 6 seconds. + if opts.AuthorityCertificate.Duration < 60*time.Second { + return fmt.Errorf("CA.Duration (%s) must be greater than or equal to 60 seconds", opts.AuthorityCertificate.Duration) + } + + return nil +} + +var DetectInClusterSettings = autodetect.DetectInClusterSettings diff --git a/pkg/authority/reconciler.go b/pkg/authority/reconciler.go deleted file mode 100644 index 2aaa5b0..0000000 --- a/pkg/authority/reconciler.go +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 authority - -import ( - "context" - - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" -) - -type Reconciler struct { - Patcher Patcher - Cache cache.Cache - Options -} - -type Patcher interface { - // Patch patches the given obj in the Kubernetes cluster. obj must be a - // struct pointer so that obj can be updated with the content returned by the Server. - Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error -} - -func (r Reconciler) caSecretSource(handler handler.TypedEventHandler[*corev1.Secret, reconcile.Request]) source.SyncingSource { - return source.Kind( - r.Cache, - &corev1.Secret{}, - handler, - predicate.NewTypedPredicateFuncs[*corev1.Secret](func(obj *corev1.Secret) bool { - return obj.Namespace == r.CAOptions.Namespace && obj.Name == r.CAOptions.Name - })) -} diff --git a/pkg/authority/target_object.go b/pkg/authority/target_object.go new file mode 100644 index 0000000..73acac8 --- /dev/null +++ b/pkg/authority/target_object.go @@ -0,0 +1,69 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 authority + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +type TargetObject struct { + // The GroupKind of the target object. + GroupKind schema.GroupKind + + // The NamespacedName of the target object. + // Note: for cluster-scoped resources, the Namespace field should be empty. + NamespacedName types.NamespacedName +} + +func (to TargetObject) String() string { + var builder strings.Builder + _, _ = builder.WriteString(to.GroupKind.Group) + _, _ = builder.WriteRune('/') + _, _ = builder.WriteString(to.GroupKind.Kind) + _, _ = builder.WriteRune('/') + if to.NamespacedName.Namespace != "" { + _, _ = builder.WriteString(to.NamespacedName.Namespace) + _, _ = builder.WriteRune('/') + } + _, _ = builder.WriteString(to.NamespacedName.Name) + return builder.String() +} + +func TargetObjectFromString(s string) (TargetObject, error) { + parts := strings.SplitN(s, "/", 5) + if len(parts) < 3 || len(parts) > 4 { + return TargetObject{}, fmt.Errorf("invalid target object string: %s", s) + } + + var to TargetObject + to.GroupKind = schema.GroupKind{Group: parts[0], Kind: parts[1]} + if len(parts) == 4 { + to.NamespacedName = types.NamespacedName{ + Namespace: parts[2], + Name: parts[3], + } + } else { + to.NamespacedName = types.NamespacedName{ + Name: parts[2], + } + } + return to, nil +} diff --git a/pkg/authority/target_object_test.go b/pkg/authority/target_object_test.go new file mode 100644 index 0000000..6f2fb0d --- /dev/null +++ b/pkg/authority/target_object_test.go @@ -0,0 +1,72 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 authority + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +func TestTargetObject_String(t *testing.T) { + tests := []struct { + name string + obj TargetObject + str string + }{ + { + name: "cluster-scoped-like", + obj: TargetObject{ + GroupKind: schema.GroupKind{Group: "admissionregistration.k8s.io", Kind: "ValidatingWebhookConfiguration"}, + NamespacedName: types.NamespacedName{ + Name: "my-validating-webhook", + }, + }, + str: `admissionregistration.k8s.io/ValidatingWebhookConfiguration/my-validating-webhook`, + }, + { + name: "namespaced", + obj: TargetObject{ + GroupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "my-deployment", + }, + }, + str: "apps/Deployment/default/my-deployment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.obj.String(); got != tc.str { + t.Fatalf("String(): expected %s, got %s", tc.str, got) + } + + // round-trip parse + parsed, err := TargetObjectFromString(tc.str) + if err != nil { + t.Fatalf("TargetObjectFromString(%q) unexpected error: %v", tc.str, err) + } + if parsed != tc.obj { + t.Fatalf("parsed mismatch: expected %+v, got %+v", tc.obj, parsed) + } + }) + } +} diff --git a/pkg/runtime/types.go b/pkg/runtime/types.go deleted file mode 100644 index 6d9dfbb..0000000 --- a/pkg/runtime/types.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 runtime - -// ApplyConfiguration is a temporary substitute for k8s.io/apimachinery/pkg/runtime/ApplyConfiguration -// that will arrive with the release of Kubernetes 1.34 Go APIs. -type ApplyConfiguration interface { - GetName() *string -} diff --git a/test/ca_secret_controller_test.go b/test/ca_secret_controller_test.go deleted file mode 100644 index 6a871ee..0000000 --- a/test/ca_secret_controller_test.go +++ /dev/null @@ -1,143 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 test - -import ( - "time" - - "github.com/cert-manager/webhook-cert-lib/internal/pki" - "github.com/cert-manager/webhook-cert-lib/pkg/authority" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("CA Secret Controller", Ordered, func() { - var ( - caSecret *corev1.Secret - caSecretRef types.NamespacedName - ) - - BeforeAll(func() { - ns := &corev1.Namespace{} - ns.Name = "cert-ca-secret-controller" - Expect(k8sClient.Create(ctx, ns)).To(Succeed()) - - caSecretRef = types.NamespacedName{ - Namespace: ns.Name, - Name: "ca-cert", - } - - opts := authority.Options{ - CAOptions: authority.CAOptions{ - NamespacedName: caSecretRef, - Duration: 7 * time.Hour, - }} - - k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - Metrics: metricsserver.Options{ - BindAddress: "0", - }, - }) - Expect(err).ToNot(HaveOccurred()) - - controller := &authority.CASecretReconciler{ - Reconciler: authority.Reconciler{ - Patcher: k8sManager.GetClient(), - Cache: k8sManager.GetCache(), - Options: opts, - }} - Expect(controller.SetupWithManager(k8sManager)).To(Succeed()) - - go func() { - defer GinkgoRecover() - err = k8sManager.Start(ctx) - Expect(err).ToNot(HaveOccurred(), "failed to run manager") - }() - }) - - BeforeEach(func() { - caSecret = &corev1.Secret{} - caSecret.Namespace = caSecretRef.Namespace - caSecret.Name = caSecretRef.Name - }) - - It("should create Secret on startup", func() { - assertCASecret(caSecret) - - By("checking for reconcile loops") - resourceVersion := caSecret.ResourceVersion - Consistently(komega.Object(caSecret)).Should( - HaveField("ResourceVersion", Equal(resourceVersion)), - ) - }) - - It("should recreate Secret if it's deleted", func() { - Expect(k8sClient.Delete(ctx, caSecret)).To(Succeed()) - assertCASecret(caSecret) - }) - - It("should issue certificate if Secret is modified", func() { - caSecret.Type = corev1.SecretTypeTLS - caSecret.Data = map[string][]byte{ - corev1.TLSCertKey: []byte("foo"), - corev1.TLSPrivateKeyKey: []byte("bar"), - } - Expect(k8sClient.Update(ctx, caSecret)).To(Succeed()) - assertCASecret(caSecret) - }) - - It("should retain old CA if CA is rotated", func() { - assertCASecret(caSecret) - - caBundleCerts, err := pki.DecodeAllCertificatesFromPEM(caSecret.Data[api.TLSCABundleKey]) - Expect(err).ToNot(HaveOccurred()) - Expect(caBundleCerts).To(HaveLen(1)) - - certBytes := caSecret.Data[corev1.TLSCertKey] - - Consistently(komega.Object(caSecret)).Should( - HaveField("Data", HaveKeyWithValue(corev1.TLSCertKey, Equal(certBytes))), - ) - - By("requesting a renewal") - caSecret.Annotations = map[string]string{api.RenewCertificateSecretAnnotation: time.Now().String()} - Expect(k8sClient.Update(ctx, caSecret)).To(Succeed()) - - Eventually(komega.Object(caSecret)).Should( - HaveField("Data", HaveKeyWithValue(corev1.TLSCertKey, Not(Equal(certBytes)))), - ) - assertCASecret(caSecret) - - certBytes = caSecret.Data[corev1.TLSCertKey] - Consistently(komega.Object(caSecret)).Should( - HaveField("Data", HaveKeyWithValue(corev1.TLSCertKey, Equal(certBytes))), - ) - - caBundleCerts, err = pki.DecodeAllCertificatesFromPEM(caSecret.Data[api.TLSCABundleKey]) - Expect(err).ToNot(HaveOccurred()) - Expect(caBundleCerts).To(HaveLen(2)) - }) -}) diff --git a/test/combined_controller_test.go b/test/combined_controller_test.go new file mode 100644 index 0000000..1f2b166 --- /dev/null +++ b/test/combined_controller_test.go @@ -0,0 +1,319 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 test + +import ( + "context" + "crypto/tls" + "fmt" + "reflect" + "testing" + "testing/synctest" + "time" + "unsafe" + + internalmetrics "github.com/cert-manager/webhook-cert-lib/internal/metrics" + "github.com/cert-manager/webhook-cert-lib/pkg/authority" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" + "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" + "github.com/go-logr/logr" + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + k8sfake "k8s.io/client-go/kubernetes/fake" +) + +type runOptions struct { + ca *caTester + clientset *k8sfake.Clientset + auth *authority.Authority + opts authority.AuthorityOptions +} + +func TestControllers(t *testing.T) { + t.Parallel() + + const ( + nrAuthorityControllers = 50 + caDuration = 7 * time.Hour + serverCertDuration = 1 * time.Hour + promotionDelay = 5 * time.Second + maxStartupDelay = 5 * time.Second + maxWatchDelay = 5 * time.Second + + // wait long enough to have observed the first serving certificate issuance + minServingCertWait = maxStartupDelay + promotionDelay + 10*maxWatchDelay + 10*time.Millisecond + ) + + type controllerCase struct { + name string + expReconciles int64 + run func(t *testing.T, run runOptions) + } + + cases := []controllerCase{ + { + name: "creates CA secret on startup", + expReconciles: 21, + run: func(t *testing.T, run runOptions) { + _ = run.ca.getPending(t) + }, + }, + { + name: "keeps resourceVersion stable", + expReconciles: 21, + run: func(t *testing.T, run runOptions) { + s1 := run.ca.getReady(t) + time.Sleep(minServingCertWait) + s2 := run.ca.getReady(t) + + require.Equal(t, s1.ResourceVersion, s2.ResourceVersion) + }, + }, + { + name: "recreates CA secret after delete", + expReconciles: 43, + run: func(t *testing.T, run runOptions) { + run.ca.delete(t) + time.Sleep(minServingCertWait) + _ = run.ca.getReady(t) + }, + }, + { + name: "repairs modified CA secret", + expReconciles: 49, + run: func(t *testing.T, run runOptions) { + caSecret := run.ca.getReady(t) + caSecret.Type = corev1.SecretTypeTLS + caSecret.Data = map[string][]byte{api.TLSServingCertKey: []byte("foo"), api.TLSServingPrivateKeyKey: []byte("bar")} + + run.ca.put(t, caSecret) + time.Sleep(minServingCertWait) + _ = run.ca.getReady(t) + }, + }, + { + name: "renews CA and rolls old keys", + expReconciles: 49, + run: func(t *testing.T, run runOptions) { + s1 := run.ca.getReady(t) + time.Sleep(6 * time.Hour) + s2 := run.ca.getReady(t) + + require.NotEqual(t, string(s1.Data[api.TLSServingCertKey]), string(s2.Data[api.TLSServingCertKey])) + require.NotEqual(t, string(s1.Data[api.TLSServingPrivateKeyKey]), string(s2.Data[api.TLSServingPrivateKeyKey])) + }, + }, + { + name: "produces a serving certificate", + expReconciles: 23, + run: func(t *testing.T, run runOptions) { + sc := &tls.Config{} // #nosec G402 -- for testing only + run.auth.ServingCertificate(sc) + cert, err := sc.GetCertificate(nil) + require.NoError(t, err) + require.NotNil(t, cert) + require.Greater(t, len(cert.Certificate), 0) + }, + }, + { + name: "injects CA bundle into ValidatingWebhookConfigurations", + expReconciles: 45, + run: func(t *testing.T, run runOptions) { + vwcs := []string{} + for i := range 10 { + name := fmt.Sprintf("vwc-%d", i) + vwcs = append(vwcs, name) + } + + for _, name := range vwcs { + vwc := newValidatingWebhookConfigurationForTest(name, run.opts.AuthorityCertificate.SecretNamespacedName) + _, err := run.clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(t.Context(), vwc, metav1.CreateOptions{}) + require.NoError(t, err) + } + + time.Sleep(minServingCertWait) + + synctest.Wait() + for _, name := range vwcs { + assertVWCInjectMatchesSecret(t, run.clientset, name, run.opts.AuthorityCertificate.SecretNamespacedName) + } + + secret := run.ca.getReady(t) + secret.Data[api.TLSServingCertKey] = []byte("updated CA bundle") + run.ca.put(t, secret) + + synctest.Wait() + for _, name := range vwcs { + assertVWCInjectMatchesSecret(t, run.clientset, name, run.opts.AuthorityCertificate.SecretNamespacedName) + } + }, + }, + { + name: "waits for promotion delay before rotating leaf", + expReconciles: 23, + run: func(t *testing.T, run runOptions) { + // Capture current serving certificate + sc := &tls.Config{} // #nosec G402 -- for testing only + run.auth.ServingCertificate(sc) + first, err := sc.GetCertificate(nil) + require.NoError(t, err) + + secret := run.ca.getReady(t) + delete(secret.Data, api.TLSPendingPrivateKeyKey) + secret.Data[api.TLSServingCertKey] = append(secret.Data[api.TLSServingCertKey], []byte("-mutated")...) + run.ca.put(t, secret) + + // Immediately after reinjection, leaf should still be the same (PropagationDelay not elapsed) + immediate, err := sc.GetCertificate(nil) + require.NoError(t, err) + require.Equal(t, first.Leaf.Raw, immediate.Leaf.Raw) + + // After promotion delay, leaf should rotate + time.Sleep(minServingCertWait) + rotated, err := sc.GetCertificate(nil) + require.NoError(t, err) + require.NotEqual(t, first.Leaf.Raw, rotated.Leaf.Raw) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cs := k8sfake.NewClientset() + delayClientWatch(cs, maxWatchDelay) + + const testTarget = "test-webhook-configuration" + + opts := authority.AuthorityOptions{ + AuthorityCertificate: authority.AuthorityCertificateOptions{ + SecretNamespacedName: types.NamespacedName{ + Namespace: "cert-ca-secret-controller", + Name: "ca-cert", + }, + Duration: caDuration, + }, + Targets: authority.TargetsOptions{ + Objects: []authority.TargetObject{ + { + GroupKind: (injectable.ValidatingWebhookCaBundleInject{}). + GroupVersionKind(). + GroupKind(), + NamespacedName: types.NamespacedName{ + Name: testTarget, + }, + }, + }, + }, + PromotionDelay: promotionDelay, + ServerCertificate: authority.ServerCertificateOptions{ + Duration: serverCertDuration, + }, + } + + _, err := cs.CoreV1().Namespaces().Create( + t.Context(), + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.AuthorityCertificate.SecretNamespacedName.Namespace, + }, + }, + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + _, err = cs.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create( + t.Context(), + newValidatingWebhookConfigurationForTest(testTarget, opts.AuthorityCertificate.SecretNamespacedName), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + synctest.Wait() + + var auths []*authority.Authority + for range nrAuthorityControllers { + auth, err := authority.NewAuthorityForClient(cs, opts) + require.NoError(t, err) + auths = append(auths, auth) + } + + run := runOptions{ + ca: &caTester{ + NamespacedName: opts.AuthorityCertificate.SecretNamespacedName, + clientset: cs, + }, + clientset: cs, + auth: auths[0], // use first replica for GetCertificate and metrics + opts: opts, + } + + ctx, cancel := context.WithCancel(t.Context()) + group, gctx := errgroup.WithContext(ctx) + for i, auth := range auths { + group.Go(func() error { + time.Sleep(randomDelay(maxStartupDelay)) + authCtx := logr.NewContext( + gctx, + testr. + NewWithOptions(t, testr.Options{ + LogTimestamp: true, + }). + WithName(fmt.Sprintf("authority-controller-%d", i)), + ) + return auth.Start(authCtx) + }) + } + defer func() { + cancel() + err := group.Wait() + assert.NoError(t, err) + }() + + time.Sleep(minServingCertWait) + synctest.Wait() + + before := getMetricsReport(t, run.auth) + if tc.run != nil { + tc.run(t, run) + } + after := getMetricsReport(t, run.auth) + + nrPatches := after.TotalPatches - before.TotalPatches + require.LessOrEqual(t, nrPatches, int64(30), "reconcile delta too high: %d", nrPatches) + }) + }) + } +} + +// HACK: use reflection to access unexported metrics field +func getMetricsReport(t *testing.T, auth *authority.Authority) internalmetrics.InternalMetricsReport { + t.Helper() + + v := reflect.ValueOf(auth).Elem() + metricsvalue := v.FieldByName("metrics") + metricsvalue = reflect.NewAt(metricsvalue.Type(), unsafe.Pointer(metricsvalue.UnsafeAddr())).Elem() + metrics := metricsvalue.Addr().Interface().(*internalmetrics.InternalMetrics) + return metrics.PatchCounts() +} diff --git a/test/delayed_watch.go b/test/delayed_watch.go new file mode 100644 index 0000000..50efc94 --- /dev/null +++ b/test/delayed_watch.go @@ -0,0 +1,147 @@ +/* +Copyright 2026 The cert-manager 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 + + 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 test + +import ( + "fmt" + "math/rand/v2" + "sync" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + k8sfake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/testing" +) + +var ( + DefaultChanSize int = 100 +) + +func delayClientWatch(cs *k8sfake.Clientset, maxDelay time.Duration) { + cs.PrependWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + var opts metav1.ListOptions + if watchAction, ok := action.(testing.WatchActionImpl); ok { + opts = watchAction.ListOptions + } + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := cs.Tracker().Watch(gvr, ns, opts) + if err != nil { + return false, nil, err + } + return true, delayedWatch(watch, maxDelay), nil + }) +} + +func delayedWatch(watcher watch.Interface, maxDelay time.Duration) watch.Interface { + dw := &delayedWatcher{ + watch: watcher, + eventQueue: make(chan eventWithTime, 10*DefaultChanSize), + result: make(chan watch.Event, DefaultChanSize), + maxDelay: maxDelay, + stopped: make(chan struct{}), + } + + dw.run() + + return dw +} + +type delayedWatcher struct { + watch watch.Interface + eventQueue chan eventWithTime + result chan watch.Event + maxDelay time.Duration + + sync.Mutex + stopped chan struct{} +} + +type eventWithTime struct { + event watch.Event + timestamp time.Time +} + +var _ watch.Interface = &delayedWatcher{} + +func randomDelay(maxDelay time.Duration) time.Duration { + return time.Duration(float64(maxDelay) * rand.Float64()) // #nosec G404 +} + +func (f *delayedWatcher) run() { + go func() { + for event := range f.watch.ResultChan() { + f.eventQueue <- eventWithTime{ + event: event, + timestamp: time.Now(), + } + } + close(f.eventQueue) + }() + + go func() { + for event := range f.eventQueue { + arrivalTime := event.timestamp.Add(randomDelay(f.maxDelay)) + select { + case <-time.After(time.Until(arrivalTime)): + case <-f.stopped: + return + } + f.trigger(event.event) + } + }() +} + +func (f *delayedWatcher) Stop() { + f.Lock() + defer f.Unlock() + f.watch.Stop() + + select { + case <-f.stopped: + // already stopped + default: + close(f.result) + close(f.stopped) + } +} + +func (f *delayedWatcher) ResultChan() <-chan watch.Event { + f.Lock() + defer f.Unlock() + + return f.result +} + +func (f *delayedWatcher) trigger(event watch.Event) { + f.Lock() + defer f.Unlock() + + select { + case <-f.stopped: + return + default: + } + + select { + case f.result <- event: + return + default: + panic(fmt.Errorf("channel full")) + } +} diff --git a/test/go.mod b/test/go.mod index 2452e6a..6115172 100644 --- a/test/go.mod +++ b/test/go.mod @@ -6,32 +6,22 @@ replace github.com/cert-manager/webhook-cert-lib => ../ require ( github.com/cert-manager/webhook-cert-lib v0.0.0-00010101000000-000000000000 - github.com/onsi/ginkgo/v2 v2.28.1 - github.com/onsi/gomega v1.39.1 + github.com/go-logr/logr v1.4.3 + github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.18.0 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 - sigs.k8s.io/controller-runtime v0.22.4 ) require ( - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch/v5 v5.9.11 // 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/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect @@ -42,32 +32,22 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/ginkgo/v2 v2.27.5 // indirect + github.com/onsi/gomega v1.39.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.41.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/test/go.sum b/test/go.sum index 87b9dbd..f929d9b 100644 --- a/test/go.sum +++ b/test/go.sum @@ -1,33 +1,15 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= -github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -36,45 +18,25 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= -github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= -github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -83,22 +45,12 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= +github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -109,73 +61,32 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -189,8 +100,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= @@ -201,8 +110,6 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= -sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/test/injectable_controller_test.go b/test/injectable_controller_test.go deleted file mode 100644 index 62f0122..0000000 --- a/test/injectable_controller_test.go +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 test - -import ( - "github.com/cert-manager/webhook-cert-lib/pkg/authority" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/injectable" - admissionregistrationv1 "k8s.io/api/admissionregistration/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Injectable Controller", Ordered, func() { - var ( - caSecret *corev1.Secret - caSecretRef types.NamespacedName - ) - - BeforeAll(func() { - ns := &corev1.Namespace{} - ns.Name = "injectable-controller" - Expect(k8sClient.Create(ctx, ns)).To(Succeed()) - - caSecret = &corev1.Secret{} - caSecret.Namespace = ns.Name - caSecret.Name = "ca-cert" - caSecret.Type = corev1.SecretTypeTLS - caSecret.Labels = map[string]string{ - api.DynamicAuthoritySecretLabel: "true", - } - caSecret.Data = map[string][]byte{ - corev1.TLSCertKey: []byte("CA cert injectable"), - corev1.TLSPrivateKeyKey: []byte("CA cert key injectable"), - api.TLSCABundleKey: []byte("CA bundle injectable"), - } - Expect(k8sClient.Create(ctx, caSecret)).To(Succeed()) - caSecretRef = client.ObjectKeyFromObject(caSecret) - - opts := authority.Options{ - CAOptions: authority.CAOptions{ - NamespacedName: caSecretRef, - }} - - k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - Metrics: metricsserver.Options{ - BindAddress: "0", - }, - }) - Expect(err).ToNot(HaveOccurred()) - - controller := &authority.InjectableReconciler{ - Reconciler: authority.Reconciler{ - Patcher: k8sManager.GetClient(), - Cache: k8sManager.GetCache(), - Options: opts, - }, - Injectable: &injectable.ValidatingWebhookCaBundleInject{}, - } - Expect(controller.SetupWithManager(k8sManager)).To(Succeed()) - - go func() { - defer GinkgoRecover() - err = k8sManager.Start(ctx) - Expect(err).ToNot(HaveOccurred(), "failed to run manager") - }() - }) - - Context("ValidatingWebhookConfiguration", func() { - var vwc *admissionregistrationv1.ValidatingWebhookConfiguration - - It("should inject CA bundle", func() { - vwc = NewValidatingWebhookConfigurationForTest("test-vwc", caSecretRef) - Expect(k8sClient.Create(ctx, vwc)).To(Succeed()) - }) - - It("should update CA bundle when bundle updated", func() { - caSecret.Data[api.TLSCABundleKey] = []byte("updated CA bundle") - Expect(k8sClient.Update(ctx, caSecret)).To(Succeed()) - }) - - AfterEach(func() { - Eventually(komega.Object(vwc)).Should( - HaveField("Webhooks", HaveEach( - HaveField("ClientConfig.CABundle", Equal(caSecret.Data[api.TLSCABundleKey])), - )), - ) - }) - }) - -}) diff --git a/test/leaf_cert_controller_test.go b/test/leaf_cert_controller_test.go deleted file mode 100644 index cbd1729..0000000 --- a/test/leaf_cert_controller_test.go +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 test - -import ( - "crypto/tls" - "time" - - "github.com/cert-manager/webhook-cert-lib/internal/certificate" - "github.com/cert-manager/webhook-cert-lib/internal/pki" - "github.com/cert-manager/webhook-cert-lib/pkg/authority" - "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Leaf Certificate Controller", Ordered, func() { - var ( - caSecret *corev1.Secret - caSecretRef types.NamespacedName - certHolder *certificate.Holder - ) - - BeforeAll(func() { - caSecret = &corev1.Secret{} - caSecret.Namespace = "leaf-cert-controller" - caSecret.Name = "ca-cert" - caSecretRef = client.ObjectKeyFromObject(caSecret) - - opts := authority.Options{ - CAOptions: authority.CAOptions{ - NamespacedName: caSecretRef, - Duration: 7 * time.Hour, - }, - LeafOptions: authority.LeafOptions{ - Duration: 1 * time.Hour, - }} - - ns := &corev1.Namespace{} - ns.Name = opts.CAOptions.Namespace - Expect(k8sClient.Create(ctx, ns)).To(Succeed()) - - caCert, caPK, err := certificate.GenerateCA(opts.CAOptions.Duration) - Expect(err).ToNot(HaveOccurred()) - caCertBytes, err := pki.EncodeCertificateAsPEM(caCert) - Expect(err).ToNot(HaveOccurred()) - pkBytes, err := pki.EncodePrivateKey(caPK) - Expect(err).ToNot(HaveOccurred()) - - caSecret = &corev1.Secret{} - caSecret.Namespace = opts.CAOptions.Namespace - caSecret.Name = opts.CAOptions.Name - caSecret.Type = corev1.SecretTypeTLS - caSecret.Labels = map[string]string{ - api.DynamicAuthoritySecretLabel: "true", - } - caSecret.Data = map[string][]byte{ - corev1.TLSCertKey: caCertBytes, - corev1.TLSPrivateKeyKey: pkBytes, - } - Expect(k8sClient.Create(ctx, caSecret)).To(Succeed()) - - k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - Metrics: metricsserver.Options{ - BindAddress: "0", - }, - }) - Expect(err).ToNot(HaveOccurred()) - - certHolder = &certificate.Holder{} - controller := &authority.LeafCertReconciler{ - Reconciler: authority.Reconciler{ - Patcher: k8sManager.GetClient(), - Cache: k8sManager.GetCache(), - Options: opts, - }, - CertificateHolder: certHolder, - } - Expect(controller.SetupWithManager(k8sManager)).To(Succeed()) - - go func() { - defer GinkgoRecover() - err = k8sManager.Start(ctx) - Expect(err).ToNot(HaveOccurred(), "failed to run manager") - }() - }) - - It("should set certificate", func() { - Eventually(func() (*tls.Certificate, error) { - return certHolder.GetCertificate(nil) - }).ShouldNot(BeNil()) - }) -}) diff --git a/test/suite_test.go b/test/suite_test.go deleted file mode 100644 index b2b48dd..0000000 --- a/test/suite_test.go +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2025 The cert-manager 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 - - 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 test - -import ( - "context" - "testing" - - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// These tests use Ginkgo (BDD-style Go testing framework). Refer to -// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. - -var ( - cfg *rest.Config - k8sClient client.Client - testEnv *envtest.Environment - ctx context.Context - cancel context.CancelFunc -) - -func TestControllers(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Controller Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - ctx, cancel = context.WithCancel(context.TODO()) - - By("bootstrapping test environment") - testEnv = &envtest.Environment{} - - var err error - // 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()) - komega.SetClient(k8sClient) -}) - -var _ = AfterSuite(func() { - By("tearing down the test environment") - cancel() - err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) -}) diff --git a/test/util_test.go b/test/util_test.go index 98ba35b..f8a16a1 100644 --- a/test/util_test.go +++ b/test/util_test.go @@ -17,42 +17,21 @@ limitations under the License. package test import ( - "errors" - "fmt" + "testing" + "testing/synctest" + "time" "github.com/cert-manager/webhook-cert-lib/internal/pki" "github.com/cert-manager/webhook-cert-lib/pkg/authority/api" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + k8sfake "k8s.io/client-go/kubernetes/fake" "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/envtest/komega" - - . "github.com/onsi/gomega" ) -func assertCASecret(secret *corev1.Secret) { - Eventually(komega.Object(secret)).Should(And( - HaveField("Labels", HaveKeyWithValue(api.DynamicAuthoritySecretLabel, "true")), - HaveField("Type", Equal(corev1.SecretTypeTLS)), - HaveField("Data", And( - HaveKeyWithValue(corev1.TLSCertKey, Not(BeEmpty())), - HaveKeyWithValue(corev1.TLSPrivateKeyKey, Not(BeEmpty())), - HaveKeyWithValue(api.TLSCABundleKey, Not(BeEmpty())), - )), - )) - - cert, err := pki.DecodeCertificateFromPEM(secret.Data[corev1.TLSCertKey]) - Expect(err).ToNot(HaveOccurred()) - caBundle, err := pki.DecodeAllCertificatesFromPEM(secret.Data[api.TLSCABundleKey]) - Expect(err).ToNot(HaveOccurred()) - - Expect(secretPublicKeysDiffer(secret)).To(BeFalse()) - Expect(cert.Subject).To(Equal(cert.Issuer)) - Expect(caBundle).To(ContainElement(cert)) -} - -func NewValidatingWebhookConfigurationForTest(name string, caSecret types.NamespacedName) *admissionregistrationv1.ValidatingWebhookConfiguration { +func newValidatingWebhookConfigurationForTest(name string, caSecret types.NamespacedName) *admissionregistrationv1.ValidatingWebhookConfiguration { vwc := &admissionregistrationv1.ValidatingWebhookConfiguration{} vwc.Name = name vwc.Labels = map[string]string{ @@ -77,23 +56,166 @@ func newValidatingWebhookForTest(name string) admissionregistrationv1.Validating } } -func secretPublicKeysDiffer(secret *corev1.Secret) (bool, error) { - pk, err := pki.DecodePrivateKeyBytes(secret.Data[corev1.TLSPrivateKeyKey]) +type caTester struct { + types.NamespacedName + clientset *k8sfake.Clientset +} + +// getPending returns true when the given TLS secret exists and has non-empty cert and key +// and carries the DynamicAuthoritySecretLabel label set to true. It also updates the provided +// secret pointer with latest Data and ResourceVersion for subsequent assertions. +func (ca *caTester) getPending(t *testing.T) *corev1.Secret { + t.Helper() + + secret, err := ca.clientset.CoreV1().Secrets(ca.Namespace).Get(t.Context(), ca.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("error: secret %s/%s not found yet: %v", ca.Namespace, ca.Name, err) + } + + if secret.Labels[api.DynamicAuthoritySecretLabel] != "true" || secret.Type != corev1.SecretTypeTLS { + t.Fatalf("error: secret present but not ready: labels=%v type=%s dataKeys=%v", secret.Labels, secret.Type, keysOf(secret.Data)) + } + + if len(secret.Data[api.TLSPendingCertKey]) == 0 { + t.Fatalf("error: secret present but %q key missing or empty: dataKeys=%v", api.TLSPendingCertKey, keysOf(secret.Data)) + } + if len(secret.Data[api.TLSPendingPrivateKeyKey]) == 0 { + t.Fatalf("error: secret present but %q key missing or empty: dataKeys=%v", api.TLSPendingPrivateKeyKey, keysOf(secret.Data)) + } + + isValidCertPEM(t, secret.Data[api.TLSPendingCertKey], secret.Data[api.TLSPendingPrivateKeyKey]) + + return secret.DeepCopy() +} + +// getReady returns true when the given TLS secret exists and has non-empty cert and key +// and carries the DynamicAuthoritySecretLabel label set to true. It also updates the provided +// secret pointer with latest Data and ResourceVersion for subsequent assertions. +func (ca *caTester) getReady(t *testing.T) *corev1.Secret { + t.Helper() + + secret, err := ca.clientset.CoreV1().Secrets(ca.Namespace).Get(t.Context(), ca.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("error: secret %s/%s not found yet: %v", ca.Namespace, ca.Name, err) + } + + if secret.Labels[api.DynamicAuthoritySecretLabel] != "true" || secret.Type != corev1.SecretTypeTLS { + t.Fatalf("error: secret present but not ready: labels=%v type=%s dataKeys=%v", secret.Labels, secret.Type, keysOf(secret.Data)) + } + + if len(secret.Data[api.TLSServingCertKey]) == 0 { + t.Fatalf("error: secret present but %q key missing or empty: dataKeys=%v", api.TLSServingCertKey, keysOf(secret.Data)) + } + if len(secret.Data[api.TLSServingPrivateKeyKey]) == 0 { + t.Fatalf("error: secret present but %q key missing or empty: dataKeys=%v", api.TLSServingPrivateKeyKey, keysOf(secret.Data)) + } + + isValidCertPEM(t, secret.Data[api.TLSServingCertKey], secret.Data[api.TLSServingPrivateKeyKey]) + + return secret.DeepCopy() +} + +func isValidCertPEM(t *testing.T, pemBytes []byte, keyBytes []byte) { + t.Helper() + + cert, err := pki.DecodeCertificateFromPEM(pemBytes) + if err != nil { + t.Fatalf("error: failed to parse cert PEM: %v", err) + } + + privKey, err := pki.DecodePrivateKeyBytes(keyBytes) if err != nil { - return true, fmt.Errorf("secret contains invalid private key data: %w", err) + t.Fatalf("error: failed to parse private key PEM: %v", err) } - x509Cert, err := pki.DecodeCertificateFromPEM(secret.Data[corev1.TLSCertKey]) + + ok, err := pki.PublicKeysEqual(cert.PublicKey, privKey.Public()) if err != nil { - return true, fmt.Errorf("secret contains an invalid certificate: %w", err) + t.Fatalf("error: failed to compare public keys: %v", err) + } + if !ok { + t.Fatalf("error: cert public key does not match private key") + } + + if time.Now().Before(cert.NotBefore) || time.Now().After(cert.NotAfter) { + t.Fatalf("error: cert is not valid at current time %v: NotBefore=%v NotAfter=%v", time.Now(), cert.NotBefore, cert.NotAfter) + } +} + +// keysOf returns keys from map for logging. +func keysOf(m map[string][]byte) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +} + +// createTLSSecretWithLabel creates a TLS Secret with the given cert/key and dynamic label. +func (ca *caTester) put(t *testing.T, secret *corev1.Secret) { + t.Helper() + + secret = secret.DeepCopy() + secret.Namespace = ca.Namespace + secret.Name = ca.Name + + _, getErr := ca.clientset.CoreV1().Secrets(ca.Namespace).Get(t.Context(), ca.Name, metav1.GetOptions{}) + exists := getErr == nil + + if exists { + if _, err := ca.clientset.CoreV1().Secrets(ca.Namespace).Update(t.Context(), secret, metav1.UpdateOptions{}); err != nil { + t.Fatalf("error: failed update secret %s/%s: %v", ca.Namespace, ca.Name, err) + } + } else { + if _, err := ca.clientset.CoreV1().Secrets(ca.Namespace).Create(t.Context(), secret, metav1.CreateOptions{}); err != nil { + t.Fatalf("error: failed create secret %s/%s: %v", ca.Namespace, ca.Name, err) + } } - equal, err := pki.PublicKeysEqual(x509Cert.PublicKey, pk.Public()) + synctest.Wait() +} + +func (ca *caTester) delete(t *testing.T) { + t.Helper() + + if err := ca.clientset.CoreV1().Secrets(ca.Namespace).Delete(t.Context(), ca.Name, metav1.DeleteOptions{}); err != nil { + t.Fatalf("error: failed delete secret %s/%s: %v", ca.Namespace, ca.Name, err) + } + + synctest.Wait() +} + +// assertVWCInjectMatchesSecret asserts all webhooks in the VWC have CABundle equal to the secret cert. +func assertVWCInjectMatchesSecret(t *testing.T, clientset *k8sfake.Clientset, vwcName string, secretNN types.NamespacedName) { + t.Helper() + s, err := clientset.CoreV1().Secrets(secretNN.Namespace).Get(t.Context(), secretNN.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get secret: %v", err) + } + got, err := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().Get(t.Context(), vwcName, metav1.GetOptions{}) if err != nil { - return true, fmt.Errorf("secret contains an invalid key-pair: %w", err) + t.Fatalf("get vwc: %v", err) } - if !equal { - return true, errors.New("secret contains a private key that does not match the certificate") + if len(got.Webhooks) == 0 { + t.Fatalf("no webhooks found in VWC %s", vwcName) + } + + bundle := trustBundle(s) + + for i := range got.Webhooks { + if string(bundle) != string(got.Webhooks[i].ClientConfig.CABundle) { + t.Fatalf("webhook %d CABundle didn't match secret", i) + } + } +} + +func trustBundle(cert *corev1.Secret) []byte { + certPool := pki.NewCertPool() + + if cert != nil && cert.Data != nil { + _ = certPool.AddCertificatesFromPEM(cert.Data[api.TLSServingCertKey]) + _ = certPool.AddCertificatesFromPEM(cert.Data[api.TLSPendingCertKey]) + _ = certPool.AddCertificatesFromPEM(cert.Data[api.TLSAllTrustedCertsKey]) } - return false, nil + return certPool.PEM() } From 6f67ad752ea629dd136836598b122e6da8404072 Mon Sep 17 00:00:00 2001 From: Tim Ramlot <42113979+inteon@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:49:48 +0100 Subject: [PATCH 2/2] add TODOs based on PR feedback Signed-off-by: Tim Ramlot <42113979+inteon@users.noreply.github.com> --- TODO | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/TODO b/TODO index 8b5ceb1..b4aae8b 100644 --- a/TODO +++ b/TODO @@ -2,3 +2,11 @@ CONSIDER: - add support for prometheus pod monitor: - https://doc.crds.dev/github.com/prometheus-operator/prometheus-operator/monitoring.coreos.com/PodMonitor/v1@v0.76.0#spec-podMetricsEndpoints-tlsConfig-ca - inject trust bundle into ConfigMap that we refer to using the PodMonitor object +- reconsider leader election system +- reconsider what keys to use in the Secret + - we should try to make it possible to issue the CA using cert-manager +- consider adding different sources: + - dynamic self-signed + - dynamic cert-manager + - static filesystem +- improve the UX: in the example make the config more transparent (eg. configure Secret name through flag) \ No newline at end of file
webhook-cert-lib
Validation Request
References webhook
Read by
API Server
Webhook Server
ValidatingWebhookConfiguration
Authority Controller
Secret