From 3d1158ed2356081953c0acfeb5892d246ee8666c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= <26610571+oliverbaehler@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:02:17 +0200 Subject: [PATCH] fix: correct tls reconciler and add tenantowners (#1946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(controller): decode old object for delete requests Signed-off-by: Oliver Bähler * chore: modernize golang Signed-off-by: Oliver Bähler * chore: modernize golang Signed-off-by: Oliver Bähler * chore: modernize golang Signed-off-by: Oliver Bähler * fix: tls controller Signed-off-by: Oliver Baehler * feat: add tenantowner tenant status reference Signed-off-by: Oliver Baehler * fix: tlsreconciler only patches cabundles Signed-off-by: Oliver Baehler * chore: refactor logger usage Signed-off-by: Oliver Baehler * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: tlsreconciler only patches cabundles Signed-off-by: Oliver Baehler * fix: tlsreconciler only patches cabundles Signed-off-by: Oliver Baehler --------- Signed-off-by: Oliver Bähler Signed-off-by: Oliver Baehler Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/e2e.yml | 1 - Makefile | 13 +- api/v1beta2/tenantowner_types.go | 13 + api/v1beta2/zz_generated.deepcopy.go | 14 +- ...sule.clastix.io_capsuleconfigurations.yaml | 2 +- .../crds/capsule.clastix.io_customquotas.yaml | 2 +- ...capsule.clastix.io_globalcustomquotas.yaml | 2 +- ...sule.clastix.io_globaltenantresources.yaml | 2 +- .../capsule.clastix.io_quantityledgers.yaml | 2 +- ...capsule.clastix.io_resourcepoolclaims.yaml | 2 +- .../capsule.clastix.io_resourcepools.yaml | 2 +- .../crds/capsule.clastix.io_rulestatuses.yaml | 2 +- .../crds/capsule.clastix.io_tenantowners.yaml | 79 ++- .../capsule.clastix.io_tenantresources.yaml | 2 +- .../crds/capsule.clastix.io_tenants.yaml | 2 +- .../{ => hooks}/crd-lifecycle/_helpers.tpl | 0 .../{ => hooks}/crd-lifecycle/crds.tpl | 0 .../{ => hooks}/crd-lifecycle/job.yaml | 0 .../{ => hooks}/crd-lifecycle/rbac.yaml | 0 .../crd-lifecycle/serviceaccount.yaml | 0 .../{ => hooks}/post-install/_helpers.tpl | 0 .../{ => hooks}/post-install/job.yaml | 0 .../{ => hooks}/post-install/rbac.yaml | 0 .../post-install/serviceaccount.yaml | 0 .../{ => hooks}/pre-delete/_helpers.tpl | 0 .../templates/{ => hooks}/pre-delete/job.yaml | 0 .../{ => hooks}/pre-delete/rbac.yaml | 0 .../pre-delete/serviceaccount.yaml | 0 cmd/main.go | 11 +- ...tenant_owner_dynamic_clusterroles_test.go} | 0 ...go => tenant_owner_missing_tenant_test.go} | 0 ..._test.go => tenant_owner_webhooks_test.go} | 2 +- e2e/tenantowner_status_test.go | 385 ++++++++++++++ e2e/{owners_test.go => tenantowner_test.go} | 30 +- internal/controllers/admission/mutating.go | 37 +- internal/controllers/admission/validating.go | 41 +- internal/controllers/tenant/enqueue.go | 63 +++ internal/controllers/tenant/limitranges.go | 22 +- internal/controllers/tenant/manager.go | 34 +- internal/controllers/tenant/namespaces.go | 13 +- .../controllers/tenant/networkpolicies.go | 16 +- internal/controllers/tenant/resourcequotas.go | 37 +- internal/controllers/tenant/rolebindings.go | 7 +- internal/controllers/tenant/rulestatus.go | 6 +- internal/controllers/tenant/status.go | 7 +- internal/controllers/tenant/utils.go | 51 -- internal/controllers/tenantowner/enqueue.go | 68 +++ internal/controllers/tenantowner/manager.go | 231 ++++++++ internal/controllers/tls/manager.go | 493 +++++++++--------- internal/controllers/tls/utils.go | 144 ++--- pkg/runtime/cert/ca.go | 49 +- pkg/runtime/cert/ca_test.go | 317 +++++++++-- pkg/runtime/cert/options.go | 25 +- pkg/runtime/cert/options_test.go | 51 ++ pkg/runtime/cert/san.go | 161 ++++++ pkg/runtime/cert/san_test.go | 399 ++++++++++++++ pkg/runtime/cert/utils.go | 25 + pkg/runtime/cert/utils_test.go | 31 ++ pkg/runtime/indexers/indexer.go | 2 + pkg/runtime/indexers/tenantowner/const.go | 8 + pkg/runtime/indexers/tenantowner/name.go | 35 ++ pkg/runtime/predicates/tenant_change.go | 31 +- pkg/tenant/owners.go | 6 +- 63 files changed, 2377 insertions(+), 601 deletions(-) rename charts/capsule/templates/{ => hooks}/crd-lifecycle/_helpers.tpl (100%) rename charts/capsule/templates/{ => hooks}/crd-lifecycle/crds.tpl (100%) rename charts/capsule/templates/{ => hooks}/crd-lifecycle/job.yaml (100%) rename charts/capsule/templates/{ => hooks}/crd-lifecycle/rbac.yaml (100%) rename charts/capsule/templates/{ => hooks}/crd-lifecycle/serviceaccount.yaml (100%) rename charts/capsule/templates/{ => hooks}/post-install/_helpers.tpl (100%) rename charts/capsule/templates/{ => hooks}/post-install/job.yaml (100%) rename charts/capsule/templates/{ => hooks}/post-install/rbac.yaml (100%) rename charts/capsule/templates/{ => hooks}/post-install/serviceaccount.yaml (100%) rename charts/capsule/templates/{ => hooks}/pre-delete/_helpers.tpl (100%) rename charts/capsule/templates/{ => hooks}/pre-delete/job.yaml (100%) rename charts/capsule/templates/{ => hooks}/pre-delete/rbac.yaml (100%) rename charts/capsule/templates/{ => hooks}/pre-delete/serviceaccount.yaml (100%) rename e2e/{owner_dynamic_clusterroles_test.go => tenant_owner_dynamic_clusterroles_test.go} (100%) rename e2e/{owner_missing_tenant_test.go => tenant_owner_missing_tenant_test.go} (100%) rename e2e/{owner_webhooks_test.go => tenant_owner_webhooks_test.go} (99%) create mode 100644 e2e/tenantowner_status_test.go rename e2e/{owners_test.go => tenantowner_test.go} (95%) create mode 100644 internal/controllers/tenant/enqueue.go create mode 100644 internal/controllers/tenantowner/enqueue.go create mode 100644 internal/controllers/tenantowner/manager.go create mode 100644 pkg/runtime/cert/options_test.go create mode 100644 pkg/runtime/cert/san.go create mode 100644 pkg/runtime/cert/san_test.go create mode 100644 pkg/runtime/cert/utils.go create mode 100644 pkg/runtime/cert/utils_test.go create mode 100644 pkg/runtime/indexers/tenantowner/const.go create mode 100644 pkg/runtime/indexers/tenantowner/name.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5cf9213f..2d61482d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -64,7 +64,6 @@ jobs: fail-fast: false matrix: k8s-version: - - 'v1.31.0' - 'v1.32.0' - 'v1.33.0' - 'v1.34.0' diff --git a/Makefile b/Makefile index 069b8c30..4ad24158 100644 --- a/Makefile +++ b/Makefile @@ -186,6 +186,7 @@ dev-setup: --create-namespace \ --version=$(CHART_VERSION) \ --set 'proxy.enabled=true' \ + --set 'proxy.certManager.generateCertificates=false' \ --set 'crds.install=true' \ --set 'crds.exclusive=true'\ --set 'crds.createConfig=true'\ @@ -194,8 +195,8 @@ dev-setup: --set "monitoring.diagnostics.enabled=true"\ --set "manager.rbac.minimal=true"\ --set 'certManager.generateCertificates=false' \ - --set 'tls.enableController=true' \ - --set 'tls.create=true' \ + --set 'tls.enableController=false' \ + --set 'tls.create=false' \ --set "webhooks.exclusive=true"\ --set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \ @@ -453,7 +454,7 @@ e2e-build: kind $(MAKE) e2e-install .PHONY: e2e-install -e2e-install: helm-controller-version ko-build-all +e2e-install: helm-controller-version ko-build-all dev-install-gw-api-crds $(MAKE) e2e-load-image CLUSTER_NAME=$(CLUSTER_NAME) IMAGE=$(CAPSULE_IMG) VERSION=$(VERSION) $(KUBECTL) label clusterrole admin projectcapsule.dev/aggregate-to-controller=true $(HELM) upgrade \ @@ -470,10 +471,10 @@ e2e-install: helm-controller-version ko-build-all --set 'manager.resources=null'\ --set "manager.image.tag=$(VERSION)" \ --set 'manager.livenessProbe.failureThreshold=10' \ - --set 'manager.options.logLevel=debug' \ + --set-string 'manager.options.logLevel=5' \ --set 'manager.options.workers=4' \ --set 'manager.options.clientConnectionQPS=2000' \ - --set 'manager.options.clientConnectionQPS=1000' \ + --set 'manager.options.clientConnectionBurst=1000' \ --set 'manager.rbac.minimal=true' \ --set 'webhooks.hooks.nodes.enabled=true' \ --set "webhooks.exclusive=true"\ @@ -604,7 +605,7 @@ helm-doc: # -- Tools #################### CONTROLLER_GEN := $(LOCALBIN)/controller-gen -CONTROLLER_GEN_VERSION ?= v0.20.0 +CONTROLLER_GEN_VERSION ?= v0.21.0 CONTROLLER_GEN_LOOKUP := kubernetes-sigs/controller-tools controller-gen: @test -s $(CONTROLLER_GEN) && $(CONTROLLER_GEN) --version | grep -q $(CONTROLLER_GEN_VERSION) || \ diff --git a/api/v1beta2/tenantowner_types.go b/api/v1beta2/tenantowner_types.go index ffd1dfab..57d83563 100644 --- a/api/v1beta2/tenantowner_types.go +++ b/api/v1beta2/tenantowner_types.go @@ -6,6 +6,7 @@ package v1beta2 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" ) @@ -26,11 +27,23 @@ type TenantOwnerStatus struct { // ObservedGeneration is the most recent generation the controller has observed. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Tenants lists the names of all Tenants that this TenantOwner is currently matched to + // via the Tenant's spec.permissions.matchOwners selectors. + // +optional + // +listType=atomic + Tenants []string `json:"tenants,omitempty"` + + // Conditions contains the reconciliation conditions for this TenantOwner. + // +optional + Conditions meta.ConditionList `json:"conditions,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster,shortName=to +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile status of this TenantOwner" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" // TenantOwner is the Schema for the tenantowners API. type TenantOwner struct { diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 1f289b5b..b68d5fe0 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -1811,7 +1811,7 @@ func (in *TenantOwner) DeepCopyInto(out *TenantOwner) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantOwner. @@ -1883,6 +1883,18 @@ func (in *TenantOwnerSpec) DeepCopy() *TenantOwnerSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TenantOwnerStatus) DeepCopyInto(out *TenantOwnerStatus) { *out = *in + if in.Tenants != nil { + in, out := &in.Tenants, &out.Tenants + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(meta.ConditionList, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantOwnerStatus. diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index ed2c4fc1..2cf9fa01 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: capsuleconfigurations.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_customquotas.yaml b/charts/capsule/crds/capsule.clastix.io_customquotas.yaml index 3d8b7146..a310c72d 100644 --- a/charts/capsule/crds/capsule.clastix.io_customquotas.yaml +++ b/charts/capsule/crds/capsule.clastix.io_customquotas.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: customquotas.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml b/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml index 8ee42687..3aed23e8 100644 --- a/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: globalcustomquotas.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 4ed7fb4f..02da8845 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: globaltenantresources.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_quantityledgers.yaml b/charts/capsule/crds/capsule.clastix.io_quantityledgers.yaml index 3d6d763d..e1789033 100644 --- a/charts/capsule/crds/capsule.clastix.io_quantityledgers.yaml +++ b/charts/capsule/crds/capsule.clastix.io_quantityledgers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: quantityledgers.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml index e16097f8..b5ba0ac9 100644 --- a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml +++ b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: resourcepoolclaims.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml index 84f1bbd3..b53aae9e 100644 --- a/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml +++ b/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: resourcepools.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index 1793f92f..d4c00e8e 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: rulestatuses.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml b/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml index a4fb5e20..bf554c0a 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: tenantowners.capsule.clastix.io spec: group: capsule.clastix.io @@ -16,7 +16,16 @@ spec: singular: tenantowner scope: Cluster versions: - - name: v1beta2 + - additionalPrinterColumns: + - description: Reconcile status of this TenantOwner + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta2 schema: openAPIV3Schema: description: TenantOwner is the Schema for the tenantowners API. @@ -75,11 +84,77 @@ spec: status: description: status defines the observed state of TenantOwner. properties: + conditions: + description: Conditions contains the reconciliation conditions for + this TenantOwner. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array observedGeneration: description: ObservedGeneration is the most recent generation the controller has observed. format: int64 type: integer + tenants: + description: |- + Tenants lists the names of all Tenants that this TenantOwner is currently matched to + via the Tenant's spec.permissions.matchOwners selectors. + items: + type: string + type: array + x-kubernetes-list-type: atomic type: object required: - spec diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 65d7a745..31b730c8 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: tenantresources.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 791bb5ad..a78cb0d4 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: tenants.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/templates/crd-lifecycle/_helpers.tpl b/charts/capsule/templates/hooks/crd-lifecycle/_helpers.tpl similarity index 100% rename from charts/capsule/templates/crd-lifecycle/_helpers.tpl rename to charts/capsule/templates/hooks/crd-lifecycle/_helpers.tpl diff --git a/charts/capsule/templates/crd-lifecycle/crds.tpl b/charts/capsule/templates/hooks/crd-lifecycle/crds.tpl similarity index 100% rename from charts/capsule/templates/crd-lifecycle/crds.tpl rename to charts/capsule/templates/hooks/crd-lifecycle/crds.tpl diff --git a/charts/capsule/templates/crd-lifecycle/job.yaml b/charts/capsule/templates/hooks/crd-lifecycle/job.yaml similarity index 100% rename from charts/capsule/templates/crd-lifecycle/job.yaml rename to charts/capsule/templates/hooks/crd-lifecycle/job.yaml diff --git a/charts/capsule/templates/crd-lifecycle/rbac.yaml b/charts/capsule/templates/hooks/crd-lifecycle/rbac.yaml similarity index 100% rename from charts/capsule/templates/crd-lifecycle/rbac.yaml rename to charts/capsule/templates/hooks/crd-lifecycle/rbac.yaml diff --git a/charts/capsule/templates/crd-lifecycle/serviceaccount.yaml b/charts/capsule/templates/hooks/crd-lifecycle/serviceaccount.yaml similarity index 100% rename from charts/capsule/templates/crd-lifecycle/serviceaccount.yaml rename to charts/capsule/templates/hooks/crd-lifecycle/serviceaccount.yaml diff --git a/charts/capsule/templates/post-install/_helpers.tpl b/charts/capsule/templates/hooks/post-install/_helpers.tpl similarity index 100% rename from charts/capsule/templates/post-install/_helpers.tpl rename to charts/capsule/templates/hooks/post-install/_helpers.tpl diff --git a/charts/capsule/templates/post-install/job.yaml b/charts/capsule/templates/hooks/post-install/job.yaml similarity index 100% rename from charts/capsule/templates/post-install/job.yaml rename to charts/capsule/templates/hooks/post-install/job.yaml diff --git a/charts/capsule/templates/post-install/rbac.yaml b/charts/capsule/templates/hooks/post-install/rbac.yaml similarity index 100% rename from charts/capsule/templates/post-install/rbac.yaml rename to charts/capsule/templates/hooks/post-install/rbac.yaml diff --git a/charts/capsule/templates/post-install/serviceaccount.yaml b/charts/capsule/templates/hooks/post-install/serviceaccount.yaml similarity index 100% rename from charts/capsule/templates/post-install/serviceaccount.yaml rename to charts/capsule/templates/hooks/post-install/serviceaccount.yaml diff --git a/charts/capsule/templates/pre-delete/_helpers.tpl b/charts/capsule/templates/hooks/pre-delete/_helpers.tpl similarity index 100% rename from charts/capsule/templates/pre-delete/_helpers.tpl rename to charts/capsule/templates/hooks/pre-delete/_helpers.tpl diff --git a/charts/capsule/templates/pre-delete/job.yaml b/charts/capsule/templates/hooks/pre-delete/job.yaml similarity index 100% rename from charts/capsule/templates/pre-delete/job.yaml rename to charts/capsule/templates/hooks/pre-delete/job.yaml diff --git a/charts/capsule/templates/pre-delete/rbac.yaml b/charts/capsule/templates/hooks/pre-delete/rbac.yaml similarity index 100% rename from charts/capsule/templates/pre-delete/rbac.yaml rename to charts/capsule/templates/hooks/pre-delete/rbac.yaml diff --git a/charts/capsule/templates/pre-delete/serviceaccount.yaml b/charts/capsule/templates/hooks/pre-delete/serviceaccount.yaml similarity index 100% rename from charts/capsule/templates/pre-delete/serviceaccount.yaml rename to charts/capsule/templates/hooks/pre-delete/serviceaccount.yaml diff --git a/cmd/main.go b/cmd/main.go index 4f81e49f..6f3a420a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -53,6 +53,7 @@ import ( rulestatuscontroller "github.com/projectcapsule/capsule/internal/controllers/rulestatus" servicelabelscontroller "github.com/projectcapsule/capsule/internal/controllers/servicelabels" tenantcontroller "github.com/projectcapsule/capsule/internal/controllers/tenant" + tenantownercontroller "github.com/projectcapsule/capsule/internal/controllers/tenantowner" tlscontroller "github.com/projectcapsule/capsule/internal/controllers/tls" utilscontroller "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/internal/metrics" @@ -321,7 +322,7 @@ func main() { } // Reconcile TLS certificates before starting controllers and webhooks - if err = tlsReconciler.ReconcileCertificates(ctx, tlsCert); err != nil { + if err = tlsReconciler.ReconcileCertificates(ctx, ctrl.Log.WithName("capsule.setup").WithName("tls"), tlsCert); err != nil { setupLog.Error(err, "unable to reconcile Capsule TLS secret") os.Exit(1) } @@ -687,6 +688,14 @@ func main() { os.Exit(1) } + if err = (&tenantownercontroller.TenantOwnerManager{ + Log: ctrl.Log.WithName("capsule.ctrl").WithName("tenantowners"), + Client: manager.GetClient(), + }).SetupWithManager(manager, controllerConfig); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "TenantOwners") + os.Exit(1) + } + if err = (&servicelabelscontroller.ServicesLabelsReconciler{ Log: ctrl.Log.WithName("capsule.ctrl").WithName("services"), }).SetupWithManager(ctx, manager); err != nil { diff --git a/e2e/owner_dynamic_clusterroles_test.go b/e2e/tenant_owner_dynamic_clusterroles_test.go similarity index 100% rename from e2e/owner_dynamic_clusterroles_test.go rename to e2e/tenant_owner_dynamic_clusterroles_test.go diff --git a/e2e/owner_missing_tenant_test.go b/e2e/tenant_owner_missing_tenant_test.go similarity index 100% rename from e2e/owner_missing_tenant_test.go rename to e2e/tenant_owner_missing_tenant_test.go diff --git a/e2e/owner_webhooks_test.go b/e2e/tenant_owner_webhooks_test.go similarity index 99% rename from e2e/owner_webhooks_test.go rename to e2e/tenant_owner_webhooks_test.go index c4c596f0..0bcd5f4a 100644 --- a/e2e/owner_webhooks_test.go +++ b/e2e/tenant_owner_webhooks_test.go @@ -21,7 +21,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/rbac" ) -var _ = Describe("when Tenant owner interacts with the webhooks", Ordered, Label("tenant"), func() { +var _ = Describe("when Tenant owner interacts with the webhooks", Ordered, Label("tenant", "permissions", "owners"), func() { tnt := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ Name: "e2e-owner-admission", diff --git a/e2e/tenantowner_status_test.go b/e2e/tenantowner_status_test.go new file mode 100644 index 00000000..a3176fde --- /dev/null +++ b/e2e/tenantowner_status_test.go @@ -0,0 +1,385 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package e2e + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + capmeta "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" +) + +// tenantOwnerReady waits until the TenantOwner's Ready condition has the expected status. +func tenantOwnerReady(to *capsulev1beta2.TenantOwner, expected metav1.ConditionStatus) { + Eventually(func(g Gomega) { + current := &capsulev1beta2.TenantOwner{} + g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: to.GetName()}, current)).To(Succeed()) + + cond := current.Status.Conditions.GetConditionByType(capmeta.ReadyCondition) + g.Expect(cond).NotTo(BeNil(), "TenantOwner %q should have a Ready condition", to.GetName()) + g.Expect(cond.Status).To( + Equal(expected), + "TenantOwner %q Ready condition should be %s, got %s: %s", + to.GetName(), expected, cond.Status, cond.Message, + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) +} + +// tenantOwnerMatchedTenants waits until the TenantOwner's status.matchedTenantNames +// equals the expected sorted list. The controller calls sort.Strings before persisting, +// so the assertion is order-sensitive to validate stable ordering for API consumers. +func tenantOwnerMatchedTenants(to *capsulev1beta2.TenantOwner, expected []string) { + Eventually(func(g Gomega) { + current := &capsulev1beta2.TenantOwner{} + g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: to.GetName()}, current)).To(Succeed()) + + actual := current.Status.Tenants + if actual == nil { + actual = []string{} + } + + g.Expect(actual).To( + Equal(expected), + "TenantOwner %q status.matchedTenantNames mismatch", to.GetName(), + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) +} + +var _ = Describe("TenantOwner status tracks matched Tenants", Ordered, Label("tenantowner", "tenant", "permissions", "owners"), func() { + // to is a shared TenantOwner used across the sub-tests in this Ordered block. + // Two Tenants are created/deleted to verify count changes. + to := &capsulev1beta2.TenantOwner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-to-status", + Labels: map[string]string{ + "e2e-to-status": "true", + "e2e.suite.capsule/name": "owner_status_test", + }, + }, + Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Kind: rbac.UserOwner, + Name: "e2e-to-status-user", + }, + ClusterRoles: []string{"view"}, + }, + }, + } + + tnt1 := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-to-status-tnt1", + }, + Spec: capsulev1beta2.TenantSpec{ + Permissions: capsulev1beta2.Permissions{ + MatchOwners: []*metav1.LabelSelector{ + {MatchLabels: map[string]string{"e2e.suite.capsule/name": "owner_status_test"}}, + }, + }, + }, + } + + tnt2 := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-to-status-tnt2", + }, + Spec: capsulev1beta2.TenantSpec{ + Permissions: capsulev1beta2.Permissions{ + MatchOwners: []*metav1.LabelSelector{ + {MatchLabels: map[string]string{"e2e.suite.capsule/name": "owner_status_test"}}, + }, + }, + }, + } + + JustBeforeEach(func() { + EventuallyCreation(func() error { + to.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), to) + }).Should(Succeed()) + }) + + JustAfterEach(func() { + EventuallyDeletion(to) + EventuallyDeletion(tnt1) + EventuallyDeletion(tnt2) + }) + + It("has Ready=True and empty tenant list when no Tenant matches", func() { + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, []string{}) + }) + + It("reflects one matched Tenant in status", func() { + EventuallyCreation(func() error { + tnt1.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt1) + }).Should(Succeed()) + + TenantReadyTrue(tnt1) + + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, []string{tnt1.Name}) + }) + + It("reflects two matched Tenants in status", func() { + EventuallyCreation(func() error { + tnt1.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt1) + }).Should(Succeed()) + + EventuallyCreation(func() error { + tnt2.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt2) + }).Should(Succeed()) + + TenantReadyTrue(tnt1) + TenantReadyTrue(tnt2) + + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, []string{tnt1.Name, tnt2.Name}) + }) + + It("clears the matched Tenant from status when the Tenant is deleted", func() { + EventuallyCreation(func() error { + tnt1.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt1) + }).Should(Succeed()) + + TenantReadyTrue(tnt1) + tenantOwnerMatchedTenants(to, []string{tnt1.Name}) + + EventuallyDeletion(tnt1) + + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, []string{}) + }) + + It("updates status when a Tenant's matchOwners selector is removed", func() { + EventuallyCreation(func() error { + tnt1.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt1) + }).Should(Succeed()) + + TenantReadyTrue(tnt1) + tenantOwnerMatchedTenants(to, []string{tnt1.Name}) + + // Remove the matchOwners selector so the TenantOwner is no longer matched. + Eventually(func(g Gomega) { + current := &capsulev1beta2.Tenant{} + g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.Name}, current)).To(Succeed()) + current.Spec.Permissions.MatchOwners = nil + g.Expect(k8sClient.Update(context.TODO(), current)).To(Succeed()) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, []string{}) + }) + + It("tracks observedGeneration in TenantOwner status", func() { + Eventually(func(g Gomega) { + current := &capsulev1beta2.TenantOwner{} + g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: to.Name}, current)).To(Succeed()) + g.Expect(current.Status.ObservedGeneration).To( + Equal(current.GetGeneration()), + "status.observedGeneration should equal metadata.generation", + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) +}) + +var _ = Describe("TenantOwner status with 10 matched Tenants", Ordered, Label("tenantowner", "status", "scale"), func() { + const tenantCount = 10 + + to := &capsulev1beta2.TenantOwner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-to-status-10match", + Labels: map[string]string{ + "e2e-to-status-10match": "true", + "e2e.suite.capsule/name": "owner_status_test", + }, + }, + Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Kind: rbac.UserOwner, + Name: "e2e-to-status-10match-user", + }, + ClusterRoles: []string{"view"}, + }, + }, + } + + tenants := func() []*capsulev1beta2.Tenant { + tnts := make([]*capsulev1beta2.Tenant, tenantCount) + for i := range tnts { + tnts[i] = &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("e2e-to-status-10match-tnt-%d", i), + Labels: map[string]string{ + "e2e.suite.capsule/name": "owner_status_test", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Permissions: capsulev1beta2.Permissions{ + MatchOwners: []*metav1.LabelSelector{ + {MatchLabels: map[string]string{"e2e-to-status-10match": "true"}}, + }, + }, + }, + } + } + + return tnts + } + + JustBeforeEach(func() { + EventuallyCreation(func() error { + to.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), to) + }).Should(Succeed()) + + for _, tnt := range tenants() { + tnt := tnt + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + } + }) + + JustAfterEach(func() { + EventuallyDeletion(to) + for _, tnt := range tenants() { + EventuallyDeletion(tnt) + } + }) + + It("reports all 10 matched Tenants and Ready=True", func() { + expectedNames := make([]string, tenantCount) + for i := range expectedNames { + expectedNames[i] = fmt.Sprintf("e2e-to-status-10match-tnt-%d", i) + } + + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, expectedNames) + }) +}) + +var _ = Describe("TenantOwner status fan-out: 10 TenantOwners matched by 1 Tenant", Ordered, Label("tenantowner", "status", "fanout"), func() { + const ownerCount = 10 + + owners := func() []*capsulev1beta2.TenantOwner { + tos := make([]*capsulev1beta2.TenantOwner, ownerCount) + for i := range tos { + tos[i] = &capsulev1beta2.TenantOwner{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("e2e-to-fanout-%d", i), + Labels: map[string]string{ + "e2e-to-fanout": "true", + "e2e.suite.capsule/name": "owner_status_test", + }, + }, + Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Kind: rbac.UserOwner, + Name: fmt.Sprintf("e2e-to-fanout-user-%d", i), + }, + ClusterRoles: []string{"view"}, + }, + }, + } + } + + return tos + } + + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-tnt-fanout-single", + Labels: map[string]string{ + "e2e.suite.capsule/name": "owner_status_test", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Permissions: capsulev1beta2.Permissions{ + MatchOwners: []*metav1.LabelSelector{ + {MatchLabels: map[string]string{ + "e2e-to-fanout": "true", + "e2e.suite.capsule/name": "owner_status_test", + }}, + }, + }, + }, + } + + JustBeforeEach(func() { + for _, to := range owners() { + to := to + + EventuallyCreation(func() error { + to.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), to) + }).Should(Succeed()) + } + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + }) + + JustAfterEach(func() { + for _, to := range owners() { + EventuallyDeletion(to) + } + EventuallyDeletion(tnt) + }) + + It("updates all 10 TenantOwners when 1 Tenant is created", func() { + // All 10 TenantOwners should each report matchedTenants=1 and tenants=[tnt.Name]. + for _, to := range owners() { + to := to + + tenantOwnerReady(to, metav1.ConditionTrue) + tenantOwnerMatchedTenants(to, []string{tnt.Name}) + } + }) + + It("clears all 10 TenantOwners when the Tenant is deleted", func() { + // Establish baseline: all matched. + for _, to := range owners() { + tenantOwnerMatchedTenants(to, []string{tnt.Name}) + } + + EventuallyDeletion(tnt) + + // After deletion every TenantOwner should report zero matches. + for _, to := range owners() { + tenantOwnerMatchedTenants(to, []string{}) + } + }) +}) diff --git a/e2e/owners_test.go b/e2e/tenantowner_test.go similarity index 95% rename from e2e/owners_test.go rename to e2e/tenantowner_test.go index 10c4a7b4..f799ba86 100644 --- a/e2e/owners_test.go +++ b/e2e/tenantowner_test.go @@ -17,7 +17,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/rbac" ) -var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "owners"), func() { +var _ = Describe("Owners", Ordered, Label("config", "tenantowner", "tenant", "permissions", "owners"), func() { originConfig := &capsulev1beta2.CapsuleConfiguration{} tnt1 := &capsulev1beta2.Tenant{ @@ -32,12 +32,14 @@ var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "ow MatchOwners: []*metav1.LabelSelector{ { MatchLabels: map[string]string{ - "customer": "x", + "customer": "x", + "e2e.suite.capsule/name": "owner_test", }, }, { MatchLabels: map[string]string{ - "team": "devops", + "team": "devops", + "e2e.suite.capsule/name": "owner_test", }, }, }, @@ -83,12 +85,14 @@ var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "ow MatchOwners: []*metav1.LabelSelector{ { MatchLabels: map[string]string{ - "customer": "x", + "customer": "x", + "e2e.suite.capsule/name": "owner_test", }, }, { MatchLabels: map[string]string{ - "team": "infrastructure", + "team": "infrastructure", + "e2e.suite.capsule/name": "owner_test", }, }, }, @@ -126,7 +130,8 @@ var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "ow ObjectMeta: metav1.ObjectMeta{ Name: "e2e-owners-infra", Labels: map[string]string{ - "team": "infrastructure", + "team": "infrastructure", + "e2e.suite.capsule/name": "owner_test", }, }, Spec: capsulev1beta2.TenantOwnerSpec{ @@ -147,7 +152,8 @@ var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "ow ObjectMeta: metav1.ObjectMeta{ Name: "e2e-owners-devops", Labels: map[string]string{ - "team": "devops", + "team": "devops", + "e2e.suite.capsule/name": "owner_test", }, }, Spec: capsulev1beta2.TenantOwnerSpec{ @@ -168,8 +174,9 @@ var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "ow ObjectMeta: metav1.ObjectMeta{ Name: "e2e-owners-common", Labels: map[string]string{ - "team": "infrastructure", - "customer": "x", + "team": "infrastructure", + "customer": "x", + "e2e.suite.capsule/name": "owner_test", }, }, Spec: capsulev1beta2.TenantOwnerSpec{ @@ -211,8 +218,9 @@ var _ = Describe("Owners", Ordered, Label("config", "tenant", "permissions", "ow ObjectMeta: metav1.ObjectMeta{ Name: "e2e-owners-common-user", Labels: map[string]string{ - "team": "infrastructure", - "customer": "x", + "team": "infrastructure", + "customer": "x", + "e2e.suite.capsule/name": "owner_test", }, }, Spec: capsulev1beta2.TenantOwnerSpec{ diff --git a/internal/controllers/admission/mutating.go b/internal/controllers/admission/mutating.go index 79e96ec8..0817b4de 100644 --- a/internal/controllers/admission/mutating.go +++ b/internal/controllers/admission/mutating.go @@ -25,6 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/controllers/tls" "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/runtime/admission" @@ -143,13 +144,15 @@ func (r *mutatingReconciler) reconcileConfiguration( obj.SetAnnotations(annotations) - // Do not overwrite caBundle. cert-manager or the legacy TLS reconciler owns it. - if cfg.Client.CABundle == nil { - obj.Webhooks = preserveMutatingWebhookCABundles(obj.Webhooks, desiredHooks) - } else { - obj.Webhooks = desiredHooks + obj.Webhooks = desiredHooks + + caCert, err := tls.FetchCurrentCaBundleForAdmission(ctx, r.client, r.configuration, cfg.Client.CABundle) + if err != nil { + return err } + preserveMutatingWebhookCABundles(obj.Webhooks, caCert) + return err }) if err != nil { @@ -220,24 +223,10 @@ func (r *mutatingReconciler) webhooks( } func preserveMutatingWebhookCABundles( - existing []admissionv1.MutatingWebhook, - desired []admissionv1.MutatingWebhook, -) []admissionv1.MutatingWebhook { - existingByName := make(map[string][]byte, len(existing)) - - for _, hook := range existing { - if len(hook.ClientConfig.CABundle) == 0 { - continue - } - - existingByName[hook.Name] = append([]byte(nil), hook.ClientConfig.CABundle...) + hooks []admissionv1.MutatingWebhook, + caBundle []byte, +) { + for i := range hooks { + hooks[i].ClientConfig.CABundle = append([]byte(nil), caBundle...) } - - for i := range desired { - if caBundle, ok := existingByName[desired[i].Name]; ok { - desired[i].ClientConfig.CABundle = append([]byte(nil), caBundle...) - } - } - - return desired } diff --git a/internal/controllers/admission/validating.go b/internal/controllers/admission/validating.go index ebff47a2..ed33a98f 100644 --- a/internal/controllers/admission/validating.go +++ b/internal/controllers/admission/validating.go @@ -25,6 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/controllers/tls" "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/runtime/admission" @@ -145,13 +146,15 @@ func (r *validatingReconciler) reconcileValidatingConfiguration( obj.SetAnnotations(annotations) - // Do not overwrite caBundle. cert-manager or the legacy TLS reconciler owns it. - if cfg.Client.CABundle == nil { - obj.Webhooks = preserveValidatingWebhookCABundles(obj.Webhooks, desiredHooks) - } else { - obj.Webhooks = desiredHooks + obj.Webhooks = desiredHooks + + caCert, err := tls.FetchCurrentCaBundleForAdmission(ctx, r.client, r.configuration, cfg.Client.CABundle) + if err != nil { + return err } + preserveValidatingWebhookCABundles(obj.Webhooks, caCert) + return err }) if err != nil { @@ -222,28 +225,10 @@ func (r *validatingReconciler) validatingWebhooks( } func preserveValidatingWebhookCABundles( - existing []admissionv1.ValidatingWebhook, - desired []admissionv1.ValidatingWebhook, -) []admissionv1.ValidatingWebhook { - existingByName := make(map[string][]byte, len(existing)) - - for _, hook := range existing { - if len(hook.ClientConfig.CABundle) == 0 { - continue - } - - existingByName[hook.Name] = append([]byte(nil), hook.ClientConfig.CABundle...) + hooks []admissionv1.ValidatingWebhook, + caBundle []byte, +) { + for i := range hooks { + hooks[i].ClientConfig.CABundle = append([]byte(nil), caBundle...) } - - for i := range desired { - if len(desired[i].ClientConfig.CABundle) > 0 { - continue - } - - if caBundle, ok := existingByName[desired[i].Name]; ok { - desired[i].ClientConfig.CABundle = append([]byte(nil), caBundle...) - } - } - - return desired } diff --git a/internal/controllers/tenant/enqueue.go b/internal/controllers/tenant/enqueue.go new file mode 100644 index 00000000..ea6e82eb --- /dev/null +++ b/internal/controllers/tenant/enqueue.go @@ -0,0 +1,63 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +func (r *Manager) enqueueForTenantsWithCondition( + ctx context.Context, + obj client.Object, + q workqueue.TypedRateLimitingInterface[reconcile.Request], + fn func(*capsulev1beta2.Tenant, client.Object) bool, +) { + var tenants capsulev1beta2.TenantList + if err := r.List(ctx, &tenants); err != nil { + r.Log.Error(err, "failed to list Tenants for class event") + + return + } + + for i := range tenants.Items { + tnt := &tenants.Items[i] + + if !fn(tnt, obj) { + continue + } + + q.Add(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: tnt.Name, + }, + }) + } +} + +func (r *Manager) enqueueAllTenants(ctx context.Context, _ client.Object) []reconcile.Request { + var tenants capsulev1beta2.TenantList + if err := r.List(ctx, &tenants); err != nil { + r.Log.Error(err, "failed to list Tenants for class event") + + return nil + } + + reqs := make([]reconcile.Request, 0, len(tenants.Items)) + for i := range tenants.Items { + reqs = append(reqs, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: tenants.Items[i].Name, + }, + }) + } + + return reqs +} diff --git a/internal/controllers/tenant/limitranges.go b/internal/controllers/tenant/limitranges.go index 92b1a09c..83739ddf 100644 --- a/internal/controllers/tenant/limitranges.go +++ b/internal/controllers/tenant/limitranges.go @@ -1,7 +1,6 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -//nolint:dupl package tenant import ( @@ -14,6 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "github.com/go-logr/logr" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" ) @@ -21,26 +21,30 @@ import ( // Ensuring all the LimitRange are applied to each Namespace handled by the Tenant. // -func (r *Manager) syncLimitRanges(ctx context.Context, tenant *capsulev1beta2.Tenant) error { +func (r *Manager) syncLimitRanges(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) error { // getting requested LimitRange keys - keys := make([]string, 0, len(tenant.Spec.LimitRanges.Items)) //nolint:staticcheck + keys := make([]string, 0, len(tenant.Spec.LimitRanges.Items)) - //nolint:staticcheck for i := range tenant.Spec.LimitRanges.Items { keys = append(keys, strconv.Itoa(i)) } return runForTenantNamespaces(ctx, tenant, func(ctx context.Context, namespace string) error { - return r.syncLimitRange(ctx, tenant, namespace, keys) + return r.syncLimitRange(ctx, log, tenant, namespace, keys) }) } -func (r *Manager) syncLimitRange(ctx context.Context, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) { +func (r *Manager) syncLimitRange( + ctx context.Context, + log logr.Logger, + tenant *capsulev1beta2.Tenant, + namespace string, + keys []string, +) (err error) { if err = r.pruningResources(ctx, namespace, keys, &corev1.LimitRange{}); err != nil { return err } - //nolint:staticcheck for i, spec := range tenant.Spec.LimitRanges.Items { target := &corev1.LimitRange{ ObjectMeta: metav1.ObjectMeta{ @@ -71,7 +75,7 @@ func (r *Manager) syncLimitRange(ctx context.Context, tenant *capsulev1beta2.Ten }) if err != nil { if apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { - r.Log.V(4).Info( + log.Info( "skipping LimitRange sync because namespace is terminating", "name", target.Name, "namespace", target.Namespace, @@ -84,7 +88,7 @@ func (r *Manager) syncLimitRange(ctx context.Context, tenant *capsulev1beta2.Ten return err } - r.Log.V(4).Info("LimitRange sync result: "+string(res), "name", target.Name, "namespace", target.Namespace) + log.Info("LimitRange sync result: "+string(res), "name", target.Name, "namespace", target.Namespace) } return nil diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index 6bba91b2..da77512a 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -236,13 +236,13 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller } func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ctrl.Result, err error) { - r.Log = r.Log.WithValues("Request.Name", request.Name) + log := r.Log.WithValues("Request.Name", request.Name) // Fetch the Tenant instance instance := &capsulev1beta2.Tenant{} if err = r.Get(ctx, request.NamespacedName, instance); err != nil { if apierrors.IsNotFound(err) { - r.Log.V(5).Info("request object not found, could have been deleted after reconcile request") + log.V(5).Info("request object not found, could have been deleted after reconcile request") // If tenant was deleted or cannot be found, clean up metrics r.Metrics.DeleteAllMetricsForTenant(request.Name) @@ -266,7 +266,7 @@ func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result c return reconcile.Result{}, updateErr } - reconcileError := r.reconcile(ctx, instance) + reconcileError := r.reconcile(ctx, log, instance) defer func() { r.syncTenantStatusMetrics(instance) @@ -293,7 +293,7 @@ func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result c } // Collect available resources - if err = r.collectAvailableResources(ctx, instance); err != nil { + if err = r.collectAvailableResources(ctx, log, instance); err != nil { err = fmt.Errorf("cannot collect available resources: %w", err) return reconcile.Result{}, err @@ -306,7 +306,7 @@ func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result c return reconcile.Result{}, reconcileError } -func (r *Manager) reconcile(ctx context.Context, instance *capsulev1beta2.Tenant) (err error) { +func (r *Manager) reconcile(ctx context.Context, log logr.Logger, instance *capsulev1beta2.Tenant) (err error) { var errs []error // Collect Ownership/Promotions for Status @@ -315,9 +315,9 @@ func (r *Manager) reconcile(ctx context.Context, instance *capsulev1beta2.Tenant } // Reconcile Namespaces - r.Log.V(4).Info("starting processing of Namespaces", "items", len(instance.Status.Namespaces)) + log.V(4).Info("starting processing of Namespaces", "items", len(instance.Status.Namespaces)) - if err = r.reconcileNamespaces(ctx, instance); err != nil { + if err = r.reconcileNamespaces(ctx, log, instance); err != nil { errs = append(errs, fmt.Errorf("namespace(s) had reconciliation errors: %w", err)) } @@ -328,37 +328,37 @@ func (r *Manager) reconcile(ctx context.Context, instance *capsulev1beta2.Tenant } // Ensuring ResourceQuota - r.Log.V(4).Info("ensuring limit resources count is updated") + log.V(4).Info("ensuring limit resources count is updated") if err = r.syncCustomResourceQuotaUsages(ctx, instance); err != nil { errs = append(errs, fmt.Errorf("cannot count limited resources: %w", err)) } // Ensuring NetworkPolicy resources - r.Log.V(4).Info("starting processing of Network Policies") + log.V(4).Info("starting processing of Network Policies") - if err = r.syncNetworkPolicies(ctx, instance); err != nil { + if err = r.syncNetworkPolicies(ctx, log, instance); err != nil { errs = append(errs, fmt.Errorf("cannot sync networkPolicy items: %w", err)) } // Ensuring LimitRange resources - r.Log.V(4).Info("Starting processing of Limit Ranges", "items", len(instance.Spec.LimitRanges.Items)) //nolint:staticcheck + r.Log.V(4).Info("Starting processing of Limit Ranges", "items", len(instance.Spec.LimitRanges.Items)) - if err = r.syncLimitRanges(ctx, instance); err != nil { + if err = r.syncLimitRanges(ctx, log, instance); err != nil { errs = append(errs, fmt.Errorf("cannot sync limitrange items: %w", err)) } // Ensuring ResourceQuota resources - r.Log.V(4).Info("Starting processing of Resource Quotas", "items", len(instance.Spec.ResourceQuota.Items)) + log.V(4).Info("Starting processing of Resource Quotas", "items", len(instance.Spec.ResourceQuota.Items)) - if err = r.syncResourceQuotas(ctx, instance); err != nil { + if err = r.syncResourceQuotas(ctx, log, instance); err != nil { errs = append(errs, fmt.Errorf("cannot sync resourcequota items: %w", err)) } // Ensuring RoleBinding resources - r.Log.V(4).Info("Ensuring RoleBindings for Owners and Tenant") + log.V(4).Info("Ensuring RoleBindings for Owners and Tenant") - if err = r.syncRoleBindings(ctx, r.Log, instance); err != nil { + if err = r.syncRoleBindings(ctx, log, instance); err != nil { errs = append(errs, fmt.Errorf("cannot sync rolebindings items: %w", err)) } @@ -366,7 +366,7 @@ func (r *Manager) reconcile(ctx context.Context, instance *capsulev1beta2.Tenant return err } - r.Log.V(4).Info("Tenant reconciling completed") + log.V(4).Info("Tenant reconciling completed") return err } diff --git a/internal/controllers/tenant/namespaces.go b/internal/controllers/tenant/namespaces.go index 41846568..49000b31 100644 --- a/internal/controllers/tenant/namespaces.go +++ b/internal/controllers/tenant/namespaces.go @@ -9,6 +9,7 @@ import ( "fmt" "maps" + "github.com/go-logr/logr" "golang.org/x/sync/errgroup" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -24,7 +25,7 @@ import ( ) // Ensuring all annotations are applied to each Namespace handled by the Tenant. -func (r *Manager) reconcileNamespaces(ctx context.Context, tnt *capsulev1beta2.Tenant) (err error) { +func (r *Manager) reconcileNamespaces(ctx context.Context, log logr.Logger, tnt *capsulev1beta2.Tenant) (err error) { if tnt.DeletionTimestamp != nil { for _, ns := range tnt.Status.Spaces { ns := &corev1.Namespace{ @@ -36,7 +37,7 @@ func (r *Manager) reconcileNamespaces(ctx context.Context, tnt *capsulev1beta2.T if err := r.Delete(ctx, ns, &client.DeleteOptions{ PropagationPolicy: ptr.To(metav1.DeletePropagationBackground), }); err != nil && !apierrors.IsNotFound(err) { - r.Log.Error(err, "unable to delete tenant namespace", + log.Error(err, "unable to delete tenant namespace", "tenant", tnt.GetName(), "namespace", ns.Name, ) @@ -66,13 +67,13 @@ func (r *Manager) reconcileNamespaces(ctx context.Context, tnt *capsulev1beta2.T ns := list.Items[i].DeepCopy() group.Go(func() error { - stat, err := r.reconcileNamespace(ctx, ns, tnt) + stat, err := r.reconcileNamespace(ctx, log, ns, tnt) if stat != nil { results <- stat } if err != nil { - r.Log.Error(err, "failed to reconcile namespace", + log.Error(err, "failed to reconcile namespace", "tenant", tnt.GetName(), "namespace", ns.GetName(), ) @@ -125,7 +126,7 @@ func (r *Manager) reconcileNamespaces(ctx context.Context, tnt *capsulev1beta2.T return err } -func (r *Manager) reconcileNamespace(ctx context.Context, namespace *corev1.Namespace, tnt *capsulev1beta2.Tenant) ( +func (r *Manager) reconcileNamespace(ctx context.Context, log logr.Logger, namespace *corev1.Namespace, tnt *capsulev1beta2.Tenant) ( stat *capsulev1beta2.TenantStatusNamespaceItem, err error, ) { @@ -246,7 +247,7 @@ func (r *Manager) reconcileNamespace(ctx context.Context, namespace *corev1.Name } // Collect Rules for namespace - err = r.reconcileRuleStatus(ctx, tnt, namespace) + err = r.reconcileRuleStatus(ctx, log, tnt, namespace) if err != nil { return stat, err } diff --git a/internal/controllers/tenant/networkpolicies.go b/internal/controllers/tenant/networkpolicies.go index 69e18732..4fb43492 100644 --- a/internal/controllers/tenant/networkpolicies.go +++ b/internal/controllers/tenant/networkpolicies.go @@ -1,7 +1,6 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -//nolint:dupl package tenant import ( @@ -9,6 +8,7 @@ import ( "fmt" "strconv" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -22,25 +22,23 @@ import ( // Ensuring all the NetworkPolicies are applied to each Namespace handled by the Tenant. // -func (r *Manager) syncNetworkPolicies(ctx context.Context, tenant *capsulev1beta2.Tenant) error { - keys := make([]string, 0, len(tenant.Spec.NetworkPolicies.Items)) //nolint:staticcheck +func (r *Manager) syncNetworkPolicies(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) error { + keys := make([]string, 0, len(tenant.Spec.NetworkPolicies.Items)) - //nolint:staticcheck for i := range tenant.Spec.NetworkPolicies.Items { keys = append(keys, strconv.Itoa(i)) } return runForTenantNamespaces(ctx, tenant, func(ctx context.Context, namespace string) error { - return r.syncNetworkPolicy(ctx, tenant, namespace, keys) + return r.syncNetworkPolicy(ctx, log, tenant, namespace, keys) }) } -func (r *Manager) syncNetworkPolicy(ctx context.Context, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) { +func (r *Manager) syncNetworkPolicy(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) { if err = r.pruningResources(ctx, namespace, keys, &networkingv1.NetworkPolicy{}); err != nil { return err } - //nolint:staticcheck for i, spec := range tenant.Spec.NetworkPolicies.Items { target := &networkingv1.NetworkPolicy{ ObjectMeta: metav1.ObjectMeta{ @@ -71,7 +69,7 @@ func (r *Manager) syncNetworkPolicy(ctx context.Context, tenant *capsulev1beta2. }) if err != nil { if apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { - r.Log.V(4).Info( + log.V(4).Info( "skipping NetworkPolicy sync because namespace is terminating", "name", target.Name, "namespace", target.Namespace, @@ -84,7 +82,7 @@ func (r *Manager) syncNetworkPolicy(ctx context.Context, tenant *capsulev1beta2. return err } - r.Log.V(4).Info("network Policy sync result: "+string(res), "name", target.Name, "namespace", target.Namespace) + log.V(4).Info("network Policy sync result: "+string(res), "name", target.Name, "namespace", target.Namespace) } return nil diff --git a/internal/controllers/tenant/resourcequotas.go b/internal/controllers/tenant/resourcequotas.go index 53c04652..b2dc5d17 100644 --- a/internal/controllers/tenant/resourcequotas.go +++ b/internal/controllers/tenant/resourcequotas.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/go-logr/logr" "golang.org/x/sync/errgroup" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -43,7 +44,7 @@ import ( // In case of Namespace-scoped Resource Budget, we're just replicating the resources across all registered Namespaces. //nolint:cyclop -func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) { //nolint:gocognit +func (r *Manager) syncResourceQuotas(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) { //nolint:gocognit // Remove prior metrics, to avoid cleaning up for metrics of deleted ResourceQuotas r.Metrics.DeleteTenantResourceMetrics(tenant.Name) // Expose the namespace quota and usage as metrics for the tenant @@ -78,20 +79,20 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 var tntRequirement *labels.Requirement if tntRequirement, scopeErr = labels.NewRequirement(meta.NewTenantLabel, selection.Equals, []string{tenant.Name}); scopeErr != nil { - r.Log.Error(scopeErr, "cannot build ResourceQuota Tenant requirement") + log.Error(scopeErr, "cannot build ResourceQuota Tenant requirement") } // Requirement to list ResourceQuota for the current index var indexRequirement *labels.Requirement if indexRequirement, scopeErr = labels.NewRequirement(meta.ResourceQuotaLabel, selection.Equals, []string{strconv.Itoa(index)}); scopeErr != nil { - r.Log.Error(scopeErr, "cannot build ResourceQuota index requirement") + log.Error(scopeErr, "cannot build ResourceQuota index requirement") } // Listing all the ResourceQuota according to the said requirements. // These are required since Capsule is going to sum all the used quota to // sum them and get the Tenant one. list := &corev1.ResourceQuotaList{} if scopeErr = r.reader.List(ctx, list, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*tntRequirement).Add(*indexRequirement)}); scopeErr != nil { - r.Log.Error(scopeErr, "cannot list ResourceQuota", "tenantFilter", tntRequirement.String(), "indexFilter", indexRequirement.String()) + log.Error(scopeErr, "cannot list ResourceQuota", "tenantFilter", tntRequirement.String(), "indexFilter", indexRequirement.String()) return scopeErr } @@ -102,7 +103,7 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 // For this case, we're going to block the Quota setting the Hard as the // used one. for name, hardQuota := range resourceQuota.Hard { - r.Log.V(4).Info("desired hard " + name.String() + " quota is " + hardQuota.String()) + log.V(4).Info("desired hard " + name.String() + " quota is " + hardQuota.String()) // Getting the whole usage across all the Tenant Namespaces var quantity resource.Quantity @@ -110,7 +111,7 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 quantity.Add(item.Status.Used[name]) } - r.Log.V(4).Info("computed " + name.String() + " quota for the whole Tenant is " + quantity.String()) + log.V(4).Info("computed " + name.String() + " quota for the whole Tenant is " + quantity.String()) // Expose usage and limit metrics for the resource (name) of the ResourceQuota (index) r.Metrics.TenantResourceUsageGauge.WithLabelValues( @@ -171,8 +172,8 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 } } - if scopeErr = r.resourceQuotasUpdate(ctx, name, quantity, toKeep, resourceQuota.Hard[name], list.Items...); scopeErr != nil { - r.Log.Error(scopeErr, "cannot proceed with outer ResourceQuota") + if scopeErr = r.resourceQuotasUpdate(ctx, log, name, quantity, toKeep, resourceQuota.Hard[name], list.Items...); scopeErr != nil { + log.Error(scopeErr, "cannot proceed with outer ResourceQuota") return scopeErr } @@ -214,14 +215,14 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 } group.Go(func() error { - return r.syncResourceQuota(ctx, tenant, namespace, keys) + return r.syncResourceQuota(ctx, log, tenant, namespace, keys) }) } return group.Wait() } -func (r *Manager) syncResourceQuota(ctx context.Context, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) { +func (r *Manager) syncResourceQuota(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) { // getting ResourceQuota labels for the mutateFn var typeLabel string @@ -274,7 +275,7 @@ func (r *Manager) syncResourceQuota(ctx context.Context, tenant *capsulev1beta2. }) if err != nil { if apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { - r.Log.V(4).Info( + log.V(4).Info( "skipping ResourceQuota sync because namespace is terminating", "name", target.Name, "namespace", target.Namespace, @@ -287,7 +288,7 @@ func (r *Manager) syncResourceQuota(ctx context.Context, tenant *capsulev1beta2. return err } - r.Log.V(4).Info("resource Quota sync result: "+string(res), "name", target.Name, "namespace", target.Namespace) + log.V(4).Info("resource Quota sync result: "+string(res), "name", target.Name, "namespace", target.Namespace) } return nil @@ -296,7 +297,15 @@ func (r *Manager) syncResourceQuota(ctx context.Context, tenant *capsulev1beta2. // Serial ResourceQuota processing is expensive: using Go routines we can speed it up. // In case of multiple errors these are logged properly, returning a generic error since we have to repush back the // reconciliation loop. -func (r *Manager) resourceQuotasUpdate(ctx context.Context, resourceName corev1.ResourceName, actual resource.Quantity, toKeep sets.Set[corev1.ResourceName], limit resource.Quantity, list ...corev1.ResourceQuota) (err error) { +func (r *Manager) resourceQuotasUpdate( + ctx context.Context, + log logr.Logger, + resourceName corev1.ResourceName, + actual resource.Quantity, + toKeep sets.Set[corev1.ResourceName], + limit resource.Quantity, + list ...corev1.ResourceQuota, +) (err error) { group := new(errgroup.Group) annotationsToKeep := sets.New[string]() @@ -372,7 +381,7 @@ func (r *Manager) resourceQuotasUpdate(ctx context.Context, resourceName corev1. if err = group.Wait(); err != nil { // We had an error and we mark the whole transaction as failed // to process it another time according to the Tenant controller back-off factor. - r.Log.Error(err, "cannot update outer ResourceQuotas", "resourceName", resourceName.String()) + log.Error(err, "cannot update outer ResourceQuotas", "resourceName", resourceName.String()) err = fmt.Errorf("update of outer ResourceQuota items has failed: %w", err) } diff --git a/internal/controllers/tenant/rolebindings.go b/internal/controllers/tenant/rolebindings.go index e3e83fd3..3a11a9f8 100644 --- a/internal/controllers/tenant/rolebindings.go +++ b/internal/controllers/tenant/rolebindings.go @@ -57,12 +57,13 @@ func (r *Manager) syncRoleBindings(ctx context.Context, log logr.Logger, tenant } return runForTenantNamespaces(ctx, tenant, func(ctx context.Context, namespace string) error { - return r.syncAdditionalRoleBinding(ctx, tenant, namespace, namespaceBindings[namespace]) + return r.syncAdditionalRoleBinding(ctx, log, tenant, namespace, namespaceBindings[namespace]) }) } func (r *Manager) syncAdditionalRoleBinding( ctx context.Context, + log logr.Logger, tenant *capsulev1beta2.Tenant, ns string, bindings map[string]rbac.AdditionalRoleBindingsSpec, @@ -114,7 +115,7 @@ func (r *Manager) syncAdditionalRoleBinding( }) if err != nil { if apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { - r.Log.V(4).Info( + log.V(4).Info( "skipping RoleBinding sync because namespace is terminating", "name", target.Name, "namespace", target.Namespace, @@ -127,7 +128,7 @@ func (r *Manager) syncAdditionalRoleBinding( return fmt.Errorf("%w (role: %s)", err, roleBinding.ClusterRoleName) } - r.Log.V(4).Info(fmt.Sprintf("roleBinding sync result: %s", string(res)), "name", target.Name, "namespace", target.Namespace) + log.V(4).Info(fmt.Sprintf("roleBinding sync result: %s", string(res)), "name", target.Name, "namespace", target.Namespace) } // Prune at finish to prevent gaps diff --git a/internal/controllers/tenant/rulestatus.go b/internal/controllers/tenant/rulestatus.go index a0b3dc97..6c8827c6 100644 --- a/internal/controllers/tenant/rulestatus.go +++ b/internal/controllers/tenant/rulestatus.go @@ -6,6 +6,7 @@ package tenant import ( "context" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -19,6 +20,7 @@ import ( func (r *Manager) reconcileRuleStatus( ctx context.Context, + log logr.Logger, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, ) error { @@ -30,6 +32,7 @@ func (r *Manager) reconcileRuleStatus( return r.ensureRuleStatus( ctx, + log, tnt, ns, ruleBody, @@ -38,6 +41,7 @@ func (r *Manager) reconcileRuleStatus( func (r *Manager) ensureRuleStatus( ctx context.Context, + log logr.Logger, tnt *capsulev1beta2.Tenant, namespace *corev1.Namespace, body *api.NamespaceRuleBodyNamespace, @@ -68,7 +72,7 @@ func (r *Manager) ensureRuleStatus( }) if err != nil { if apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { - r.Log.V(4).Info( + log.V(4).Info( "skipping RuleStatus sync because namespace is terminating", "name", rule.Name, "namespace", rule.Namespace, diff --git a/internal/controllers/tenant/status.go b/internal/controllers/tenant/status.go index 51500b0a..dd6787d1 100644 --- a/internal/controllers/tenant/status.go +++ b/internal/controllers/tenant/status.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" + "github.com/go-logr/logr" nodev1 "k8s.io/api/node/v1" resources "k8s.io/api/resource/v1" schedulingv1 "k8s.io/api/scheduling/v1" @@ -19,7 +20,6 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -148,9 +148,7 @@ func (r *Manager) collectRBAC(ctx context.Context, tnt *capsulev1beta2.Tenant) ( return nil } -func (r *Manager) collectAvailableResources(ctx context.Context, tnt *capsulev1beta2.Tenant) (err error) { - log := log.FromContext(ctx) - +func (r *Manager) collectAvailableResources(ctx context.Context, log logr.Logger, tnt *capsulev1beta2.Tenant) (err error) { if r.classes.device { log.V(5).Info("collecting available deviceclasses") @@ -344,7 +342,6 @@ func listObjectNamesBySelector( var regex *regexp.Regexp - //nolint:staticcheck if allowed.Regex != "" { regex, err = regexp.Compile(allowed.Regex) if err != nil { diff --git a/internal/controllers/tenant/utils.go b/internal/controllers/tenant/utils.go index 89a5c2a7..f32e93a5 100644 --- a/internal/controllers/tenant/utils.go +++ b/internal/controllers/tenant/utils.go @@ -14,11 +14,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" - "k8s.io/client-go/util/workqueue" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" @@ -75,54 +72,6 @@ func runForTenantNamespaces( return errors.Join(joined...) } -func (r *Manager) enqueueForTenantsWithCondition( - ctx context.Context, - obj client.Object, - q workqueue.TypedRateLimitingInterface[reconcile.Request], - fn func(*capsulev1beta2.Tenant, client.Object) bool, -) { - var tenants capsulev1beta2.TenantList - if err := r.List(ctx, &tenants); err != nil { - r.Log.Error(err, "failed to list Tenants for class event") - - return - } - - for i := range tenants.Items { - tnt := &tenants.Items[i] - - if !fn(tnt, obj) { - continue - } - - q.Add(reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: tnt.Name, - }, - }) - } -} - -func (r *Manager) enqueueAllTenants(ctx context.Context, _ client.Object) []reconcile.Request { - var tenants capsulev1beta2.TenantList - if err := r.List(ctx, &tenants); err != nil { - r.Log.Error(err, "failed to list Tenants for class event") - - return nil - } - - reqs := make([]reconcile.Request, 0, len(tenants.Items)) - for i := range tenants.Items { - reqs = append(reqs, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: tenants.Items[i].Name, - }, - }) - } - - return reqs -} - // pruningResources is taking care of removing the no more requested sub-resources as LimitRange, ResourceQuota or // NetworkPolicy using the "exists" and "notin" LabelSelector to perform an outer-join removal. func (r *Manager) pruningResources(ctx context.Context, ns string, keys []string, obj client.Object) (err error) { diff --git a/internal/controllers/tenantowner/enqueue.go b/internal/controllers/tenantowner/enqueue.go new file mode 100644 index 00000000..46e6f423 --- /dev/null +++ b/internal/controllers/tenantowner/enqueue.go @@ -0,0 +1,68 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package tenantowners + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + indexer "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantowner" +) + +func (r *TenantOwnerManager) enqueueTenantOwnerRequests( + ctx context.Context, + q workqueue.TypedRateLimitingInterface[ctrl.Request], + owners rbac.OwnerStatusListSpec, +) { + seen := make(map[types.NamespacedName]struct{}, len(owners)) + + for _, owner := range owners { + if owner.Name == "" { + continue + } + + tenantOwnerList := &capsulev1beta2.TenantOwnerList{} + if err := r.List( + ctx, + tenantOwnerList, + client.MatchingFields{ + indexer.NameIndexerFieldName: owner.Name, + }, + ); err != nil { + r.Log.Error( + err, + "Failed to list TenantOwners by spec.name", + "ownerName", owner.Name, + "ownerKind", owner.Kind, + ) + + continue + } + + for _, tenantOwner := range tenantOwnerList.Items { + if tenantOwner.Spec.Kind != owner.Kind { + continue + } + + key := types.NamespacedName{ + Name: tenantOwner.Name, + } + + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + + q.Add(ctrl.Request{ + NamespacedName: key, + }) + } + } +} diff --git a/internal/controllers/tenantowner/manager.go b/internal/controllers/tenantowner/manager.go new file mode 100644 index 00000000..fb77a809 --- /dev/null +++ b/internal/controllers/tenantowner/manager.go @@ -0,0 +1,231 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package tenantowners + +import ( + "context" + "fmt" + "sort" + + "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "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/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/controllers/utils" + capmeta "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + indexer "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenant" + "github.com/projectcapsule/capsule/pkg/tenant" +) + +// TenantOwnerManager reconciles TenantOwner objects and keeps +// status.tenants / status.matchedTenants / status.conditions in sync. +// +// It watches both TenantOwner objects (for spec changes) and Tenant objects +// (for selector changes), re-enqueuing every TenantOwner whenever any Tenant +// changes, because a single Tenant change can affect any subset of TenantOwners. +type TenantOwnerManager struct { + client.Client + + reader client.Reader + + Log logr.Logger +} + +func (r *TenantOwnerManager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { + r.reader = mgr.GetAPIReader() + + return ctrl.NewControllerManagedBy(mgr). + Named("capsule/tenant-owner-status"). + For( + &capsulev1beta2.TenantOwner{}, + builder.WithPredicates( + predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.LabelChangedPredicate{}, + ), + ), + ). + Watches( + &capsulev1beta2.Tenant{}, + handler.TypedFuncs[client.Object, ctrl.Request]{ + CreateFunc: func( + ctx context.Context, + e event.TypedCreateEvent[client.Object], + q workqueue.TypedRateLimitingInterface[reconcile.Request], + ) { + tnt, ok := e.Object.(*capsulev1beta2.Tenant) + if !ok { + return + } + + r.enqueueTenantOwnerRequests(ctx, q, tnt.Status.Owners) + }, + UpdateFunc: func( + ctx context.Context, + e event.TypedUpdateEvent[client.Object], + q workqueue.TypedRateLimitingInterface[reconcile.Request], + ) { + oldTnt, ok1 := e.ObjectOld.(*capsulev1beta2.Tenant) + + newTnt, ok2 := e.ObjectNew.(*capsulev1beta2.Tenant) + if !ok1 || !ok2 { + return + } + + owners := make( + rbac.OwnerStatusListSpec, + 0, + len(oldTnt.Status.Owners)+len(newTnt.Status.Owners), + ) + + owners = append(owners, oldTnt.Status.Owners...) + owners = append(owners, newTnt.Status.Owners...) + + r.enqueueTenantOwnerRequests(ctx, q, owners) + }, + + DeleteFunc: func( + ctx context.Context, + e event.TypedDeleteEvent[client.Object], + q workqueue.TypedRateLimitingInterface[reconcile.Request], + ) { + tnt, ok := e.Object.(*capsulev1beta2.Tenant) + if !ok { + return + } + + r.enqueueTenantOwnerRequests(ctx, q, tnt.Status.Owners) + }, + }, + ). + Complete(r) +} + +func (r *TenantOwnerManager) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { + log := r.Log.WithValues("tenantowner", req.Name) + + instance := &capsulev1beta2.TenantOwner{} + if err = r.Get(ctx, req.NamespacedName, instance); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + + return reconcile.Result{}, err + } + + matchedTenants, reconcileErr := r.reconcileMatchedTenants(ctx, log, instance) + + log.V(5).Info("matched tenants", "count", len(matchedTenants)) + + if statusErr := r.updateTenantOwnerStatus(ctx, instance, matchedTenants, reconcileErr); statusErr != nil { + if apierrors.IsNotFound(statusErr) { + return reconcile.Result{}, nil + } + + return reconcile.Result{}, fmt.Errorf("cannot update TenantOwner status: %w", statusErr) + } + + return reconcile.Result{}, reconcileErr +} + +// reconcileMatchedTenants lists all Tenants and returns the sorted names of +// those whose spec.permissions.matchOwners selectors select this TenantOwner. +// All work is done against the in-memory cache — no direct API server calls. +func (r *TenantOwnerManager) reconcileMatchedTenants(ctx context.Context, log logr.Logger, to *capsulev1beta2.TenantOwner) ([]string, error) { + if to.Spec.Name == "" || to.Spec.Kind.String() == "" { + return nil, fmt.Errorf( + "TenantOwner %s has incomplete owner reference: kind=%q name=%q", + to.Name, + to.Spec.Kind, + to.Spec.Name, + ) + } + + ownerKey := tenant.OwnerKindIndexKey(to.Spec.Kind.String(), to.Spec.Name) + + tnts := &capsulev1beta2.TenantList{} + + if err := r.List( + ctx, + tnts, + client.MatchingFields{ + indexer.OwnerKindIndexerFieldName: ownerKey, + }, + ); err != nil { + return nil, fmt.Errorf( + "listing Tenants by owner %s/%s: %w", + to.Spec.Kind, + to.Spec.Name, + err, + ) + } + + log.V(4).Info("found tenant references", "tenants", len(tnts.Items)) + + matched := make([]string, 0, len(tnts.Items)) + + for i := range tnts.Items { + matched = append(matched, tnts.Items[i].Name) + } + + sort.Strings(matched) + + return matched, nil +} + +// updateTenantOwnerStatus writes the reconciled status back to the API server +// using a RetryOnConflict re-GET loop to avoid clobbering concurrent updates. +func (r *TenantOwnerManager) updateTenantOwnerStatus( + ctx context.Context, + instance *capsulev1beta2.TenantOwner, + matchedTenants []string, + reconcileError error, +) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + latest := &capsulev1beta2.TenantOwner{} + if err := r.reader.Get(ctx, types.NamespacedName{Name: instance.Name}, latest); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return err + } + + latest.Status.ObservedGeneration = latest.GetGeneration() + + latest.Status.Tenants = matchedTenants + + readyCondition := capmeta.NewReadyCondition(latest) + if reconcileError != nil { + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = capmeta.FailedReason + readyCondition.Message = reconcileError.Error() + } + + latest.Status.Conditions.UpdateConditionByType(readyCondition) + + if err := r.Client.Status().Update(ctx, latest); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return err + } + + instance.Status = latest.Status + + return nil + }) +} diff --git a/internal/controllers/tls/manager.go b/internal/controllers/tls/manager.go index 2b2e979c..2a2ca45c 100644 --- a/internal/controllers/tls/manager.go +++ b/internal/controllers/tls/manager.go @@ -1,11 +1,12 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 +//nolint:nestif package tls import ( + "bytes" "context" - "crypto/rsa" "crypto/x509" "fmt" "time" @@ -27,7 +28,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/projectcapsule/capsule/pkg/runtime/cert" - capsuleclient "github.com/projectcapsule/capsule/pkg/runtime/client" "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/predicates" ) @@ -35,11 +35,6 @@ import ( const ( certificateExpirationThreshold = 3 * 24 * time.Hour certificateValidity = 6 * 30 * 24 * time.Hour - - // caPrivateKeyKey is intentionally not a Kubernetes core constant. - // The TLS Secret remains type kubernetes.io/tls, but we persist the CA key - // so serving cert renewal does not require CA rotation. - caPrivateKeyKey = "ca.key" ) type Reconciler struct { @@ -56,7 +51,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return []reconcile.Request{ { NamespacedName: types.NamespacedName{ - Namespace: r.Namespace, + Namespace: configuration.ControllerNamespace(), Name: r.Configuration.TLSSecretName(), }, }, @@ -104,7 +99,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { } func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { - r.Log = r.Log.WithValues( + log := r.Log.WithValues( "Request.Namespace", request.Namespace, "Request.Name", request.Name, ) @@ -117,6 +112,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl. request.Name = r.Configuration.TLSSecretName() } + log.V(4).Info("TLS reconciliation started") + certSecret := &corev1.Secret{} if err := r.Get(ctx, request.NamespacedName, certSecret); err != nil { if !apierrors.IsNotFound(err) { @@ -129,7 +126,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl. certSecret.Data = map[string][]byte{} } - if err := r.ReconcileCertificates(ctx, certSecret); err != nil { + if err := r.ReconcileCertificates(ctx, log, certSecret); err != nil { return ctrl.Result{}, err } @@ -142,7 +139,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl. requeueAfter := max(time.Until(requeueTime), 0) - r.Log.V(4).Info("TLS reconciliation completed", "requeueAfter", requeueAfter.String()) + log.V(4).Info("TLS reconciliation completed", "requeueAfter", requeueAfter.String()) return ctrl.Result{ Requeue: true, @@ -150,27 +147,43 @@ func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl. }, nil } -func (r *Reconciler) ReconcileCertificates(ctx context.Context, certSecret *corev1.Secret) error { - dnsName := r.webhookDNSName() - - ca, caBundle, rotateServingCert, err := r.ensureCertificateMaterial(certSecret, dnsName) +func (r *Reconciler) ReconcileCertificates( + ctx context.Context, + log logr.Logger, + certSecret *corev1.Secret, +) error { + sans, err := r.desiredWebhookSANs(ctx) if err != nil { return err } + log.V(4).Info( + "Resolved desired webhook certificate SANs", + "dnsNames", sans.DNSNames, + "ipAddresses", cert.IPsToStrings(sans.IPAddrs), + ) + + ca, caBundle, rotateServingCert, err := r.ensureCertificateMaterial(log, certSecret, sans) + if err != nil { + return err + } + + log.V(4).Info( + "certificate requires rotation", + "rotation", rotateServingCert, + ) + if rotateServingCert { if ca == nil { return fmt.Errorf("cannot rotate serving certificate without CA private key") } - r.Log.V(3).Info("Generating new serving TLS certificate", "dnsName", dnsName) - crt, key, err := ca.GenerateCertificate(cert.NewCertOpts( time.Now().Add(certificateValidity), - dnsName, + sans, )) if err != nil { - r.Log.Error(err, "cannot generate serving TLS certificate") + log.Error(err, "cannot generate serving TLS certificate") return err } @@ -178,7 +191,7 @@ func (r *Reconciler) ReconcileCertificates(ctx context.Context, certSecret *core certSecret.Data[corev1.TLSCertKey] = crt.Bytes() certSecret.Data[corev1.TLSPrivateKeyKey] = key.Bytes() - if err := r.validateSecretCertificate(certSecret, dnsName); err != nil { + if err := r.validateSecretCertificate(certSecret, sans); err != nil { return err } @@ -192,18 +205,10 @@ func (r *Reconciler) ReconcileCertificates(ctx context.Context, certSecret *core return fmt.Errorf("missing %q field in %q secret", corev1.ServiceAccountRootCAKey, r.Configuration.TLSSecretName()) } - r.Log.V(4).Info("Patching caBundle in webhooks and managed CRD conversions") + log.V(5).Info("Patching caBundle in managed CRD conversions") patchGroup, groupCtx := errgroup.WithContext(ctx) - patchGroup.Go(func() error { - return r.patchMutatingWebhookConfigurationCABundle(groupCtx, caBundle) - }) - - patchGroup.Go(func() error { - return r.patchValidatingWebhookConfigurationCABundle(groupCtx, caBundle) - }) - for key, managed := range r.conversionManagedCRDs() { patchGroup.Go(func() error { if err := r.updateManagedCustomResourceDefinition(groupCtx, managed, caBundle); err != nil { @@ -227,112 +232,157 @@ func (r *Reconciler) ReconcileCertificates(ctx context.Context, certSecret *core // - Serving certificate renewal never rotates the CA. // - Legacy Secrets without ca.key rotate once into the stable format. func (r *Reconciler) ensureCertificateMaterial( + log logr.Logger, certSecret *corev1.Secret, - dnsName string, + sans cert.CertificateSANs, ) (*cert.CapsuleCA, []byte, bool, error) { + sans = sans.Normalize() + + if sans.Empty() { + return nil, nil, false, fmt.Errorf("cannot ensure TLS material without SANs") + } + if certSecret.Data == nil { certSecret.Data = map[string][]byte{} } caBundle := certSecret.Data[corev1.ServiceAccountRootCAKey] - caKey := certSecret.Data[caPrivateKeyKey] - tlsCrt := certSecret.Data[corev1.TLSCertKey] - tlsKey := certSecret.Data[corev1.TLSPrivateKeyKey] + caKey := certSecret.Data["ca.key"] - hasCA := len(caBundle) > 0 + hasCABundle := len(caBundle) > 0 hasCAKey := len(caKey) > 0 - hasServingCert := len(tlsCrt) > 0 && len(tlsKey) > 0 - // Fresh empty Secret or completely broken Secret. - if !hasCA || !hasServingCert { - r.Log.Info( - "Generating new certificate authority and serving certificate", - "reason", "missing ca.crt or serving certificate", - "secret", certSecret.Name, - "namespace", certSecret.Namespace, - ) + var ca *cert.CapsuleCA - ca, newCABundle, newCAKey, err := generateCertificateAuthorityMaterial() + rotateServingCert := false + + switch { + case hasCABundle && hasCAKey: + loadedCA, err := cert.NewCertificateAuthorityFromBytes(caBundle, caKey) if err != nil { - return nil, nil, false, err - } - - certSecret.Data[corev1.ServiceAccountRootCAKey] = newCABundle - certSecret.Data[caPrivateKeyKey] = newCAKey - - return ca, newCABundle, true, nil - } - - // Legacy mode: - // The Secret has a CA and serving cert, but not the CA private key. - // If the serving cert is still valid and chains to ca.crt, do NOT rotate now. - // Rotating here causes a temporary caBundle/server-cert mismatch during startup. - if !hasCAKey { - if err := r.validateSecretCertificate(certSecret, dnsName); err == nil { - r.Log.Info( - "TLS Secret is using legacy CA material without ca.key; keeping existing CA and serving certificate", - "secret", certSecret.Name, - "namespace", certSecret.Namespace, + log.V(3).Info( + "Existing CA material is invalid, generating new CA", + "error", err.Error(), ) - return nil, caBundle, false, nil + generatedCA, generatedCABundle, generatedCAKey, err := generateCertificateAuthorityMaterial() + if err != nil { + return nil, nil, false, err + } + + ca = generatedCA + caBundle = generatedCABundle + + certSecret.Data[corev1.ServiceAccountRootCAKey] = generatedCABundle + certSecret.Data["ca.key"] = generatedCAKey + + rotateServingCert = true + } else { + ca = loadedCA } - r.Log.Info( - "TLS Secret is missing ca.key and serving certificate is invalid or expiring; rotating CA", - "secret", certSecret.Name, - "namespace", certSecret.Namespace, + case hasCABundle && !hasCAKey: + // Legacy mode: we can validate and patch caBundle, but we cannot issue + // a new serving certificate without the CA private key. + log.V(10).Info( + "TLS Secret contains CA bundle but no CA private key; running in legacy CA mode", + "secret", client.ObjectKeyFromObject(certSecret).String(), ) - ca, newCABundle, newCAKey, err := generateCertificateAuthorityMaterial() + if err := r.validateSecretCertificate(certSecret, sans); err != nil { + return nil, nil, false, fmt.Errorf( + "TLS Secret %s contains legacy CA material without ca.key and the serving certificate is invalid: %w", + client.ObjectKeyFromObject(certSecret).String(), + err, + ) + } + + return nil, caBundle, false, nil + + default: + log.V(10).Info( + "TLS Secret is missing CA material, generating new CA", + "secret", client.ObjectKeyFromObject(certSecret).String(), + ) + + generatedCA, generatedCABundle, generatedCAKey, err := generateCertificateAuthorityMaterial() if err != nil { return nil, nil, false, err } - certSecret.Data[corev1.ServiceAccountRootCAKey] = newCABundle - certSecret.Data[caPrivateKeyKey] = newCAKey + ca = generatedCA + caBundle = generatedCABundle - return ca, newCABundle, true, nil + certSecret.Data[corev1.ServiceAccountRootCAKey] = generatedCABundle + certSecret.Data["ca.key"] = generatedCAKey + + rotateServingCert = true } - ca, err := cert.NewCertificateAuthorityFromBytes(caBundle, caKey) - if err != nil { - r.Log.Error(err, "existing CA material is invalid, regenerating CA") + servingCertPEM := certSecret.Data[corev1.TLSCertKey] + servingKeyPEM := certSecret.Data[corev1.TLSPrivateKeyKey] - newCA, newCABundle, newCAKey, err := generateCertificateAuthorityMaterial() + if len(servingCertPEM) == 0 || len(servingKeyPEM) == 0 { + log.V(10).Info( + "TLS Secret is missing serving certificate material, rotating serving certificate", + "secret", client.ObjectKeyFromObject(certSecret).String(), + ) + + rotateServingCert = true + } else { + servingCert, err := cert.GetCertificateFromBytes(servingCertPEM) if err != nil { - return nil, nil, false, err + log.V(10).Info( + "Failed to parse serving certificate, rotating serving certificate", + "secret", client.ObjectKeyFromObject(certSecret).String(), + "error", err.Error(), + ) + + rotateServingCert = true + } else { + if time.Until(servingCert.NotAfter) <= certificateExpirationThreshold { + log.V(10).Info( + "Serving certificate is close to expiry, rotating serving certificate", + "secret", client.ObjectKeyFromObject(certSecret).String(), + "notAfter", servingCert.NotAfter, + ) + + rotateServingCert = true + } + + if !sans.MatchesCertificate(servingCert) { + log.V(3).Info( + "Serving certificate SANs differ from desired SANs, rotating serving certificate", + "secret", client.ObjectKeyFromObject(certSecret).String(), + "desiredDNSNames", sans.DNSNames, + "desiredIPAddresses", cert.IPsToStrings(sans.IPAddrs), + "currentDNSNames", servingCert.DNSNames, + "currentIPAddresses", cert.IPsToStrings(servingCert.IPAddresses), + ) + + rotateServingCert = true + } + + if err := r.validateSecretCertificate(certSecret, sans); err != nil { + log.V(10).Info( + "Serving certificate failed validation, rotating serving certificate", + "secret", client.ObjectKeyFromObject(certSecret).String(), + "error", err.Error(), + ) + + rotateServingCert = true + } } - - certSecret.Data[corev1.ServiceAccountRootCAKey] = newCABundle - certSecret.Data[caPrivateKeyKey] = newCAKey - - return newCA, newCABundle, true, nil } - if err := validateCAKeyPair(caBundle, caKey); err != nil { - r.Log.Error(err, "existing CA certificate/key pair is invalid, regenerating CA") - - newCA, newCABundle, newCAKey, err := generateCertificateAuthorityMaterial() - if err != nil { - return nil, nil, false, err - } - - certSecret.Data[corev1.ServiceAccountRootCAKey] = newCABundle - certSecret.Data[caPrivateKeyKey] = newCAKey - - return newCA, newCABundle, true, nil + if rotateServingCert && ca == nil { + return nil, nil, false, fmt.Errorf( + "cannot rotate serving certificate for TLS Secret %s without CA private key", + client.ObjectKeyFromObject(certSecret).String(), + ) } - if err := r.validateSecretCertificate(certSecret, dnsName); err != nil { - r.Log.Info("serving certificate requires renewal", "reason", err.Error()) - - return ca, caBundle, true, nil - } - - r.Log.V(4).Info("Skipping TLS certificate generation as existing certificate is valid") - - return ca, caBundle, false, nil + return ca, caBundle, rotateServingCert, nil } func generateCertificateAuthorityMaterial() (*cert.CapsuleCA, []byte, []byte, error) { @@ -385,138 +435,91 @@ func (r *Reconciler) upsertTLSSecret(ctx context.Context, certSecret *corev1.Sec return nil } -func (r *Reconciler) validateSecretCertificate(secret *corev1.Secret, dnsName string) error { - if secret == nil { - return fmt.Errorf("secret is nil") - } - - if secret.Data == nil { - return fmt.Errorf("secret data is nil") - } - - caBundle := secret.Data[corev1.ServiceAccountRootCAKey] - if len(caBundle) == 0 { - return fmt.Errorf("missing %q", corev1.ServiceAccountRootCAKey) - } - - leafPEM := secret.Data[corev1.TLSCertKey] - if len(leafPEM) == 0 { - return fmt.Errorf("missing %q", corev1.TLSCertKey) - } - - keyPEM := secret.Data[corev1.TLSPrivateKeyKey] - if len(keyPEM) == 0 { - return fmt.Errorf("missing %q", corev1.TLSPrivateKeyKey) - } - - leaf, key, err := cert.GetCertificateWithPrivateKeyFromBytes(leafPEM, keyPEM) - if err != nil { - return fmt.Errorf("cannot parse serving certificate/key pair: %w", err) - } - - if err := cert.ValidateCertificate(leaf, key, certificateExpirationThreshold); err != nil { - return fmt.Errorf("serving certificate is invalid or expiring: %w", err) +func (r *Reconciler) validateSecretCertificate( + certSecret *corev1.Secret, + sans cert.CertificateSANs, +) error { + caPEM := certSecret.Data[corev1.ServiceAccountRootCAKey] + if len(caPEM) == 0 { + return fmt.Errorf("missing %q in TLS Secret %s/%s", + corev1.ServiceAccountRootCAKey, + certSecret.Namespace, + certSecret.Name, + ) } roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(caBundle) { - return fmt.Errorf("cannot parse caBundle") + if ok := roots.AppendCertsFromPEM(caPEM); !ok { + return fmt.Errorf("failed to parse %q in TLS Secret %s/%s", + corev1.ServiceAccountRootCAKey, + certSecret.Namespace, + certSecret.Name, + ) } - if _, err := leaf.Verify(x509.VerifyOptions{ - DNSName: dnsName, - Roots: roots, - KeyUsages: []x509.ExtKeyUsage{ - x509.ExtKeyUsageServerAuth, - }, - }); err != nil { - return fmt.Errorf("serving certificate does not verify against caBundle: %w", err) - } - - return nil -} - -func validateCAKeyPair(caCertPEM, caKeyPEM []byte) error { - caCert, caKey, err := cert.GetCertificateWithPrivateKeyFromBytes(caCertPEM, caKeyPEM) + leaf, err := cert.GetCertificateFromBytes(certSecret.Data[corev1.TLSCertKey]) if err != nil { - return fmt.Errorf("cannot parse CA certificate/key pair: %w", err) + return fmt.Errorf("parse serving certificate from TLS Secret %s/%s: %w", + certSecret.Namespace, + certSecret.Name, + err, + ) } - if !caCert.IsCA { - return fmt.Errorf("ca.crt is not a CA certificate") + keyPEM := certSecret.Data[corev1.TLSPrivateKeyKey] + if len(keyPEM) == 0 { + return fmt.Errorf("missing %q in TLS Secret %s/%s", + corev1.TLSPrivateKeyKey, + certSecret.Namespace, + certSecret.Name, + ) } - if !publicKeysEqual(caCert.PublicKey, &caKey.PublicKey) { - return fmt.Errorf("ca.crt does not match ca.key") + key, err := cert.GetPrivateKeyFromBytes(keyPEM) + if err != nil { + return fmt.Errorf("parse serving private key from TLS Secret %s/%s: %w", + certSecret.Namespace, + certSecret.Name, + err, + ) } - now := time.Now() - if now.Before(caCert.NotBefore) { - return fmt.Errorf("CA certificate is not valid yet") + if err := cert.ValidateCertificate(leaf, key, certificateExpirationThreshold); err != nil { + return fmt.Errorf("serving certificate/key pair is invalid or expiring: %w", err) } - if now.After(caCert.NotAfter.Add(-certificateExpirationThreshold)) { - return fmt.Errorf("CA certificate expired or expires soon") + normalized := sans.Normalize() + if normalized.Empty() { + return fmt.Errorf("cannot validate serving certificate without desired SANs") + } + + for _, dnsName := range normalized.DNSNames { + if _, err := leaf.Verify(x509.VerifyOptions{ + DNSName: dnsName, + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + }); err != nil { + return fmt.Errorf("serving certificate is not valid for DNS SAN %q: %w", dnsName, err) + } + } + + for _, ip := range normalized.IPAddrs { + if _, err := leaf.Verify(x509.VerifyOptions{ + DNSName: ip.String(), + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + }); err != nil { + return fmt.Errorf("serving certificate is not valid for IP SAN %q: %w", ip.String(), err) + } } return nil } -func publicKeysEqual(a any, b *rsa.PublicKey) bool { - pub, ok := a.(*rsa.PublicKey) - if !ok { - return false - } - - return pub.Equal(b) -} - -//nolint:dupl -func (r *Reconciler) patchValidatingWebhookConfigurationCABundle(ctx context.Context, caBundle []byte) error { - return retry.RetryOnConflict(retry.DefaultBackoff, func() error { - vw := &admissionregistrationv1.ValidatingWebhookConfiguration{} - if err := r.Get(ctx, types.NamespacedName{ - Name: string(r.Configuration.Admission().Validating.Name), - }, vw); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - - return err - } - - patches := r.validatingWebhookCABundlePatches(vw.Webhooks, caBundle) - if len(patches) == 0 { - return nil - } - - return capsuleclient.ApplyPatches(ctx, r.Client, vw, patches, "capsule-tls-controller") - }) -} - -//nolint:dupl -func (r *Reconciler) patchMutatingWebhookConfigurationCABundle(ctx context.Context, caBundle []byte) error { - return retry.RetryOnConflict(retry.DefaultBackoff, func() error { - mw := &admissionregistrationv1.MutatingWebhookConfiguration{} - if err := r.Get(ctx, types.NamespacedName{ - Name: string(r.Configuration.Admission().Mutating.Name), - }, mw); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - - return err - } - - patches := r.mutatingWebhookCABundlePatches(mw.Webhooks, caBundle) - if len(patches) == 0 { - return nil - } - - return capsuleclient.ApplyPatches(ctx, r.Client, mw, patches, "capsule-tls-controller") - }) -} - func (r *Reconciler) updateManagedCustomResourceDefinition( ctx context.Context, managed ManagedCRD, @@ -536,44 +539,26 @@ func (r *Reconciler) updateManagedCustomResourceDefinition( return err } + // Only patch CRDs that already use webhook conversion. + if crd.Spec.Conversion == nil || + crd.Spec.Conversion.Webhook == nil || + crd.Spec.Conversion.Webhook.ClientConfig == nil { + return nil + } + + current := crd.Spec.Conversion.Webhook.ClientConfig.CABundle + if bytes.Equal(current, caBundle) { + return nil + } + before := crd.DeepCopy() - path := managed.ConversionPath - if path == "" { - path = "/convert" - } - - versions := managed.ConversionReviewVersions - if len(versions) == 0 { - versions = []string{"v1", "v1beta1"} - } - - port := int32(443) - - crd.Spec.Conversion = &apiextensionsv1.CustomResourceConversion{ - Strategy: apiextensionsv1.WebhookConverter, - Webhook: &apiextensionsv1.WebhookConversion{ - ClientConfig: &apiextensionsv1.WebhookClientConfig{ - Service: &apiextensionsv1.ServiceReference{ - Namespace: r.Namespace, - Name: r.Configuration.Admission().ServiceName, - Path: &path, - Port: &port, - }, - CABundle: caBundle, - }, - ConversionReviewVersions: versions, - }, - } + crd.Spec.Conversion.Webhook.ClientConfig.CABundle = append([]byte(nil), caBundle...) return r.Patch(ctx, crd, client.MergeFrom(before)) }) } -func (r *Reconciler) webhookDNSName() string { - return fmt.Sprintf("%s.%s.svc", r.Configuration.Admission().ServiceName, r.Namespace) -} - func copySecretData(in map[string][]byte) map[string][]byte { out := make(map[string][]byte, len(in)) @@ -583,17 +568,3 @@ func copySecretData(in map[string][]byte) map[string][]byte { return out } - -func equalBytes(a, b []byte) bool { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i] != b[i] { - return false - } - } - - return true -} diff --git a/internal/controllers/tls/utils.go b/internal/controllers/tls/utils.go index 339a290c..79adbeb6 100644 --- a/internal/controllers/tls/utils.go +++ b/internal/controllers/tls/utils.go @@ -4,13 +4,15 @@ package tls import ( - "crypto/sha256" - "encoding/hex" + "context" "fmt" - admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" - capsuleclient "github.com/projectcapsule/capsule/pkg/runtime/client" + "github.com/projectcapsule/capsule/pkg/runtime/cert" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" ) type ManagedCRD struct { @@ -90,74 +92,90 @@ func (r Reconciler) conversionManagedCRDs() map[string]ManagedCRD { return out } -//nolint:dupl -func (r *Reconciler) validatingWebhookCABundlePatches( - webhooks []admissionregistrationv1.ValidatingWebhook, - caBundle []byte, -) []capsuleclient.JSONPatch { - patches := make([]capsuleclient.JSONPatch, 0, len(webhooks)) +// Collects required SANs for certificate +// desiredWebhookSANs collects required SANs for the webhook serving certificate. +func (r *Reconciler) desiredWebhookSANs(ctx context.Context) (cert.CertificateSANs, error) { + sans := cert.CertificateSANs{} - for i := range webhooks { - if webhooks[i].ClientConfig.Service == nil { - continue + sans.AddDNSNames( + r.Configuration.Admission().ServiceName, + fmt.Sprintf("%s.%s", r.Configuration.Admission().ServiceName, r.Namespace), + fmt.Sprintf("%s.%s.svc", r.Configuration.Admission().ServiceName, r.Namespace), + fmt.Sprintf("%s.%s.svc.cluster.local", r.Configuration.Admission().ServiceName, r.Namespace), + ) + + mutating := r.Configuration.Admission().Mutating.Client + if mutating != nil { + sans.AddServiceReference(mutating.Service, configuration.ControllerNamespace()) + + if err := sans.AddURL(mutating.URL); err != nil { + return cert.CertificateSANs{}, fmt.Errorf("mutating admission client URL: %w", err) } - - if equalBytes(webhooks[i].ClientConfig.CABundle, caBundle) { - continue - } - - r.Log.V(3).Info( - "Patching webhook caBundle", - "webhook", webhooks[i].Name, - "old", certFingerprint(webhooks[i].ClientConfig.CABundle), - "new", certFingerprint(caBundle), - ) - - patches = append(patches, capsuleclient.JSONPatch{ - Operation: capsuleclient.JSONPatchAdd, - Path: fmt.Sprintf("/webhooks/%d/clientConfig/caBundle", i), - Value: caBundle, - }) } - return patches -} + validating := r.Configuration.Admission().Validating.Client + if validating != nil { + sans.AddServiceReference(validating.Service, configuration.ControllerNamespace()) -//nolint:dupl -func (r *Reconciler) mutatingWebhookCABundlePatches( - webhooks []admissionregistrationv1.MutatingWebhook, - caBundle []byte, -) []capsuleclient.JSONPatch { - patches := make([]capsuleclient.JSONPatch, 0, len(webhooks)) - - for i := range webhooks { - if webhooks[i].ClientConfig.Service == nil { - continue + if err := sans.AddURL(validating.URL); err != nil { + return cert.CertificateSANs{}, fmt.Errorf("validating admission client URL: %w", err) } - - if equalBytes(webhooks[i].ClientConfig.CABundle, caBundle) { - continue - } - - r.Log.V(3).Info( - "Patching webhook caBundle", - "webhook", webhooks[i].Name, - "old", certFingerprint(webhooks[i].ClientConfig.CABundle), - "new", certFingerprint(caBundle), - ) - - patches = append(patches, capsuleclient.JSONPatch{ - Operation: capsuleclient.JSONPatchAdd, - Path: fmt.Sprintf("/webhooks/%d/clientConfig/caBundle", i), - Value: caBundle, - }) } - return patches + sans = sans.Normalize() + + if sans.Empty() { + return cert.CertificateSANs{}, fmt.Errorf("no webhook SANs could be resolved") + } + + r.Log.V(5).Info( + "Evaluated required SANs for TLS controller", + "dnsNames", sans.DNSNames, + "ipAddresses", cert.IPsToStrings(sans.IPAddrs), + ) + + return sans, nil } -func certFingerprint(pemBytes []byte) string { - sum := sha256.Sum256(pemBytes) +func FetchCurrentCaBundleForAdmission( + ctx context.Context, + c client.Reader, + cfg configuration.Configuration, + configuredCABundle []byte, +) ([]byte, error) { + // Explicit configuration wins. + if len(configuredCABundle) > 0 { + return append([]byte(nil), configuredCABundle...), nil + } - return hex.EncodeToString(sum[:8]) + // Internal Capsule TLS enabled: source of truth is the TLS Secret. + if cfg.EnableTLSConfiguration() { + secret := &corev1.Secret{} + + if err := c.Get(ctx, types.NamespacedName{ + Namespace: configuration.ControllerNamespace(), + Name: cfg.TLSSecretName(), + }, secret); err != nil { + return nil, fmt.Errorf("get TLS Secret %s/%s: %w", + configuration.ControllerNamespace(), + cfg.TLSSecretName(), + err, + ) + } + + caBundle := secret.Data[corev1.ServiceAccountRootCAKey] + if len(caBundle) == 0 { + return nil, fmt.Errorf("TLS Secret %s/%s missing %q", + secret.Namespace, + secret.Name, + corev1.ServiceAccountRootCAKey, + ) + } + + return append([]byte(nil), caBundle...), nil + } + + // cert-manager / external injector mode: + // return nil and preserve current webhook caBundle. + return nil, nil } diff --git a/pkg/runtime/cert/ca.go b/pkg/runtime/cert/ca.go index 8f194649..b24c191a 100644 --- a/pkg/runtime/cert/ca.go +++ b/pkg/runtime/cert/ca.go @@ -10,6 +10,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "fmt" "math/big" "time" @@ -141,20 +142,40 @@ func GenerateCertificateAuthority() (s *CapsuleCA, err error) { return s, err } -func GetCertificateFromBytes(certBytes []byte) (*x509.Certificate, error) { - var b *pem.Block +func GetCertificateFromBytes(raw []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("failed to decode certificate PEM") + } - b, _ = pem.Decode(certBytes) + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("expected CERTIFICATE PEM block, got %q", block.Type) + } - return x509.ParseCertificate(b.Bytes) + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + + return certificate, nil } -func GetPrivateKeyFromBytes(keyBytes []byte) (*rsa.PrivateKey, error) { - var b *pem.Block +func GetPrivateKeyFromBytes(raw []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("failed to decode private key PEM") + } - b, _ = pem.Decode(keyBytes) + if block.Type != "RSA PRIVATE KEY" { + return nil, fmt.Errorf("expected RSA PRIVATE KEY PEM block, got %q", block.Type) + } - return x509.ParsePKCS1PrivateKey(b.Bytes) + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse RSA private key: %w", err) + } + + return privateKey, nil } func GetCertificateWithPrivateKeyFromBytes(certBytes, keyBytes []byte) (*x509.Certificate, *rsa.PrivateKey, error) { @@ -171,7 +192,7 @@ func GetCertificateWithPrivateKeyFromBytes(certBytes, keyBytes []byte) (*x509.Ce return cert, key, nil } -func (c *CapsuleCA) GenerateCertificate(opts CertificateOptions) (certificatePem *bytes.Buffer, certificateKey *bytes.Buffer, err error) { +func (c *CapsuleCA) GenerateCertificate(opts CertOpts) (certificatePem *bytes.Buffer, certificateKey *bytes.Buffer, err error) { var certPrivKey *rsa.PrivateKey certPrivKey, err = rsa.GenerateKey(rand.Reader, 4096) @@ -179,6 +200,11 @@ func (c *CapsuleCA) GenerateCertificate(opts CertificateOptions) (certificatePem return nil, nil, err } + sans := opts.SAN + if sans.Empty() { + return nil, nil, fmt.Errorf("cannot generate certificate without SANs") + } + cert := &x509.Certificate{ SerialNumber: big.NewInt(1658), Subject: pkix.Name{ @@ -189,9 +215,10 @@ func (c *CapsuleCA) GenerateCertificate(opts CertificateOptions) (certificatePem StreetAddress: []string{"27, Old Gloucester Street"}, PostalCode: []string{"WC1N 3AX"}, }, - DNSNames: opts.DNSNames(), + DNSNames: sans.DNSNames, + IPAddresses: sans.IPAddrs, NotBefore: time.Now().AddDate(0, 0, -1), - NotAfter: opts.ExpirationDate(), + NotAfter: opts.GetExpirationDate(), SubjectKeyId: []byte{1, 2, 3, 4, 6}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature, diff --git a/pkg/runtime/cert/ca_test.go b/pkg/runtime/cert/ca_test.go index 8c89565d..86037873 100644 --- a/pkg/runtime/cert/ca_test.go +++ b/pkg/runtime/cert/ca_test.go @@ -8,71 +8,288 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" + "net" + "reflect" "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/projectcapsule/capsule/pkg/runtime/cert" ) func TestNewCertificateAuthorityFromBytes(t *testing.T) { - var ca *cert.CapsuleCA + t.Parallel() - var err error - - ca, err = cert.GenerateCertificateAuthority() - assert.Nil(t, err) - - var crt *bytes.Buffer - crt, err = ca.CACertificatePem() - assert.Nil(t, err) - - var key *bytes.Buffer - key, err = ca.CAPrivateKeyPem() - assert.Nil(t, err) - - _, err = cert.NewCertificateAuthorityFromBytes(crt.Bytes(), key.Bytes()) - assert.Nil(t, err) -} - -func TestCapsuleCa_GenerateCertificate(t *testing.T) { - type testCase struct { - dnsNames []string + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("expected CA generation to succeed, got %v", err) } - for name, c := range map[string]testCase{ - "foo.tld": {[]string{"foo.tld"}}, - "SAN": {[]string{"capsule-webhook-service.capsule-system.svc", "capsule-webhook-service.capsule-system.default.cluster"}}, - } { + crt, err := ca.CACertificatePem() + if err != nil { + t.Fatalf("expected CA certificate PEM encoding to succeed, got %v", err) + } + + key, err := ca.CAPrivateKeyPem() + if err != nil { + t.Fatalf("expected CA private key PEM encoding to succeed, got %v", err) + } + + loadedCA, err := cert.NewCertificateAuthorityFromBytes(crt.Bytes(), key.Bytes()) + if err != nil { + t.Fatalf("expected loading CA from PEM bytes to succeed, got %v", err) + } + + loadedCrt, err := loadedCA.CACertificatePem() + if err != nil { + t.Fatalf("expected loaded CA certificate PEM encoding to succeed, got %v", err) + } + + if !bytes.Equal(crt.Bytes(), loadedCrt.Bytes()) { + t.Fatal("expected loaded CA certificate PEM to match original CA certificate PEM") + } +} + +func TestNewCertificateAuthorityFromBytesRejectsInvalidCertificatePEM(t *testing.T) { + t.Parallel() + + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("expected CA generation to succeed, got %v", err) + } + + key, err := ca.CAPrivateKeyPem() + if err != nil { + t.Fatalf("expected CA private key PEM encoding to succeed, got %v", err) + } + + if _, err := cert.NewCertificateAuthorityFromBytes([]byte("invalid certificate"), key.Bytes()); err == nil { + t.Fatal("expected invalid certificate PEM to be rejected") + } +} + +func TestNewCertificateAuthorityFromBytesRejectsInvalidPrivateKeyPEM(t *testing.T) { + t.Parallel() + + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("expected CA generation to succeed, got %v", err) + } + + crt, err := ca.CACertificatePem() + if err != nil { + t.Fatalf("expected CA certificate PEM encoding to succeed, got %v", err) + } + + if _, err := cert.NewCertificateAuthorityFromBytes(crt.Bytes(), []byte("invalid private key")); err == nil { + t.Fatal("expected invalid private key PEM to be rejected") + } +} + +func TestCapsuleCAGenerateCertificate(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sans cert.CertificateSANs + }{ + "dns name": { + sans: cert.CertificateSANs{ + DNSNames: []string{"foo.tld"}, + }, + }, + "multiple dns names": { + sans: cert.CertificateSANs{ + DNSNames: []string{ + "capsule-webhook-service.capsule-system.svc", + "capsule-webhook-service.capsule-system.svc.cluster.local", + }, + }, + }, + "dns names are normalized": { + sans: cert.CertificateSANs{ + DNSNames: []string{ + " Capsule-Webhook-Service.Capsule-System.Svc ", + "capsule-webhook-service.capsule-system.svc", + }, + }, + }, + "ip addresses": { + sans: cert.CertificateSANs{ + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + net.ParseIP("fd00::1"), + }, + }, + }, + "dns names and ip addresses": { + sans: cert.CertificateSANs{ + DNSNames: []string{ + "capsule-webhook-service.capsule-system.svc", + }, + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + }, + }, + }, + } + + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { - var ca *cert.CapsuleCA - var err error + t.Parallel() - e := time.Now().AddDate(1, 0, 0) + expirationDate := time.Now().AddDate(1, 0, 0).UTC() - ca, err = cert.GenerateCertificateAuthority() - assert.Nil(t, err) - - var crt *bytes.Buffer - var key *bytes.Buffer - crt, key, err = ca.GenerateCertificate(cert.NewCertOpts(e, c.dnsNames...)) - assert.Nil(t, err) - - var b *pem.Block - var c *x509.Certificate - b, _ = pem.Decode(crt.Bytes()) - c, err = x509.ParseCertificate(b.Bytes) - assert.Nil(t, err) - - assert.Equal(t, e.Unix(), c.NotAfter.Unix()) - - for _, i := range c.DNSNames { - assert.Contains(t, c.DNSNames, i) + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("expected CA generation to succeed, got %v", err) } - _, err = tls.X509KeyPair(crt.Bytes(), key.Bytes()) - assert.Nil(t, err) + crt, key, err := ca.GenerateCertificate(cert.NewCertOpts(expirationDate, tt.sans)) + if err != nil { + t.Fatalf("expected serving certificate generation to succeed, got %v", err) + } + + servingCert := parseCertificatePEM(t, crt.Bytes()) + + if servingCert.NotAfter.Unix() != expirationDate.Unix() { + t.Fatalf("expected certificate NotAfter %d, got %d", expirationDate.Unix(), servingCert.NotAfter.Unix()) + } + + expectedSANs := tt.sans.Normalize() + actualSANs := cert.CertificateSANs{ + DNSNames: servingCert.DNSNames, + IPAddrs: servingCert.IPAddresses, + }.Normalize() + + assertCertificateSANsEqual(t, expectedSANs, actualSANs) + + if !tt.sans.MatchesCertificate(servingCert) { + t.Fatalf( + "expected generated certificate to match requested SANs, desiredDNSNames=%v desiredIPAddresses=%v actualDNSNames=%v actualIPAddresses=%v", + expectedSANs.DNSNames, + cert.IPsToStrings(expectedSANs.IPAddrs), + servingCert.DNSNames, + cert.IPsToStrings(servingCert.IPAddresses), + ) + } + + if _, err := tls.X509KeyPair(crt.Bytes(), key.Bytes()); err != nil { + t.Fatalf("expected generated certificate/key pair to be valid, got %v", err) + } }) } } + +func TestCapsuleCAGenerateCertificateIsSignedByCA(t *testing.T) { + t.Parallel() + + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("expected CA generation to succeed, got %v", err) + } + + expirationDate := time.Now().AddDate(1, 0, 0).UTC() + + crt, _, err := ca.GenerateCertificate(cert.NewCertOpts(expirationDate, cert.CertificateSANs{ + DNSNames: []string{"capsule-webhook-service.capsule-system.svc"}, + })) + if err != nil { + t.Fatalf("expected serving certificate generation to succeed, got %v", err) + } + + servingCert := parseCertificatePEM(t, crt.Bytes()) + + caCrt, err := ca.CACertificatePem() + if err != nil { + t.Fatalf("expected CA certificate PEM encoding to succeed, got %v", err) + } + + caCert := parseCertificatePEM(t, caCrt.Bytes()) + + roots := x509.NewCertPool() + roots.AddCert(caCert) + + verifyOptions := x509.VerifyOptions{ + DNSName: "capsule-webhook-service.capsule-system.svc", + Roots: roots, + } + + if _, err := servingCert.Verify(verifyOptions); err != nil { + t.Fatalf("expected serving certificate to verify against generated CA, got %v", err) + } +} + +func TestCapsuleCAGenerateCertificateWithIPAddressSANVerifiesForIP(t *testing.T) { + t.Parallel() + + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("expected CA generation to succeed, got %v", err) + } + + ip := net.ParseIP("10.96.0.10") + expirationDate := time.Now().AddDate(1, 0, 0).UTC() + + crt, _, err := ca.GenerateCertificate(cert.NewCertOpts(expirationDate, cert.CertificateSANs{ + IPAddrs: []net.IP{ip}, + })) + if err != nil { + t.Fatalf("expected serving certificate generation to succeed, got %v", err) + } + + servingCert := parseCertificatePEM(t, crt.Bytes()) + + caCrt, err := ca.CACertificatePem() + if err != nil { + t.Fatalf("expected CA certificate PEM encoding to succeed, got %v", err) + } + + caCert := parseCertificatePEM(t, caCrt.Bytes()) + + roots := x509.NewCertPool() + roots.AddCert(caCert) + + verifyOptions := x509.VerifyOptions{ + DNSName: ip.String(), + Roots: roots, + } + + if _, err := servingCert.Verify(verifyOptions); err != nil { + t.Fatalf("expected serving certificate to verify against generated CA for IP SAN, got %v", err) + } +} + +func parseCertificatePEM(t *testing.T, certificatePEM []byte) *x509.Certificate { + t.Helper() + + block, _ := pem.Decode(certificatePEM) + if block == nil { + t.Fatal("expected certificate PEM block, got nil") + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("expected certificate parsing to succeed, got %v", err) + } + + return certificate +} + +func assertCertificateSANsEqual(t *testing.T, expected, actual cert.CertificateSANs) { + t.Helper() + + expected = expected.Normalize() + actual = actual.Normalize() + + if !reflect.DeepEqual(expected.DNSNames, actual.DNSNames) { + t.Fatalf("expected DNS names %v, got %v", expected.DNSNames, actual.DNSNames) + } + + expectedIPs := cert.IPsToStrings(expected.IPAddrs) + actualIPs := cert.IPsToStrings(actual.IPAddrs) + + if !reflect.DeepEqual(expectedIPs, actualIPs) { + t.Fatalf("expected IP addresses %v, got %v", expectedIPs, actualIPs) + } +} diff --git a/pkg/runtime/cert/options.go b/pkg/runtime/cert/options.go index 5164a131..8a2719bf 100644 --- a/pkg/runtime/cert/options.go +++ b/pkg/runtime/cert/options.go @@ -6,23 +6,26 @@ package cert import "time" type CertificateOptions interface { - DNSNames() []string - ExpirationDate() time.Time + GetDNSNames() CertificateSANs + GetExpirationDate() time.Time } -type certOpts struct { - dnsNames []string - expirationDate time.Time +type CertOpts struct { + SAN CertificateSANs + ExpirationDate time.Time } -func (c certOpts) DNSNames() []string { - return c.dnsNames +func NewCertOpts(expirationDate time.Time, sans CertificateSANs) CertOpts { + return CertOpts{ + ExpirationDate: expirationDate, + SAN: sans.Normalize(), + } } -func (c certOpts) ExpirationDate() time.Time { - return c.expirationDate +func (c CertOpts) GetDNSNames() CertificateSANs { + return c.SAN } -func NewCertOpts(expirationDate time.Time, dnsNames ...string) CertificateOptions { - return &certOpts{dnsNames: dnsNames, expirationDate: expirationDate} +func (c CertOpts) GetExpirationDate() time.Time { + return c.ExpirationDate } diff --git a/pkg/runtime/cert/options_test.go b/pkg/runtime/cert/options_test.go new file mode 100644 index 00000000..3c59d33c --- /dev/null +++ b/pkg/runtime/cert/options_test.go @@ -0,0 +1,51 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cert + +import ( + "net" + "testing" + "time" +) + +func TestNewCertOpts(t *testing.T) { + t.Parallel() + + expirationDate := time.Now().Add(24 * time.Hour).UTC() + + opts := NewCertOpts(expirationDate, CertificateSANs{ + DNSNames: []string{ + " Webhook.Capsule-System.Svc ", + "webhook.capsule-system.svc", + "api.example.com", + }, + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + net.ParseIP("10.96.0.10"), + nil, + }, + }) + + if !opts.GetExpirationDate().Equal(expirationDate) { + t.Fatalf("expected expiration date %s, got %s", expirationDate, opts.GetExpirationDate()) + } + + expectedSANs := CertificateSANs{ + DNSNames: []string{ + "api.example.com", + "webhook.capsule-system.svc", + }, + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + }, + } + + assertCertificateSANsEqual(t, expectedSANs, opts.GetDNSNames()) +} + +func TestCertOptsImplementsCertificateOptions(t *testing.T) { + t.Parallel() + + var _ CertificateOptions = CertOpts{} +} diff --git a/pkg/runtime/cert/san.go b/pkg/runtime/cert/san.go new file mode 100644 index 00000000..84d1ec59 --- /dev/null +++ b/pkg/runtime/cert/san.go @@ -0,0 +1,161 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cert + +import ( + "crypto/x509" + "fmt" + "net" + "net/url" + "sort" + "strings" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" +) + +type CertificateSANs struct { + DNSNames []string + IPAddrs []net.IP +} + +func (s CertificateSANs) Empty() bool { + return len(s.DNSNames) == 0 && len(s.IPAddrs) == 0 +} + +func (s CertificateSANs) Normalize() CertificateSANs { + dnsSet := make(map[string]struct{}, len(s.DNSNames)) + dnsNames := make([]string, 0, len(s.DNSNames)) + + for _, name := range s.DNSNames { + name = strings.TrimSpace(strings.ToLower(name)) + if name == "" { + continue + } + + if _, ok := dnsSet[name]; ok { + continue + } + + dnsSet[name] = struct{}{} + + dnsNames = append(dnsNames, name) + } + + ipSet := make(map[string]struct{}, len(s.IPAddrs)) + ipAddrs := make([]net.IP, 0, len(s.IPAddrs)) + + for _, ip := range s.IPAddrs { + if ip == nil { + continue + } + + normalized := ip.String() + if _, ok := ipSet[normalized]; ok { + continue + } + + ipSet[normalized] = struct{}{} + + ipAddrs = append(ipAddrs, ip) + } + + sort.Strings(dnsNames) + sort.Slice(ipAddrs, func(i, j int) bool { + return ipAddrs[i].String() < ipAddrs[j].String() + }) + + return CertificateSANs{ + DNSNames: dnsNames, + IPAddrs: ipAddrs, + } +} + +func (s *CertificateSANs) AddDNSNames(names ...string) { + s.DNSNames = append(s.DNSNames, names...) +} + +func (s *CertificateSANs) AddIPAddrs(ips ...net.IP) { + s.IPAddrs = append(s.IPAddrs, ips...) +} + +func (s *CertificateSANs) AddServiceReference( + service *admissionregistrationv1.ServiceReference, + defaultNamespace string, +) { + if service == nil || service.Name == "" { + return + } + + namespace := service.Namespace + if namespace == "" { + namespace = defaultNamespace + } + + s.AddDNSNames( + service.Name, + fmt.Sprintf("%s.%s", service.Name, namespace), + fmt.Sprintf("%s.%s.svc", service.Name, namespace), + fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, namespace), + ) +} + +func (s *CertificateSANs) AddURL(rawURL *string) error { + if rawURL == nil || strings.TrimSpace(*rawURL) == "" { + return nil + } + + parsed, err := url.Parse(*rawURL) + if err != nil { + return fmt.Errorf("parse webhook URL %q: %w", *rawURL, err) + } + + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("webhook URL %q has empty host", *rawURL) + } + + if ip := net.ParseIP(host); ip != nil { + s.AddIPAddrs(ip) + + return nil + } + + s.AddDNSNames(host) + + return nil +} + +func (s CertificateSANs) MatchesCertificate(certificate *x509.Certificate) bool { + if certificate == nil { + return false + } + + desired := s.Normalize() + actual := CertificateSANs{ + DNSNames: append([]string(nil), certificate.DNSNames...), + IPAddrs: append([]net.IP(nil), certificate.IPAddresses...), + }.Normalize() + + if len(desired.DNSNames) != len(actual.DNSNames) { + return false + } + + for i := range desired.DNSNames { + if desired.DNSNames[i] != actual.DNSNames[i] { + return false + } + } + + if len(desired.IPAddrs) != len(actual.IPAddrs) { + return false + } + + for i := range desired.IPAddrs { + if desired.IPAddrs[i].String() != actual.IPAddrs[i].String() { + return false + } + } + + return true +} diff --git a/pkg/runtime/cert/san_test.go b/pkg/runtime/cert/san_test.go new file mode 100644 index 00000000..665dfae2 --- /dev/null +++ b/pkg/runtime/cert/san_test.go @@ -0,0 +1,399 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cert + +import ( + "crypto/x509" + "net" + "reflect" + "testing" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + "k8s.io/utils/ptr" +) + +func TestCertificateSANsEmpty(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sans CertificateSANs + want bool + }{ + { + name: "empty", + sans: CertificateSANs{}, + want: true, + }, + { + name: "dns names present", + sans: CertificateSANs{ + DNSNames: []string{"webhook.capsule-system.svc"}, + }, + want: false, + }, + { + name: "ip addresses present", + sans: CertificateSANs{ + IPAddrs: []net.IP{net.ParseIP("10.96.0.10")}, + }, + want: false, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.sans.Empty(); got != tt.want { + t.Fatalf("expected Empty() to be %t, got %t", tt.want, got) + } + }) + } +} + +func TestCertificateSANsNormalize(t *testing.T) { + t.Parallel() + + sans := CertificateSANs{ + DNSNames: []string{ + " Webhook.Capsule-System.Svc ", + "", + "webhook.capsule-system.svc", + "API.Example.Com", + " api.example.com ", + }, + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.20"), + nil, + net.ParseIP("10.96.0.10"), + net.ParseIP("10.96.0.20"), + }, + } + + expected := CertificateSANs{ + DNSNames: []string{ + "api.example.com", + "webhook.capsule-system.svc", + }, + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + net.ParseIP("10.96.0.20"), + }, + } + + assertCertificateSANsEqual(t, expected, sans.Normalize()) +} + +func TestCertificateSANsAddDNSNames(t *testing.T) { + t.Parallel() + + var sans CertificateSANs + + sans.AddDNSNames("webhook", "webhook.capsule-system.svc") + + expected := []string{ + "webhook", + "webhook.capsule-system.svc", + } + + if !reflect.DeepEqual(expected, sans.DNSNames) { + t.Fatalf("expected DNS names %v, got %v", expected, sans.DNSNames) + } +} + +func TestCertificateSANsAddIPAddrs(t *testing.T) { + t.Parallel() + + var sans CertificateSANs + + sans.AddIPAddrs(net.ParseIP("10.96.0.10"), net.ParseIP("fd00::1")) + + expected := []string{ + "10.96.0.10", + "fd00::1", + } + + if got := IPsToStrings(sans.IPAddrs); !reflect.DeepEqual(expected, got) { + t.Fatalf("expected IP addresses %v, got %v", expected, got) + } +} + +func TestCertificateSANsAddServiceReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + service *admissionregistrationv1.ServiceReference + defaultNamespace string + expected []string + }{ + { + name: "nil service is ignored", + }, + { + name: "empty service name is ignored", + service: &admissionregistrationv1.ServiceReference{ + Namespace: "capsule-system", + }, + }, + { + name: "uses service namespace", + service: &admissionregistrationv1.ServiceReference{ + Name: "capsule-webhook-service", + Namespace: "capsule-system", + }, + defaultNamespace: "default", + expected: []string{ + "capsule-webhook-service", + "capsule-webhook-service.capsule-system", + "capsule-webhook-service.capsule-system.svc", + "capsule-webhook-service.capsule-system.svc.cluster.local", + }, + }, + { + name: "uses default namespace when service namespace is empty", + service: &admissionregistrationv1.ServiceReference{ + Name: "capsule-webhook-service", + }, + defaultNamespace: "capsule-system", + expected: []string{ + "capsule-webhook-service", + "capsule-webhook-service.capsule-system", + "capsule-webhook-service.capsule-system.svc", + "capsule-webhook-service.capsule-system.svc.cluster.local", + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var sans CertificateSANs + + sans.AddServiceReference(tt.service, tt.defaultNamespace) + + if !reflect.DeepEqual(tt.expected, sans.DNSNames) { + t.Fatalf("expected DNS names %v, got %v", tt.expected, sans.DNSNames) + } + }) + } +} + +func TestCertificateSANsAddURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURL *string + expectedDNSNames []string + expectedIPAddrs []net.IP + wantErr bool + }{ + { + name: "nil URL is ignored", + }, + { + name: "blank URL is ignored", + rawURL: ptr.To(" "), + }, + { + name: "adds dns hostname", + rawURL: ptr.To("https://webhook.example.com/mutate"), + expectedDNSNames: []string{"webhook.example.com"}, + }, + { + name: "adds dns hostname without port", + rawURL: ptr.To("https://webhook.example.com:9443/mutate"), + expectedDNSNames: []string{"webhook.example.com"}, + }, + { + name: "adds ipv4 address", + rawURL: ptr.To("https://10.96.0.10:9443/mutate"), + expectedIPAddrs: []net.IP{net.ParseIP("10.96.0.10")}, + }, + { + name: "adds ipv6 address", + rawURL: ptr.To("https://[fd00::1]:9443/mutate"), + expectedIPAddrs: []net.IP{net.ParseIP("fd00::1")}, + }, + { + name: "rejects invalid URL", + rawURL: ptr.To("://bad-url"), + wantErr: true, + }, + { + name: "rejects URL without host", + rawURL: ptr.To("https:///mutate"), + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var sans CertificateSANs + + err := sans.AddURL(tt.rawURL) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := CertificateSANs{ + DNSNames: tt.expectedDNSNames, + IPAddrs: tt.expectedIPAddrs, + } + + assertCertificateSANsEqual(t, expected, sans) + }) + } +} + +func TestCertificateSANsMatchesCertificate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sans CertificateSANs + certificate *x509.Certificate + want bool + }{ + { + name: "nil certificate does not match", + sans: CertificateSANs{ + DNSNames: []string{"webhook.capsule-system.svc"}, + }, + want: false, + }, + { + name: "matches normalized dns and ip sans", + sans: CertificateSANs{ + DNSNames: []string{ + " Webhook.Capsule-System.Svc ", + "api.example.com", + }, + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + }, + }, + certificate: &x509.Certificate{ + DNSNames: []string{ + "api.example.com", + "webhook.capsule-system.svc", + }, + IPAddresses: []net.IP{ + net.ParseIP("10.96.0.10"), + }, + }, + want: true, + }, + { + name: "does not match when certificate misses dns san", + sans: CertificateSANs{ + DNSNames: []string{ + "api.example.com", + "webhook.capsule-system.svc", + }, + }, + certificate: &x509.Certificate{ + DNSNames: []string{ + "webhook.capsule-system.svc", + }, + }, + want: false, + }, + { + name: "does not match when certificate has stale extra dns san", + sans: CertificateSANs{ + DNSNames: []string{ + "webhook.capsule-system.svc", + }, + }, + certificate: &x509.Certificate{ + DNSNames: []string{ + "old-webhook.capsule-system.svc", + "webhook.capsule-system.svc", + }, + }, + want: false, + }, + { + name: "does not match when certificate misses ip san", + sans: CertificateSANs{ + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + }, + }, + certificate: &x509.Certificate{}, + want: false, + }, + { + name: "does not match when certificate has stale extra ip san", + sans: CertificateSANs{ + IPAddrs: []net.IP{ + net.ParseIP("10.96.0.10"), + }, + }, + certificate: &x509.Certificate{ + IPAddresses: []net.IP{ + net.ParseIP("10.96.0.10"), + net.ParseIP("10.96.0.20"), + }, + }, + want: false, + }, + { + name: "matches empty sans against certificate without sans", + sans: CertificateSANs{}, + certificate: &x509.Certificate{}, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.sans.MatchesCertificate(tt.certificate); got != tt.want { + t.Fatalf("expected MatchesCertificate() to be %t, got %t", tt.want, got) + } + }) + } +} + +func assertCertificateSANsEqual(t *testing.T, expected, actual CertificateSANs) { + t.Helper() + + expected = expected.Normalize() + actual = actual.Normalize() + + if !reflect.DeepEqual(expected.DNSNames, actual.DNSNames) { + t.Fatalf("expected DNS names %v, got %v", expected.DNSNames, actual.DNSNames) + } + + expectedIPs := IPsToStrings(expected.IPAddrs) + actualIPs := IPsToStrings(actual.IPAddrs) + + if !reflect.DeepEqual(expectedIPs, actualIPs) { + t.Fatalf("expected IP addresses %v, got %v", expectedIPs, actualIPs) + } +} diff --git a/pkg/runtime/cert/utils.go b/pkg/runtime/cert/utils.go new file mode 100644 index 00000000..4083a98c --- /dev/null +++ b/pkg/runtime/cert/utils.go @@ -0,0 +1,25 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cert + +import ( + "net" + "sort" +) + +func IPsToStrings(ips []net.IP) []string { + out := make([]string, 0, len(ips)) + + for _, ip := range ips { + if ip == nil { + continue + } + + out = append(out, ip.String()) + } + + sort.Strings(out) + + return out +} diff --git a/pkg/runtime/cert/utils_test.go b/pkg/runtime/cert/utils_test.go new file mode 100644 index 00000000..4c62f818 --- /dev/null +++ b/pkg/runtime/cert/utils_test.go @@ -0,0 +1,31 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cert + +import ( + "net" + "reflect" + "testing" +) + +func TestIPsToStrings(t *testing.T) { + t.Parallel() + + ips := []net.IP{ + net.ParseIP("10.96.0.20"), + nil, + net.ParseIP("10.96.0.10"), + net.ParseIP("fd00::1"), + } + + expected := []string{ + "10.96.0.10", + "10.96.0.20", + "fd00::1", + } + + if got := IPsToStrings(ips); !reflect.DeepEqual(expected, got) { + t.Fatalf("expected IP strings %v, got %v", expected, got) + } +} diff --git a/pkg/runtime/indexers/indexer.go b/pkg/runtime/indexers/indexer.go index 1ca0ebb9..164f15a4 100644 --- a/pkg/runtime/indexers/indexer.go +++ b/pkg/runtime/indexers/indexer.go @@ -20,6 +20,7 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/indexers/namespace" "github.com/projectcapsule/capsule/pkg/runtime/indexers/resourcepool" "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenant" + "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantowner" "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource" "github.com/projectcapsule/capsule/pkg/utils" ) @@ -32,6 +33,7 @@ type CustomIndexer interface { func AddToManager(ctx context.Context, log logr.Logger, mgr manager.Manager) error { indexers := []CustomIndexer{ + tenantowner.OwnerNameReference{}, tenant.NamespacesReference{Obj: &capsulev1beta2.Tenant{}}, tenantresource.GlobalServiceAccount{}, tenantresource.GlobalProcessedItems{}, diff --git a/pkg/runtime/indexers/tenantowner/const.go b/pkg/runtime/indexers/tenantowner/const.go new file mode 100644 index 00000000..97ef297b --- /dev/null +++ b/pkg/runtime/indexers/tenantowner/const.go @@ -0,0 +1,8 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantowner + +const ( + NameIndexerFieldName string = ".spec.name" +) diff --git a/pkg/runtime/indexers/tenantowner/name.go b/pkg/runtime/indexers/tenantowner/name.go new file mode 100644 index 00000000..17260974 --- /dev/null +++ b/pkg/runtime/indexers/tenantowner/name.go @@ -0,0 +1,35 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantowner + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +type OwnerNameReference struct{} + +func (o OwnerNameReference) Object() client.Object { + return &capsulev1beta2.TenantOwner{} +} + +func (o OwnerNameReference) Field() string { + return NameIndexerFieldName +} + +func (o OwnerNameReference) Func() client.IndexerFunc { + return func(object client.Object) []string { + instance, ok := object.(*capsulev1beta2.TenantOwner) + if !ok { + return nil + } + + if instance.Spec.Name == "" { + return nil + } + + return []string{instance.Spec.Name} + } +} diff --git a/pkg/runtime/predicates/tenant_change.go b/pkg/runtime/predicates/tenant_change.go index 852e05ad..572af0ab 100644 --- a/pkg/runtime/predicates/tenant_change.go +++ b/pkg/runtime/predicates/tenant_change.go @@ -12,9 +12,32 @@ import ( type TenantStatusOwnersChangedPredicate struct{} -func (TenantStatusOwnersChangedPredicate) Create(event.CreateEvent) bool { return false } -func (TenantStatusOwnersChangedPredicate) Delete(event.DeleteEvent) bool { return false } -func (TenantStatusOwnersChangedPredicate) Generic(event.GenericEvent) bool { return false } +func (TenantStatusOwnersChangedPredicate) Create(e event.CreateEvent) bool { + tenant, ok := e.Object.(*capsulev1beta2.Tenant) + if !ok { + return false + } + + return len(tenant.Status.Owners) > 0 +} + +func (TenantStatusOwnersChangedPredicate) Delete(e event.DeleteEvent) bool { + tenant, ok := e.Object.(*capsulev1beta2.Tenant) + if !ok { + return false + } + + return len(tenant.Status.Owners) > 0 +} + +func (TenantStatusOwnersChangedPredicate) Generic(e event.GenericEvent) bool { + tenant, ok := e.Object.(*capsulev1beta2.Tenant) + if !ok { + return false + } + + return len(tenant.Status.Owners) > 0 +} // TenantCountChangedPredicate fires on Tenant create/delete only. // Update events are ignored because only create and delete change the set of @@ -45,7 +68,7 @@ func ownersChanged(a, b rbac.OwnerStatusListSpec) bool { } for i := range a { - if a[i].Name == b[i].Name && a[i].Kind == b[i].Kind { + if a[i].Name != b[i].Name || a[i].Kind != b[i].Kind { return true } } diff --git a/pkg/tenant/owners.go b/pkg/tenant/owners.go index 3b4b703c..a6c25455 100644 --- a/pkg/tenant/owners.go +++ b/pkg/tenant/owners.go @@ -77,8 +77,12 @@ func CollectOwners( func GetOwnersWithKinds(tenant *capsulev1beta2.Tenant) (owners []string) { for _, owner := range tenant.Status.Owners { - owners = append(owners, fmt.Sprintf("%s:%s", owner.Kind.String(), owner.Name)) + owners = append(owners, OwnerKindIndexKey(owner.Kind.String(), owner.Name)) } return owners } + +func OwnerKindIndexKey(kind, name string) string { + return fmt.Sprintf("%s:%s", kind, name) +}