From 4b07463705d6eebcaabffcabd77d401ef7a809f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= <26610571+oliverbaehler@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:38:41 +0200 Subject: [PATCH] fix: metric recorders for all conditionals (#1998) 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: preserve ca-bundles injected from external providers Signed-off-by: Oliver Baehler * feat: add metadata enforcement Signed-off-by: Oliver Baehler * feat: add metadata enforcement Signed-off-by: Oliver Baehler * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler * fix: add resourcepoolclaim validation 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> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler * fix: wave of fixes Signed-off-by: Oliver Baehler * fix: wave of fixes Signed-off-by: Oliver Baehler * fix: wave of fixes Signed-off-by: Oliver Baehler * fix: wave of fixes Signed-off-by: Oliver Baehler * fix: wave of fixes Signed-off-by: Oliver Baehler * fix: wave of fixes 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> --- Makefile | 2 +- charts/capsule/README.md | 4 +- ...sule.clastix.io_globaltenantresources.yaml | 4 + .../capsule.clastix.io_tenantresources.yaml | 4 + charts/capsule/values.schema.json | 166 ++++--- charts/capsule/values.yaml | 104 +++- e2e/replications_globaltenantresource_test.go | 289 +++++++++++ e2e/replications_tenantresource_test.go | 85 ++++ .../capsule/example-setup/resource.yaml | 3 - .../resourcepools/pool_controller.go | 10 +- .../resourcepools/pool_controller_test.go | 67 +++ internal/controllers/rulestatus/manager.go | 2 +- internal/metrics/config_recorder.go | 4 - .../metrics/global_tenantresource_recorder.go | 1 - internal/metrics/rulestatus_recorder.go | 5 - internal/metrics/tenantowner_recorder.go | 4 - internal/metrics/tenantresource_recorder.go | 5 - internal/webhook/generic/replications.go | 14 +- pkg/api/meta/reference.go | 3 + pkg/api/processor/processor_func.go | 456 +++++++++++------- pkg/api/processor/processor_func_test.go | 135 ++++++ .../tenantresource/global_created_items.go | 2 +- .../indexers/tenantresource/global_items.go | 2 +- pkg/runtime/indexers/tenantresource/items.go | 15 + .../indexers/tenantresource/items_test.go | 83 ++++ .../namespaced_created_items.go | 2 +- .../tenantresource/namespaced_items.go | 2 +- 27 files changed, 1176 insertions(+), 297 deletions(-) create mode 100644 internal/controllers/resourcepools/pool_controller_test.go create mode 100644 pkg/api/processor/processor_func_test.go create mode 100644 pkg/runtime/indexers/tenantresource/items.go create mode 100644 pkg/runtime/indexers/tenantresource/items_test.go diff --git a/Makefile b/Makefile index dc1c52e1..3a2ea3e6 100644 --- a/Makefile +++ b/Makefile @@ -165,7 +165,7 @@ endef export TLS_CNF CHART ?= "./charts/capsule" CHART_VERSION ?= "./charts/capsule" -dev-setup: +dev-setup: dev-setup-cert-manager $(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true mkdir -p /tmp/k8s-webhook-server/serving-certs echo "$${TLS_CNF}" > _tls.cnf diff --git a/charts/capsule/README.md b/charts/capsule/README.md index e2bd7305..6a705a58 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -292,7 +292,7 @@ The following Values have changed key or Value: | webhooks.hooks.managed.failurePolicy | string | `"Ignore"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.managed.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | | webhooks.hooks.managed.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | -| webhooks.hooks.managed.namespaceSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | +| webhooks.hooks.managed.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | | webhooks.hooks.managed.objectSelector | object | `{"matchExpressions":[{"key":"projectcapsule.dev/managed-by","operator":"In","values":["controller"]}]}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.managed.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.managed.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE","DELETE"],"resources":["*"],"scope":"Namespaced"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | @@ -350,7 +350,7 @@ The following Values have changed key or Value: | webhooks.hooks.replications.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.replications.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | | webhooks.hooks.replications.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | -| webhooks.hooks.replications.namespaceSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | +| webhooks.hooks.replications.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | | webhooks.hooks.replications.objectSelector | object | `{"matchExpressions":[{"key":"projectcapsule.dev/created-by","operator":"In","values":["replications"]}]}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.replications.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.replications.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["UPDATE","DELETE"],"resources":["*"],"scope":"*"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 0460af38..4388e30b 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -567,6 +567,10 @@ spec: type: string status: properties: + clusterScoped: + description: Indicates whether the referenced resource is + cluster-scoped. + type: boolean created: description: Indicates wether the resource was created or adopted diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 9ac13608..840a0920 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -502,6 +502,10 @@ spec: type: string status: properties: + clusterScoped: + description: Indicates whether the referenced resource is + cluster-scoped. + type: boolean created: description: Indicates wether the resource was created or adopted diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index fd2fba8c..c8bfb124 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -1044,11 +1044,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "rules": { "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", @@ -1077,11 +1079,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1147,11 +1151,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1216,11 +1222,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "rules": { "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", @@ -1308,11 +1316,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1374,11 +1384,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1427,11 +1439,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1492,11 +1506,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1565,11 +1581,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "rules": { "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", @@ -1646,11 +1664,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1684,22 +1704,7 @@ "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", "type": "object", - "properties": { - "matchExpressions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "operator": { - "type": "string" - } - } - } - } - } + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", @@ -1725,7 +1730,8 @@ } } } - } + }, + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1818,11 +1824,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1895,11 +1903,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1932,11 +1942,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -1965,11 +1977,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2018,11 +2032,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2071,11 +2087,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2109,22 +2127,7 @@ "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", "type": "object", - "properties": { - "matchExpressions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "operator": { - "type": "string" - } - } - } - } - } + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", @@ -2150,7 +2153,8 @@ } } } - } + }, + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2218,11 +2222,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2295,11 +2301,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true } } }, @@ -2340,11 +2348,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2389,11 +2399,13 @@ } } } - } + }, + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", @@ -2426,11 +2438,13 @@ }, "namespaceSelector": { "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", - "type": "object" + "type": "object", + "additionalProperties": true }, "opts": { "description": "Capsule Hook Options", diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index f9cbd9d6..0eb842b4 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -814,7 +814,6 @@ webhooks: # Admission Webhook Configuration hooks: - customquotas: # -- Enable the Hook enabled: true @@ -822,8 +821,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -848,8 +851,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -876,8 +883,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # matchExpressions: @@ -896,8 +907,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # matchExpressions: @@ -916,8 +931,12 @@ webhooks: failurePolicy: Ignore # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -955,8 +974,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1013,8 +1036,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -1032,8 +1059,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1041,7 +1072,6 @@ webhooks: operator: Exists - key: projectcapsule.dev/custom-resources operator: Exists - # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: - name: ignore-subresources @@ -1058,8 +1088,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -1076,8 +1110,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1116,8 +1154,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1138,8 +1180,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1160,8 +1206,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1181,8 +1231,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1202,8 +1256,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1223,8 +1281,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -1242,8 +1304,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -1261,8 +1327,12 @@ webhooks: failurePolicy: Ignore # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -1279,6 +1349,8 @@ webhooks: failurePolicy: Ignore # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: matchExpressions: @@ -1286,11 +1358,10 @@ webhooks: operator: In values: - "controller" + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) - namespaceSelector: - matchExpressions: - - key: "capsule.clastix.io/tenant" - operator: Exists + namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: [] # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) @@ -1316,6 +1387,8 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Equivalent + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: matchExpressions: @@ -1323,11 +1396,10 @@ webhooks: operator: In values: - "replications" + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) - namespaceSelector: - matchExpressions: - - key: "capsule.clastix.io/tenant" - operator: Exists + namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: [] # - name: "exclude-privileged-users" @@ -1360,8 +1432,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -1379,8 +1455,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: {} # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -1395,8 +1475,12 @@ webhooks: failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchPolicy: Exact + # @schema type: object + # @schema additionalProperties: true # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: {} + # @schema type: object + # @schema additionalProperties: true # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: diff --git a/e2e/replications_globaltenantresource_test.go b/e2e/replications_globaltenantresource_test.go index 6e33f5cd..2abe7558 100644 --- a/e2e/replications_globaltenantresource_test.go +++ b/e2e/replications_globaltenantresource_test.go @@ -6,6 +6,7 @@ package e2e import ( "context" "fmt" + "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -13,6 +14,7 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -182,6 +184,46 @@ var _ = Describe("GlobalTenantResource", Ordered, Label("replications", "global" }) Context("cluster-scoped objects", func() { + const ( + controllerClusterResourceRoleName = "gtr-controller-cluster-resource-writer" + controllerClusterResourceBindingName = "gtr-controller-cluster-resource-writer-binding" + ) + + BeforeEach(func() { + bindServiceAccountToClusterResources( + ControllerNamespace, + ControllerServiceAccount, + controllerClusterResourceRoleName, + controllerClusterResourceBindingName, + []rbacv1.PolicyRule{ + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"clusterroles"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get"}, + }, + }, + ) + }) + + AfterEach(func() { + ignoreNotFound(k8sClient.Delete(ctx, &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: controllerClusterResourceBindingName}, + })) + ignoreNotFound(k8sClient.Delete(ctx, &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: controllerClusterResourceRoleName}, + })) + }) + It("applies raw cluster-scoped items", func() { gtr := newRawClusterRoleGlobalTenantResource("gtr-cluster-raw", "gtr-cluster-raw-role") @@ -209,6 +251,101 @@ var _ = Describe("GlobalTenantResource", Ordered, Label("replications", "global" }}) expectGlobalTenantResourceProcessedClusterRole("gtr-cluster-generator", "gtr-cluster-generator-role") }) + + It("only allows the managing GlobalTenantResource service account to update created cluster-scoped objects", func() { + saName := "gtr-cluster-update-guard" + clusterRoleName := "gtr-cluster-admission-role" + writerRoleName := "gtr-cluster-admission-writer" + writerBindingName := "gtr-cluster-admission-writer-binding" + + defer ignoreNotFound(k8sClient.Delete(ctx, &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: writerBindingName}, + })) + defer ignoreNotFound(k8sClient.Delete(ctx, &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: writerRoleName}, + })) + + ensureServiceAccount("capsule-system", saName) + bindServiceAccountToClusterResources( + "capsule-system", + saName, + writerRoleName, + writerBindingName, + []rbacv1.PolicyRule{ + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"clusterroles"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + ) + + gtr := newRawClusterRoleGlobalTenantResource("gtr-cluster-admission", clusterRoleName) + gtr.Spec.ServiceAccount = &apimeta.NamespacedRFC1123ObjectReferenceWithNamespace{ + Name: apimeta.RFC1123Name(saName), + Namespace: apimeta.RFC1123SubdomainName("capsule-system"), + } + + EventuallyCreation(func() error { return k8sClient.Create(ctx, gtr) }).Should(Succeed()) + expectGlobalTenantResourceReady(gtr.Name) + expectClusterRoleRules(clusterRoleName, []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }}) + expectGlobalTenantResourceProcessedClusterRole(gtr.Name, clusterRoleName) + + Eventually(func() error { + clusterRole := &rbacv1.ClusterRole{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, clusterRole); err != nil { + return err + } + + if clusterRole.Annotations == nil { + clusterRole.Annotations = map[string]string{} + } + clusterRole.Annotations["e2e.capsule.dev/update-attempt"] = "admin" + + err := k8sClient.Update(ctx, clusterRole) + if err == nil { + return fmt.Errorf("expected cluster role update to be denied") + } + if apierrors.IsConflict(err) { + return err + } + if !apierrors.IsForbidden(err) { + return fmt.Errorf("expected forbidden error, got: %w", err) + } + if !strings.Contains(err.Error(), "managed by a global capsule replication") { + return fmt.Errorf("expected global replication admission denial, got: %w", err) + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + replicationClient := impersonationClient( + serviceAccountUsername("capsule-system", saName), + serviceAccountGroups("capsule-system"), + ) + Eventually(func() error { + clusterRole := &rbacv1.ClusterRole{} + if err := replicationClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, clusterRole); err != nil { + return err + } + + if clusterRole.Annotations == nil { + clusterRole.Annotations = map[string]string{} + } + clusterRole.Annotations["e2e.capsule.dev/updated-by"] = "manager" + + return replicationClient.Update(ctx, clusterRole) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) }) It("skips applying resources to terminating namespaces and removes them from processedItems", func() { @@ -697,6 +834,75 @@ data: } }) + It("only allows the managing GlobalTenantResource service account to update created objects", func() { + saName := "gtr-update-guard" + targetNamespace := tenantANamespaces[0] + configMapName := "gtr-admission-protected" + + ensureServiceAccount("capsule-system", saName) + for _, ns := range tenantANamespaces { + bindServiceAccountToConfigMapWriter("capsule-system", saName, ns) + } + + gtr := newRawConfigMapGlobalTenantResource("gtr-sa-update-guard", map[string]string{ + "mode": "managed", + }) + gtr.Spec.ServiceAccount = &apimeta.NamespacedRFC1123ObjectReferenceWithNamespace{ + Name: apimeta.RFC1123Name(saName), + Namespace: apimeta.RFC1123SubdomainName("capsule-system"), + } + gtr.Spec.TenantSelector = metav1.LabelSelector{ + MatchLabels: map[string]string{"energy": "solar"}, + } + renameFirstRawConfigMap(gtr, configMapName) + + EventuallyCreation(func() error { return k8sClient.Create(ctx, gtr) }).Should(Succeed()) + expectGlobalTenantResourceReady(gtr.Name) + expectConfigMapData(targetNamespace, configMapName, map[string]string{"mode": "managed"}) + expectManagedLabelsOnConfigMap(targetNamespace, configMapName, true) + + tenantOwnerClient := impersonationClient(tenantAOwner.Name, withDefaultGroups([]string{tenantAOwner.Name})) + Eventually(func() error { + cm := &corev1.ConfigMap{} + if err := tenantOwnerClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: targetNamespace}, cm); err != nil { + return err + } + + cm.Data["mode"] = "tenant-owner" + + err := tenantOwnerClient.Update(ctx, cm) + if err == nil { + return fmt.Errorf("expected tenant owner update to be denied") + } + if apierrors.IsConflict(err) { + return err + } + if !apierrors.IsForbidden(err) { + return fmt.Errorf("expected forbidden error, got: %w", err) + } + if !strings.Contains(err.Error(), "managed by a global capsule replication") { + return fmt.Errorf("expected global replication admission denial, got: %w", err) + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + replicationClient := impersonationClient( + serviceAccountUsername("capsule-system", saName), + serviceAccountGroups("capsule-system"), + ) + Eventually(func() error { + cm := &corev1.ConfigMap{} + if err := replicationClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: targetNamespace}, cm); err != nil { + return err + } + + cm.Data["mode"] = "service-account" + + return replicationClient.Update(ctx, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + It("fails to prune replicated resources when the impersonated service account cannot delete them", func() { saCreate := "gtr-creator-ok" saNoDelete := "gtr-creator-no-delete" @@ -1479,6 +1685,7 @@ func expectClusterRoleRules(name string, expected []rbacv1.PolicyRule) { g.Expect(clusterRole.Namespace).To(BeEmpty()) g.Expect(clusterRole.Labels).To(HaveKeyWithValue("e2e.capsule.dev/test-suite", "true")) g.Expect(clusterRole.Labels).To(HaveKeyWithValue(managedByLabel, meta.ValueControllerReplications)) + g.Expect(clusterRole.Labels).To(HaveKeyWithValue(createdByLabel, meta.ValueControllerReplications)) g.Expect(clusterRole.Rules).To(ConsistOf(expected)) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } @@ -1495,3 +1702,85 @@ func expectGlobalTenantResourceProcessedClusterRole(gtrName, clusterRoleName str ))) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } + +func bindServiceAccountToClusterResource( + saNamespace, saName, clusterRoleName, clusterRoleBindingName string, + resources, verbs []string, +) { + bindServiceAccountToClusterResources( + saNamespace, + saName, + clusterRoleName, + clusterRoleBindingName, + []rbacv1.PolicyRule{{ + APIGroups: []string{rbacv1.GroupName}, + Resources: resources, + Verbs: verbs, + }}, + ) +} + +func bindServiceAccountToClusterResources( + saNamespace, saName, clusterRoleName, clusterRoleBindingName string, + rules []rbacv1.PolicyRule, +) { + ctx := context.Background() + + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + Labels: map[string]string{ + "e2e.capsule.dev/test-suite": "true", + }, + }, + Rules: rules, + } + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleBindingName, + }, + Subjects: []rbacv1.Subject{{ + Kind: "ServiceAccount", + Name: saName, + Namespace: saNamespace, + }}, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: clusterRoleName, + }, + } + + Eventually(func() error { + current := &rbacv1.ClusterRole{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, current) + if apierrors.IsNotFound(err) { + return k8sClient.Create(ctx, clusterRole) + } + if err != nil { + return err + } + + current.Labels = clusterRole.Labels + current.Rules = clusterRole.Rules + + return k8sClient.Update(ctx, current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + Eventually(func() error { + current := &rbacv1.ClusterRoleBinding{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleBindingName}, current) + if apierrors.IsNotFound(err) { + return k8sClient.Create(ctx, clusterRoleBinding) + } + if err != nil { + return err + } + + current.Subjects = clusterRoleBinding.Subjects + current.RoleRef = clusterRoleBinding.RoleRef + + return k8sClient.Update(ctx, current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) +} diff --git a/e2e/replications_tenantresource_test.go b/e2e/replications_tenantresource_test.go index 80b7d807..675a9d45 100644 --- a/e2e/replications_tenantresource_test.go +++ b/e2e/replications_tenantresource_test.go @@ -1417,6 +1417,79 @@ data: } }) + It("only allows the managing TenantResource service account to update created objects", func() { + saName := "tr-update-guard" + targetNamespace := targetNamespaces[0] + configMapName := "tr-admission-protected" + + ensureServiceAccount(baseNamespace, saName) + for _, ns := range append(targetNamespaces, baseNamespace) { + bindServiceAccountToConfigMapWriter(baseNamespace, saName, ns) + } + + tr := newRawConfigMapTenantResource(baseNamespace, "sa-update-guard", map[string]string{ + "mode": "managed", + }) + tr.Spec.ServiceAccount = &apimeta.LocalRFC1123ObjectReference{ + Name: apimeta.RFC1123Name(saName), + } + renameFirstTenantResourceRawConfigMap(tr, configMapName) + + EventuallyCreation(func() error { return k8sClient.Create(ctx, tr) }).Should(Succeed()) + expectTenantResourceReady(baseNamespace, tr.Name) + expectConfigMapData(targetNamespace, configMapName, map[string]string{"mode": "managed"}) + expectManagedLabelsOnConfigMap(targetNamespace, configMapName, true) + expectProcessedItemStatus( + baseNamespace, + tr.Name, + configMapRID(tnt.Name, targetNamespace, configMapName, "0/raw-0"), + metav1.ConditionTrue, + true, + "", + ) + + tenantOwnerClient := impersonationClient(tenantOwner.Name, withDefaultGroups([]string{tenantOwner.Name})) + Eventually(func() error { + cm := &corev1.ConfigMap{} + if err := tenantOwnerClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: targetNamespace}, cm); err != nil { + return err + } + + cm.Data["mode"] = "tenant-owner" + + err := tenantOwnerClient.Update(ctx, cm) + if err == nil { + return fmt.Errorf("expected tenant owner update to be denied") + } + if apierrors.IsConflict(err) { + return err + } + if !apierrors.IsForbidden(err) { + return fmt.Errorf("expected forbidden error, got: %w", err) + } + if !strings.Contains(err.Error(), "managed by a tenant capsule replication") { + return fmt.Errorf("expected tenant replication admission denial, got: %w", err) + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + replicationClient := impersonationClient( + serviceAccountUsername(baseNamespace, saName), + serviceAccountGroups(baseNamespace), + ) + Eventually(func() error { + cm := &corev1.ConfigMap{} + if err := replicationClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: targetNamespace}, cm); err != nil { + return err + } + + cm.Data["mode"] = "service-account" + + return replicationClient.Update(ctx, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + It("fails to prune replicated resources when the impersonated service account cannot delete them", func() { saCreate := "creator-ok" saNoDelete := "creator-no-delete" @@ -2109,3 +2182,15 @@ func ensureServiceAccount(namespace, name string) { return err }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } + +func serviceAccountUsername(namespace, name string) string { + return fmt.Sprintf("system:serviceaccount:%s:%s", namespace, name) +} + +func serviceAccountGroups(namespace string) []string { + return []string{ + "system:authenticated", + "system:serviceaccounts", + fmt.Sprintf("system:serviceaccounts:%s", namespace), + } +} diff --git a/hack/distro/capsule/example-setup/resource.yaml b/hack/distro/capsule/example-setup/resource.yaml index 37546296..8f210dda 100644 --- a/hack/distro/capsule/example-setup/resource.yaml +++ b/hack/distro/capsule/example-setup/resource.yaml @@ -5,8 +5,6 @@ metadata: name: gitops-owners spec: resyncPeriod: 60s - dependsOn: - - name: custom-cm-2 resources: - additionalMetadata: labels: @@ -29,7 +27,6 @@ metadata: name: cluster-replication spec: resyncPeriod: 60s - scope: None resources: - generators: - template: | diff --git a/internal/controllers/resourcepools/pool_controller.go b/internal/controllers/resourcepools/pool_controller.go index 90db1604..b5110a00 100644 --- a/internal/controllers/resourcepools/pool_controller.go +++ b/internal/controllers/resourcepools/pool_controller.go @@ -146,13 +146,15 @@ func (r *resourcePoolController) finalize( ctx context.Context, pool *capsulev1beta2.ResourcePool, ) { - // Case: all claims are gone, remove finalizer - if pool.Status.ClaimSize == 0 && controllerutil.ContainsFinalizer(pool, meta.ControllerFinalizer) { + managedResources := pool.Status.ClaimSize + pool.Status.NamespaceSize + + // Case: all managed resources are gone, remove finalizer + if managedResources == 0 && controllerutil.ContainsFinalizer(pool, meta.ControllerFinalizer) { controllerutil.RemoveFinalizer(pool, meta.ControllerFinalizer) } - // Case: claims still exist, add finalizer if not already present - if pool.Status.ClaimSize > 0 && !controllerutil.ContainsFinalizer(pool, meta.ControllerFinalizer) { + // Case: managed resources still exist, add finalizer if not already present + if managedResources > 0 && !controllerutil.ContainsFinalizer(pool, meta.ControllerFinalizer) { controllerutil.AddFinalizer(pool, meta.ControllerFinalizer) } } diff --git a/internal/controllers/resourcepools/pool_controller_test.go b/internal/controllers/resourcepools/pool_controller_test.go new file mode 100644 index 00000000..a4d5eb1d --- /dev/null +++ b/internal/controllers/resourcepools/pool_controller_test.go @@ -0,0 +1,67 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package resourcepools + +import ( + "context" + "testing" + + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" +) + +func TestResourcePoolFinalize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claimSize uint + namespaceSize uint + initialFinalizer bool + wantFinalizer bool + }{ + { + name: "adds finalizer when namespaces are managed without claims", + namespaceSize: 2, + initialFinalizer: false, + wantFinalizer: true, + }, + { + name: "adds finalizer when claims are managed", + claimSize: 1, + initialFinalizer: false, + wantFinalizer: true, + }, + { + name: "removes finalizer when no managed resources remain", + initialFinalizer: true, + wantFinalizer: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pool := &capsulev1beta2.ResourcePool{ + Status: capsulev1beta2.ResourcePoolStatus{ + ClaimSize: tt.claimSize, + NamespaceSize: tt.namespaceSize, + }, + } + + if tt.initialFinalizer { + controllerutil.AddFinalizer(pool, meta.ControllerFinalizer) + } + + (&resourcePoolController{}).finalize(context.Background(), pool) + + if got := controllerutil.ContainsFinalizer(pool, meta.ControllerFinalizer); got != tt.wantFinalizer { + t.Fatalf("finalizer presence = %v, want %v", got, tt.wantFinalizer) + } + }) + } +} diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index 1d970ea8..fb73b6a7 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -101,7 +101,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct r.Metrics.RecordConditions(instance) - if e := patchHelper.Patch(ctx, instance); err != nil { + if e := patchHelper.Patch(ctx, instance); e != nil { if apierrors.IsNotFound(e) || apierrors.HasStatusCause(e, corev1.NamespaceTerminatingCause) { err = nil diff --git a/internal/metrics/config_recorder.go b/internal/metrics/config_recorder.go index 00445bda..74626916 100644 --- a/internal/metrics/config_recorder.go +++ b/internal/metrics/config_recorder.go @@ -78,9 +78,5 @@ func (r *ConfigRecorder) DeleteConditionMetricByType(name string, condition stri // DeleteCondition deletes the condition metrics for the ref. func (r *ConfigRecorder) DeleteMetrics(resourceName string) { - r.resourceConditionGauge.DeletePartialMatch(map[string]string{ - "name": resourceName, - }) - r.DeleteConditionMetrics(resourceName) } diff --git a/internal/metrics/global_tenantresource_recorder.go b/internal/metrics/global_tenantresource_recorder.go index 1878c8fc..ed105aad 100644 --- a/internal/metrics/global_tenantresource_recorder.go +++ b/internal/metrics/global_tenantresource_recorder.go @@ -1,7 +1,6 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -//nolint:dupl package metrics import ( diff --git a/internal/metrics/rulestatus_recorder.go b/internal/metrics/rulestatus_recorder.go index 7d933d01..bbf9e252 100644 --- a/internal/metrics/rulestatus_recorder.go +++ b/internal/metrics/rulestatus_recorder.go @@ -80,10 +80,5 @@ func (r *RuleStatusRecorder) DeleteConditionMetricByType(name string, namespace // DeleteCondition deletes the condition metrics for the ref. func (r *RuleStatusRecorder) DeleteMetrics(resourceName string, resourceNamespace string) { - r.resourceConditionGauge.DeletePartialMatch(map[string]string{ - "name": resourceName, - "target_namespace": resourceNamespace, - }) - r.DeleteConditionMetrics(resourceName, resourceNamespace) } diff --git a/internal/metrics/tenantowner_recorder.go b/internal/metrics/tenantowner_recorder.go index bfb5a904..50ab69d0 100644 --- a/internal/metrics/tenantowner_recorder.go +++ b/internal/metrics/tenantowner_recorder.go @@ -78,9 +78,5 @@ func (r *TenantOwnerRecorder) DeleteConditionMetricByType(name string, condition // DeleteCondition deletes the condition metrics for the ref. func (r *TenantOwnerRecorder) DeleteMetrics(resourceName string) { - r.resourceConditionGauge.DeletePartialMatch(map[string]string{ - "name": resourceName, - }) - r.DeleteConditionMetrics(resourceName) } diff --git a/internal/metrics/tenantresource_recorder.go b/internal/metrics/tenantresource_recorder.go index 1c9b4a4c..3176cf57 100644 --- a/internal/metrics/tenantresource_recorder.go +++ b/internal/metrics/tenantresource_recorder.go @@ -80,10 +80,5 @@ func (r *TenantResourceRecorder) DeleteConditionMetricByType(name string, namesp // DeleteCondition deletes the condition metrics for the ref. func (r *TenantResourceRecorder) DeleteMetrics(resourceName string, resourceNamespace string) { - r.resourceConditionGauge.DeletePartialMatch(map[string]string{ - "name": resourceName, - "target_namespace": resourceNamespace, - }) - r.DeleteConditionMetrics(resourceName, resourceNamespace) } diff --git a/internal/webhook/generic/replications.go b/internal/webhook/generic/replications.go index 8fe44ead..a629c472 100644 --- a/internal/webhook/generic/replications.go +++ b/internal/webhook/generic/replications.go @@ -18,7 +18,6 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/gvk" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource" - "github.com/projectcapsule/capsule/pkg/tenant" ) type replicaHandler struct{} @@ -39,13 +38,13 @@ func (h *replicaHandler) OnCreate( } func (h *replicaHandler) OnDelete( - _ client.Client, + c client.Client, reader client.Reader, _ admission.Decoder, recorder events.EventRecorder, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return nil + return h.handler(ctx, c, req, recorder) } } @@ -66,15 +65,6 @@ func (h *replicaHandler) handler( req admission.Request, recorder events.EventRecorder, ) *admission.Response { - tnt, err := tenant.TenantByStatusNamespace(ctx, c, req.Namespace) - if err != nil { - return ad.ErroredResponse(err) - } - - if tnt == nil { - return nil - } - // Checking if the object is managed by a TenantResource, local or global ref := gvk.ResourceID{ Group: req.Kind.Group, diff --git a/pkg/api/meta/reference.go b/pkg/api/meta/reference.go index c975eeab..dfd703cc 100644 --- a/pkg/api/meta/reference.go +++ b/pkg/api/meta/reference.go @@ -186,4 +186,7 @@ type ObjectReferenceStatusCondition struct { // Indicates wether the resource was created or adopted Created bool `json:"created,omitempty"` + + // Indicates whether the referenced resource is cluster-scoped. + ClusterScoped bool `json:"clusterScoped,omitempty"` } diff --git a/pkg/api/processor/processor_func.go b/pkg/api/processor/processor_func.go index 96b7a625..2dd4c8c9 100644 --- a/pkg/api/processor/processor_func.go +++ b/pkg/api/processor/processor_func.go @@ -13,6 +13,7 @@ import ( k8smeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" @@ -22,7 +23,6 @@ import ( clt "github.com/projectcapsule/capsule/pkg/runtime/client" ) -//nolint:gocognit func (p *Processor) Reconcile( ctx context.Context, log logr.Logger, @@ -31,177 +31,15 @@ func (p *Processor) Reconcile( acc Accumulator, opts ProcessorOptions, ) (err error) { - itemErrors := 0 - log.V(5).Info("starting pruning items", "present", len(*processed)) - failAndContinue := func(i meta.ObjectReferenceStatus, msg string, err error) bool { // replace ItemType - if err == nil { - return false - } - - itemErrors++ - i.Status = metav1.ConditionFalse - i.Message = msg + err.Error() - processed.UpdateItem(i) - - return true - } - - terminatingNamespaces := map[string]bool{} - - for _, i := range *processed { - if _, exists := acc[i.GetKey("")]; exists { - continue - } - - obj := &unstructured.Unstructured{} - obj.SetGroupVersionKind(i.GetGVK()) - obj.SetName(i.GetName()) - - ns := i.GetNamespace() - if ns != "" { - obj.SetNamespace(ns) - } - - if i.LastApply.IsZero() { - processed.RemoveItem(i) - - continue - } - - if opts.Prune { - log.V(4).Info("pruning resources", "Kind", i.Kind, "Name", i.Name, "Namespace", i.Namespace) - - fieldOwner := opts.FieldOwnerPrefix + "/" + i.FieldOwner("") - - deleted, reconErr := p.Prune(ctx, c, obj, fieldOwner, &i) - if failAndContinue(i, "pruning failed for item: ", reconErr) { - continue - } - - if deleted { - processed.RemoveItem(i) - - continue - } - } - - // Disown item (only when GET succeeded) - patches, err := p.handleRemoveManagedMetadata(ctx, c, obj, opts.Owner) - if err != nil { - if apierrors.IsNotFound(err) { - processed.RemoveItem(i) - - continue - } - - if failAndContinue(i, "disowning failed for item: ", err) { - continue - } - } - - //nolint:nestif - if len(patches) > 0 { - err = clt.ApplyPatches(ctx, c, obj, patches, meta.ResourceControllerFieldOwnerPrefix()) - if err != nil { - if apierrors.IsNotFound(err) { - processed.RemoveItem(i) - - continue - } - - if failAndContinue(i, "removing metdata failed for item: ", err) { - continue - } - } - } - - processed.RemoveItem(i) - } - - if itemErrors > 0 { + if itemErrors := p.pruneProcessedItems(ctx, log, c, processed, acc, opts); itemErrors > 0 { return fmt.Errorf("pruning of %d resources failed", itemErrors) } log.V(5).Info("accumulation after pruning", "items", len(acc)) - for _, item := range acc { - or := meta.ObjectReferenceStatus{ - ResourceID: item.Resource, - ObjectReferenceStatusCondition: meta.ObjectReferenceStatusCondition{ - Type: meta.ReadyCondition, - }, - } - - hadError := false - - for _, obj := range *item.Objects { - fieldOwner := opts.FieldOwnerPrefix + "/" + item.Resource.FieldOwner("") - - terminating, namespace, err := p.isNamespaceTerminatingForObject(ctx, obj.Object, terminatingNamespaces) - if err != nil { - hadError = true - or.Status = metav1.ConditionFalse - or.Message = "checking namespace termination failed for item " + obj.Origin.Origin + ": " + err.Error() - - processed.UpdateItem(or) - - continue - } - - if terminating { - log.V(4).Info( - "skipping apply because namespace is terminating", - "item", obj.Origin.Origin, - "namespace", namespace, - "Kind", obj.Object.GetKind(), - "Name", obj.Object.GetName(), - ) - - processed.RemoveItem(or) - - continue - } - - ver, created, err := p.Apply( - ctx, - c, - obj.Object, - fieldOwner, - opts.Force, - opts.Adopt, - opts.Owner, - processed.GetItem(item.Resource), - ) - - or.Created = created - - if err != nil { - hadError = true - or.Status = metav1.ConditionFalse - or.Message = "apply failed for item " + obj.Origin.Origin + ": " + err.Error() - - log.V(4).Info("failed to apply item", "item", obj.Origin.Origin) - } else { - if ver != nil { - or.LastApply = *ver - } - - or.Status = metav1.ConditionTrue - - log.V(4).Info("successfully applied item", "item", obj.Origin.Origin, "version", ver) - } - - processed.UpdateItem(or) - } - - if hadError { - itemErrors++ - } - } - - if itemErrors > 0 { + if itemErrors := p.applyAccumulatedItems(ctx, log, c, processed, acc, opts); itemErrors > 0 { return fmt.Errorf("applying of %d resources failed", itemErrors) } @@ -212,6 +50,285 @@ func (p *Processor) Reconcile( return nil } +func (p *Processor) pruneProcessedItems( + ctx context.Context, + log logr.Logger, + c client.Client, + processed *meta.ProcessedItems, + acc Accumulator, + opts ProcessorOptions, +) int { + itemErrors := 0 + + for _, item := range *processed { + if _, exists := acc[item.GetKey("")]; exists { + continue + } + + if item.LastApply.IsZero() { + processed.RemoveItem(item) + + continue + } + + obj, err := p.objectForProcessedItem(item) + if failAndRecord(processed, &itemErrors, item, "resolving resource scope failed: ", err) { + continue + } + + if p.pruneProcessedItem(ctx, log, c, processed, opts, item, obj, &itemErrors) { + continue + } + + p.disownProcessedItem(ctx, c, processed, opts, item, obj, &itemErrors) + } + + return itemErrors +} + +func (p *Processor) pruneProcessedItem( + ctx context.Context, + log logr.Logger, + c client.Client, + processed *meta.ProcessedItems, + opts ProcessorOptions, + item meta.ObjectReferenceStatus, + obj *unstructured.Unstructured, + itemErrors *int, +) bool { + if !opts.Prune { + return false + } + + log.V(4).Info("pruning resources", "Kind", item.Kind, "Name", item.Name, "Namespace", item.Namespace) + + fieldOwner := opts.FieldOwnerPrefix + "/" + item.FieldOwner("") + + deleted, err := p.Prune(ctx, c, obj, fieldOwner, &item) + if failAndRecord(processed, itemErrors, item, "pruning failed for item: ", err) { + return true + } + + if deleted { + processed.RemoveItem(item) + + return true + } + + return false +} + +func (p *Processor) disownProcessedItem( + ctx context.Context, + c client.Client, + processed *meta.ProcessedItems, + opts ProcessorOptions, + item meta.ObjectReferenceStatus, + obj *unstructured.Unstructured, + itemErrors *int, +) { + patches, err := p.handleRemoveManagedMetadata(ctx, c, obj, opts.Owner) + if err != nil { + if apierrors.IsNotFound(err) { + processed.RemoveItem(item) + + return + } + + if failAndRecord(processed, itemErrors, item, "disowning failed for item: ", err) { + return + } + } + + //nolint:nestif + if len(patches) > 0 { + err = clt.ApplyPatches(ctx, c, obj, patches, meta.ResourceControllerFieldOwnerPrefix()) + if err != nil { + if apierrors.IsNotFound(err) { + processed.RemoveItem(item) + + return + } + + if failAndRecord(processed, itemErrors, item, "removing metdata failed for item: ", err) { + return + } + } + } + + processed.RemoveItem(item) +} + +func (p *Processor) applyAccumulatedItems( + ctx context.Context, + log logr.Logger, + c client.Client, + processed *meta.ProcessedItems, + acc Accumulator, + opts ProcessorOptions, +) int { + itemErrors := 0 + terminatingNamespaces := map[string]bool{} + + for _, item := range acc { + if p.applyAccumulatedItem(ctx, log, c, processed, item, opts, terminatingNamespaces) { + itemErrors++ + } + } + + return itemErrors +} + +func (p *Processor) applyAccumulatedItem( + ctx context.Context, + log logr.Logger, + c client.Client, + processed *meta.ProcessedItems, + item *AccumulatorItem, + opts ProcessorOptions, + terminatingNamespaces map[string]bool, +) bool { + or := meta.ObjectReferenceStatus{ + ResourceID: item.Resource, + ObjectReferenceStatusCondition: meta.ObjectReferenceStatusCondition{ + Type: meta.ReadyCondition, + }, + } + + clusterScoped, err := p.isClusterScoped(item.Resource.GetGVK()) + if err != nil { + or.Status = metav1.ConditionFalse + or.Message = "resolving resource scope failed: " + err.Error() + processed.UpdateItem(or) + + return true + } + + or.ClusterScoped = clusterScoped + + hadError := false + + for _, obj := range *item.Objects { + if p.applyAccumulatorObject(ctx, log, c, processed, item, obj, opts, terminatingNamespaces, &or) { + hadError = true + } + } + + return hadError +} + +func (p *Processor) applyAccumulatorObject( + ctx context.Context, + log logr.Logger, + c client.Client, + processed *meta.ProcessedItems, + item *AccumulatorItem, + obj AccumulatorObject, + opts ProcessorOptions, + terminatingNamespaces map[string]bool, + or *meta.ObjectReferenceStatus, +) bool { + fieldOwner := opts.FieldOwnerPrefix + "/" + item.Resource.FieldOwner("") + + terminating, namespace, err := p.isNamespaceTerminatingForObject(ctx, obj.Object, terminatingNamespaces) + if err != nil { + or.Status = metav1.ConditionFalse + or.Message = "checking namespace termination failed for item " + obj.Origin.Origin + ": " + err.Error() + + processed.UpdateItem(*or) + + return true + } + + if terminating { + log.V(4).Info( + "skipping apply because namespace is terminating", + "item", obj.Origin.Origin, + "namespace", namespace, + "Kind", obj.Object.GetKind(), + "Name", obj.Object.GetName(), + ) + + processed.RemoveItem(*or) + + return false + } + + ver, created, err := p.Apply( + ctx, + c, + obj.Object, + fieldOwner, + opts.Force, + opts.Adopt, + opts.Owner, + processed.GetItem(item.Resource), + ) + + or.Created = created + + if err != nil { + or.Status = metav1.ConditionFalse + or.Message = "apply failed for item " + obj.Origin.Origin + ": " + err.Error() + + log.V(4).Info("failed to apply item", "item", obj.Origin.Origin) + } else { + if ver != nil { + or.LastApply = *ver + } + + or.Status = metav1.ConditionTrue + + log.V(4).Info("successfully applied item", "item", obj.Origin.Origin, "version", ver) + } + + processed.UpdateItem(*or) + + return err != nil +} + +func (p *Processor) objectForProcessedItem(item meta.ObjectReferenceStatus) (*unstructured.Unstructured, error) { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(item.GetGVK()) + obj.SetName(item.GetName()) + + clusterScoped := item.ClusterScoped + if !clusterScoped { + var err error + + clusterScoped, err = p.isClusterScoped(item.GetGVK()) + if err != nil { + return nil, err + } + } + + ns := item.GetNamespace() + if ns != "" && !clusterScoped { + obj.SetNamespace(ns) + } + + return obj, nil +} + +func failAndRecord( + processed *meta.ProcessedItems, + itemErrors *int, + item meta.ObjectReferenceStatus, + msg string, + err error, +) bool { + if err == nil { + return false + } + + (*itemErrors)++ + item.Status = metav1.ConditionFalse + item.Message = msg + err.Error() + processed.UpdateItem(item) + + return true +} + // Prune by reverting the patch by the given fieldOwner // If the item was created by the controller and has no more field-managers we are going to delete. func (r *Processor) Prune( @@ -282,6 +399,15 @@ func (r *Processor) Prune( return false, err } +func (r *Processor) isClusterScoped(gvk schema.GroupVersionKind) (bool, error) { + mapping, err := r.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return false, err + } + + return mapping.Scope.Name() == k8smeta.RESTScopeNameRoot, nil +} + // Completely prune the resource when there's no more managers and the resource was created by the controller. func (r *Processor) handlePruneDeletion( ctx context.Context, diff --git a/pkg/api/processor/processor_func_test.go b/pkg/api/processor/processor_func_test.go new file mode 100644 index 00000000..f43ee853 --- /dev/null +++ b/pkg/api/processor/processor_func_test.go @@ -0,0 +1,135 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package processor + +import ( + "errors" + "testing" + + k8smeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/gvk" +) + +func TestObjectForProcessedItem(t *testing.T) { + t.Parallel() + + mapper := k8smeta.NewDefaultRESTMapper([]schema.GroupVersion{{Version: "v1"}}) + mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Secret"}, k8smeta.RESTScopeNamespace) + mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, k8smeta.RESTScopeRoot) + + p := &Processor{Mapper: mapper} + + t.Run("keeps namespace for namespaced resource", func(t *testing.T) { + t.Parallel() + + obj, err := p.objectForProcessedItem(meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Version: "v1", + Kind: "Secret", + Namespace: "tenant-a", + Name: "example", + }, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if obj.GetNamespace() != "tenant-a" { + t.Fatalf("expected namespace tenant-a, got %q", obj.GetNamespace()) + } + }) + + t.Run("drops tracking namespace for mapped cluster scoped resource", func(t *testing.T) { + t.Parallel() + + obj, err := p.objectForProcessedItem(meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Version: "v1", + Kind: "Namespace", + Namespace: "tenant-a", + Name: "example", + }, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if obj.GetNamespace() != "" { + t.Fatalf("expected empty namespace, got %q", obj.GetNamespace()) + } + }) + + t.Run("uses status flag without mapper lookup", func(t *testing.T) { + t.Parallel() + + obj, err := (&Processor{}).objectForProcessedItem(meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Version: "v1", + Kind: "UnknownClusterKind", + Namespace: "tenant-a", + Name: "example", + }, + ObjectReferenceStatusCondition: meta.ObjectReferenceStatusCondition{ + ClusterScoped: true, + }, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if obj.GetNamespace() != "" { + t.Fatalf("expected empty namespace, got %q", obj.GetNamespace()) + } + }) +} + +func TestFailAndRecord(t *testing.T) { + t.Parallel() + + processed := meta.ProcessedItems{} + itemErrors := 0 + item := meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Version: "v1", + Kind: "Secret", + Name: "example", + }, + ObjectReferenceStatusCondition: meta.ObjectReferenceStatusCondition{ + Status: metav1.ConditionTrue, + }, + } + + if failAndRecord(&processed, &itemErrors, item, "prefix: ", nil) { + t.Fatal("expected nil error to be ignored") + } + + if itemErrors != 0 { + t.Fatalf("expected no item errors, got %d", itemErrors) + } + + if failAndRecord(&processed, &itemErrors, item, "prefix: ", errors.New("boom")) != true { + t.Fatal("expected error to be recorded") + } + + if itemErrors != 1 { + t.Fatalf("expected one item error, got %d", itemErrors) + } + + got := processed.GetItem(item.ResourceID) + if got == nil { + t.Fatal("expected processed item to be recorded") + } + + if got.Status != metav1.ConditionFalse { + t.Fatalf("expected status False, got %q", got.Status) + } + + if got.Message != "prefix: boom" { + t.Fatalf("expected message %q, got %q", "prefix: boom", got.Message) + } +} diff --git a/pkg/runtime/indexers/tenantresource/global_created_items.go b/pkg/runtime/indexers/tenantresource/global_created_items.go index 1b294cb9..0487bafb 100644 --- a/pkg/runtime/indexers/tenantresource/global_created_items.go +++ b/pkg/runtime/indexers/tenantresource/global_created_items.go @@ -27,7 +27,7 @@ func (g GlobalCreatedItems) Func() client.IndexerFunc { for _, pi := range tgr.Status.ProcessedItems { if pi.Created { - out = append(out, pi.GetGVKKey("")) + out = append(out, processedItemKey(pi)) } } diff --git a/pkg/runtime/indexers/tenantresource/global_items.go b/pkg/runtime/indexers/tenantresource/global_items.go index 6eb095aa..01e138ad 100644 --- a/pkg/runtime/indexers/tenantresource/global_items.go +++ b/pkg/runtime/indexers/tenantresource/global_items.go @@ -25,7 +25,7 @@ func (g GlobalProcessedItems) Func() client.IndexerFunc { out := make([]string, 0, len(tgr.Status.ProcessedItems)) for _, pi := range tgr.Status.ProcessedItems { - out = append(out, pi.GetGVKKey("")) + out = append(out, processedItemKey(pi)) } return out diff --git a/pkg/runtime/indexers/tenantresource/items.go b/pkg/runtime/indexers/tenantresource/items.go new file mode 100644 index 00000000..5fb13d4c --- /dev/null +++ b/pkg/runtime/indexers/tenantresource/items.go @@ -0,0 +1,15 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantresource + +import "github.com/projectcapsule/capsule/pkg/api/meta" + +func processedItemKey(item meta.ObjectReferenceStatus) string { + ref := item.ResourceID + if item.ClusterScoped { + ref.Namespace = "" + } + + return ref.GetGVKKey("") +} diff --git a/pkg/runtime/indexers/tenantresource/items_test.go b/pkg/runtime/indexers/tenantresource/items_test.go new file mode 100644 index 00000000..f7e8e2fa --- /dev/null +++ b/pkg/runtime/indexers/tenantresource/items_test.go @@ -0,0 +1,83 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantresource + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/gvk" +) + +func TestCreatedItemsIndexersUseAdmissionKeyForClusterScopedItems(t *testing.T) { + t.Parallel() + + item := meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Namespace: "tenant-a", + Name: "example", + }, + ObjectReferenceStatusCondition: meta.ObjectReferenceStatusCondition{ + Created: true, + ClusterScoped: true, + }, + } + + want := []string{ + gvk.ResourceID{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "example", + }.GetGVKKey(""), + } + + t.Run("global", func(t *testing.T) { + t.Parallel() + + idx := GlobalCreatedItems{} + obj := &capsulev1beta2.GlobalTenantResource{} + obj.Status.ProcessedItems = meta.ProcessedItems{item} + + if got := idx.Func()(obj); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected keys\nwant=%#v\ngot =%#v", want, got) + } + }) + + t.Run("namespaced", func(t *testing.T) { + t.Parallel() + + idx := NamespacedCreatedItems{} + obj := &capsulev1beta2.TenantResource{} + obj.Status.ProcessedItems = meta.ProcessedItems{item} + + if got := idx.Func()(obj); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected keys\nwant=%#v\ngot =%#v", want, got) + } + }) +} + +func TestProcessedItemKeyKeepsNamespaceForNamespacedItems(t *testing.T) { + t.Parallel() + + item := meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Version: "v1", + Kind: "Secret", + Namespace: "tenant-a", + Name: "example", + }, + } + + want := item.GetGVKKey("") + + if got := processedItemKey(item); got != want { + t.Fatalf("unexpected key: want %q, got %q", want, got) + } +} diff --git a/pkg/runtime/indexers/tenantresource/namespaced_created_items.go b/pkg/runtime/indexers/tenantresource/namespaced_created_items.go index 83f4ef6a..5a6e573e 100644 --- a/pkg/runtime/indexers/tenantresource/namespaced_created_items.go +++ b/pkg/runtime/indexers/tenantresource/namespaced_created_items.go @@ -27,7 +27,7 @@ func (g NamespacedCreatedItems) Func() client.IndexerFunc { for _, pi := range tgr.Status.ProcessedItems { if pi.Created { - out = append(out, pi.GetGVKKey("")) + out = append(out, processedItemKey(pi)) } } diff --git a/pkg/runtime/indexers/tenantresource/namespaced_items.go b/pkg/runtime/indexers/tenantresource/namespaced_items.go index 407d1270..128c3624 100644 --- a/pkg/runtime/indexers/tenantresource/namespaced_items.go +++ b/pkg/runtime/indexers/tenantresource/namespaced_items.go @@ -25,7 +25,7 @@ func (g NamespacedProcessedItems) Func() client.IndexerFunc { out := make([]string, 0, len(tgr.Status.ProcessedItems)) for _, pi := range tgr.Status.ProcessedItems { - out = append(out, pi.GetGVKKey("")) + out = append(out, processedItemKey(pi)) } return out