diff --git a/api/v1beta2/customquota_types.go b/api/v1beta2/customquota_types.go index 80672325..20f5cbf6 100644 --- a/api/v1beta2/customquota_types.go +++ b/api/v1beta2/customquota_types.go @@ -7,7 +7,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/projectcapsule/capsule/pkg/runtime/gvk" + "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/quota" "github.com/projectcapsule/capsule/pkg/runtime/selectors" ) @@ -35,7 +35,7 @@ type CustomQuotaOptionsSpec struct { // +kubebuilder:validation:XValidation:rule="self.op == 'count' ? !has(self.path) || size(self.path) == 0 : has(self.path) && size(self.path) > 0",message="path must be empty when op is 'count'; otherwise path must be set and non-empty" type CustomQuotaSpecSource struct { - gvk.VersionKind `json:",inline"` + runtime.VersionKind `json:",inline"` CustomQuotaSpecSourceConfig `json:",inline"` } diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 065d9ca0..750fb473 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -126,6 +126,7 @@ The following Values have changed key or Value: | manager.options.allowServiceAccountPromotion | bool | `false` | ServiceAccounts within tenant namespaces can be promoted to owners of the given tenant this can be achieved by labeling the serviceaccount and then they are considered owners. This can only be done by other owners of the tenant. However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts. | | manager.options.annotations | object | `{}` | Additional annotations to add to the CapsuleConfiguration resource | | manager.options.cacheInvalidation | string | `"0h30m0s"` | Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server | +| manager.options.cacheSyncTimeout | string | `"4m"` | Timeout used when waiting for controller cache synchronization. Empty uses controller-runtime's default. | | manager.options.capsuleConfiguration | string | `"default"` | Change the default name of the capsule configuration name | | manager.options.capsuleUserGroups | list | `[]` | DEPRECATED: use users properties. Names of the users considered as Capsule users. | | manager.options.clientConnectionBurst | int | `30` | Burst to use for interacting with kubernetes apiserver | @@ -255,6 +256,16 @@ The following Values have changed key or Value: | webhooks.hooks.gateways.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.gateways.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.gateways.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) | +| webhooks.hooks.generic | object | `{"enabled":true,"failurePolicy":"Fail","matchConditions":[{"expression":"!has(request.subResource) || request.subResource == \"\"","name":"ignore-subresources"},{"expression":"request.resource.resource != \"events\"","name":"ignore-events"}],"matchPolicy":"Equivalent","namespaceSelector":{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]},"objectSelector":{},"opts":{},"reinvocationPolicy":"Never","rules":[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE"],"resources":["*"],"scope":"Namespaced"}]}` | Generic Rules API | +| webhooks.hooks.generic.enabled | bool | `true` | Enable the Hook | +| webhooks.hooks.generic.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | +| webhooks.hooks.generic.matchConditions | list | `[{"expression":"!has(request.subResource) || request.subResource == \"\"","name":"ignore-subresources"},{"expression":"request.resource.resource != \"events\"","name":"ignore-events"}]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.generic.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.generic.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.generic.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | +| webhooks.hooks.generic.opts | object | `{}` | Capsule Hook Options | +| webhooks.hooks.generic.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) | +| webhooks.hooks.generic.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE"],"resources":["*"],"scope":"Namespaced"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | | webhooks.hooks.globalcustomquotas.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.globalcustomquotas.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.globalcustomquotas.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | diff --git a/charts/capsule/crds/capsule.clastix.io_customquotas.yaml b/charts/capsule/crds/capsule.clastix.io_customquotas.yaml index 189696bd..e26cbc8a 100644 --- a/charts/capsule/crds/capsule.clastix.io_customquotas.yaml +++ b/charts/capsule/crds/capsule.clastix.io_customquotas.yaml @@ -141,12 +141,25 @@ spec: items: properties: apiVersion: - description: API version of the referent. + description: |- + API version, API group, or API group/version selector of the referent. + + Empty APIVersion means the core Kubernetes API version "v1". + Use "*" to explicitly match all API groups and versions. + + Examples: + - "" means core "v1". + - "v1" means core "v1". + - "apps" means any version in the "apps" API group. + - "apps/v1" means the "apps/v1" API group/version. + - "apps/*" means any version in the "apps" API group. type: string kind: description: |- Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + Use "*" to match all kinds. + minLength: 1 type: string op: default: add @@ -221,7 +234,6 @@ spec: x-kubernetes-map-type: atomic type: array required: - - apiVersion - kind type: object x-kubernetes-validations: diff --git a/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml b/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml index c103def1..9b3398f5 100644 --- a/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globalcustomquotas.yaml @@ -191,12 +191,25 @@ spec: items: properties: apiVersion: - description: API version of the referent. + description: |- + API version, API group, or API group/version selector of the referent. + + Empty APIVersion means the core Kubernetes API version "v1". + Use "*" to explicitly match all API groups and versions. + + Examples: + - "" means core "v1". + - "v1" means core "v1". + - "apps" means any version in the "apps" API group. + - "apps/v1" means the "apps/v1" API group/version. + - "apps/*" means any version in the "apps" API group. type: string kind: description: |- Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + Use "*" to match all kinds. + minLength: 1 type: string op: default: add @@ -271,7 +284,6 @@ spec: x-kubernetes-map-type: atomic type: array required: - - apiVersion - kind type: object x-kubernetes-validations: diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 02da8845..0460af38 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -117,7 +117,18 @@ spec: items: properties: apiVersion: - description: API version of the referent. + description: |- + API version, API group, or API group/version selector of the referent. + + Empty APIVersion means the core Kubernetes API version "v1". + Use "*" to explicitly match all API groups and versions. + + Examples: + - "" means core "v1". + - "v1" means core "v1". + - "apps" means any version in the "apps" API group. + - "apps/v1" means the "apps/v1" API group/version. + - "apps/*" means any version in the "apps" API group. type: string index: description: Index to mount the resource in the template @@ -126,7 +137,9 @@ spec: kind: description: |- Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + Use "*" to match all kinds. + minLength: 1 type: string name: description: |- @@ -193,7 +206,6 @@ spec: type: object x-kubernetes-map-type: atomic required: - - apiVersion - kind type: object type: array @@ -272,12 +284,25 @@ spec: description: Reference properties: apiVersion: - description: API version of the referent. + description: |- + API version, API group, or API group/version selector of the referent. + + Empty APIVersion means the core Kubernetes API version "v1". + Use "*" to explicitly match all API groups and versions. + + Examples: + - "" means core "v1". + - "v1" means core "v1". + - "apps" means any version in the "apps" API group. + - "apps/v1" means the "apps/v1" API group/version. + - "apps/*" means any version in the "apps" API group. type: string kind: description: |- Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + Use "*" to match all kinds. + minLength: 1 type: string name: description: |- @@ -343,7 +368,6 @@ spec: type: object x-kubernetes-map-type: atomic required: - - apiVersion - kind type: object type: array diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index d2219ac4..b8cfcaa0 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -68,6 +68,138 @@ spec: - deny - audit type: string + metadata: + description: Enforcement for object metadata on namespaced resources. + items: + description: MetadataRule defines metadata constraints for + namespaced resources. + properties: + annotations: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Annotations defines metadata policies by + annotation key. + type: object + apiGroups: + description: |- + API groups or API group/version selectors of the referents. + + Empty or omitted APIGroups means the core Kubernetes API version "v1". + Use "*" to match all API groups and versions. + + Examples: + - [] or [""] means core "v1". + - ["v1"] means core "v1". + - ["apps"] means any version in the "apps" API group. + - ["apps/v1"] means only "apps/v1". + - ["apps", "batch/v1"] means any "apps" version and "batch/v1". + - ["*"] means all API groups and versions. + items: + type: string + type: array + kinds: + description: |- + Kinds of the referents. + + Use "*" to match all kinds. + items: + minLength: 1 + type: string + minItems: 1 + type: array + labels: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Labels defines metadata policies by label + key. + type: object + required: + - kinds + type: object + x-kubernetes-validations: + - message: at least one of labels or annotations must be set + rule: has(self.labels) || has(self.annotations) + type: array services: description: Enforcement for Services. properties: @@ -340,6 +472,140 @@ spec: - deny - audit type: string + metadata: + description: Enforcement for object metadata on namespaced + resources. + items: + description: MetadataRule defines metadata constraints for + namespaced resources. + properties: + annotations: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must + be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Annotations defines metadata policies by + annotation key. + type: object + apiGroups: + description: |- + API groups or API group/version selectors of the referents. + + Empty or omitted APIGroups means the core Kubernetes API version "v1". + Use "*" to match all API groups and versions. + + Examples: + - [] or [""] means core "v1". + - ["v1"] means core "v1". + - ["apps"] means any version in the "apps" API group. + - ["apps/v1"] means only "apps/v1". + - ["apps", "batch/v1"] means any "apps" version and "batch/v1". + - ["*"] means all API groups and versions. + items: + type: string + type: array + kinds: + description: |- + Kinds of the referents. + + Use "*" to match all kinds. + items: + minLength: 1 + type: string + minItems: 1 + type: array + labels: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must + be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Labels defines metadata policies by label + key. + type: object + required: + - kinds + type: object + x-kubernetes-validations: + - message: at least one of labels or annotations must be + set + rule: has(self.labels) || has(self.annotations) + type: array services: description: Enforcement for Services. properties: @@ -549,6 +815,140 @@ spec: - deny - audit type: string + metadata: + description: Enforcement for object metadata on namespaced + resources. + items: + description: MetadataRule defines metadata constraints + for namespaced resources. + properties: + annotations: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the + provided values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must + be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Annotations defines metadata policies + by annotation key. + type: object + apiGroups: + description: |- + API groups or API group/version selectors of the referents. + + Empty or omitted APIGroups means the core Kubernetes API version "v1". + Use "*" to match all API groups and versions. + + Examples: + - [] or [""] means core "v1". + - ["v1"] means core "v1". + - ["apps"] means any version in the "apps" API group. + - ["apps/v1"] means only "apps/v1". + - ["apps", "batch/v1"] means any "apps" version and "batch/v1". + - ["*"] means all API groups and versions. + items: + type: string + type: array + kinds: + description: |- + Kinds of the referents. + + Use "*" to match all kinds. + items: + minLength: 1 + type: string + minItems: 1 + type: array + labels: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the + provided values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must + be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Labels defines metadata policies by label + key. + type: object + required: + - kinds + type: object + x-kubernetes-validations: + - message: at least one of labels or annotations must + be set + rule: has(self.labels) || has(self.annotations) + type: array services: description: Enforcement for Services. properties: diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 31b730c8..9ac13608 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -117,7 +117,18 @@ spec: items: properties: apiVersion: - description: API version of the referent. + description: |- + API version, API group, or API group/version selector of the referent. + + Empty APIVersion means the core Kubernetes API version "v1". + Use "*" to explicitly match all API groups and versions. + + Examples: + - "" means core "v1". + - "v1" means core "v1". + - "apps" means any version in the "apps" API group. + - "apps/v1" means the "apps/v1" API group/version. + - "apps/*" means any version in the "apps" API group. type: string index: description: Index to mount the resource in the template @@ -126,7 +137,9 @@ spec: kind: description: |- Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + Use "*" to match all kinds. + minLength: 1 type: string name: description: |- @@ -193,7 +206,6 @@ spec: type: object x-kubernetes-map-type: atomic required: - - apiVersion - kind type: object type: array @@ -272,12 +284,25 @@ spec: description: Reference properties: apiVersion: - description: API version of the referent. + description: |- + API version, API group, or API group/version selector of the referent. + + Empty APIVersion means the core Kubernetes API version "v1". + Use "*" to explicitly match all API groups and versions. + + Examples: + - "" means core "v1". + - "v1" means core "v1". + - "apps" means any version in the "apps" API group. + - "apps/v1" means the "apps/v1" API group/version. + - "apps/*" means any version in the "apps" API group. type: string kind: description: |- Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + Use "*" to match all kinds. + minLength: 1 type: string name: description: |- @@ -343,7 +368,6 @@ spec: type: object x-kubernetes-map-type: atomic required: - - apiVersion - kind type: object type: array diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index ce26495d..75c696e3 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2515,6 +2515,140 @@ spec: - deny - audit type: string + metadata: + description: Enforcement for object metadata on namespaced + resources. + items: + description: MetadataRule defines metadata constraints + for namespaced resources. + properties: + annotations: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the + provided values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must + be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Annotations defines metadata policies + by annotation key. + type: object + apiGroups: + description: |- + API groups or API group/version selectors of the referents. + + Empty or omitted APIGroups means the core Kubernetes API version "v1". + Use "*" to match all API groups and versions. + + Examples: + - [] or [""] means core "v1". + - ["v1"] means core "v1". + - ["apps"] means any version in the "apps" API group. + - ["apps/v1"] means only "apps/v1". + - ["apps", "batch/v1"] means any "apps" version and "batch/v1". + - ["*"] means all API groups and versions. + items: + type: string + type: array + kinds: + description: |- + Kinds of the referents. + + Use "*" to match all kinds. + items: + minLength: 1 + type: string + minItems: 1 + type: array + labels: + additionalProperties: + properties: + required: + default: false + description: |- + Required enforces that the metadata key must be present. + + This is mainly meaningful with action=allow. Deny and audit rules remain + value matchers and do not require missing metadata to exist. + type: boolean + values: + description: |- + Values defines allowed, denied, or audited values for the metadata key. + + If Required=true and Values is empty, only presence is enforced. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the + provided values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must + be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + description: Labels defines metadata policies by label + key. + type: object + required: + - kinds + type: object + x-kubernetes-validations: + - message: at least one of labels or annotations must + be set + rule: has(self.labels) || has(self.annotations) + type: array services: description: Enforcement for Services. properties: diff --git a/charts/capsule/templates/_pod.tpl b/charts/capsule/templates/_pod.tpl index cc5220e1..936e4d6c 100644 --- a/charts/capsule/templates/_pod.tpl +++ b/charts/capsule/templates/_pod.tpl @@ -67,6 +67,9 @@ spec: - --workers={{ .Values.manager.options.workers }} - --client-connection-qps={{ .Values.manager.options.clientConnectionQPS }} - --client-connection-burst={{ .Values.manager.options.clientConnectionBurst }} + {{- with .Values.manager.options.cacheSyncTimeout }} + - --cache-sync-timeout={{ . }} + {{- end }} {{- with .Values.manager.extraArgs }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/charts/capsule/templates/configuration.yaml b/charts/capsule/templates/configuration.yaml index 7f05d3af..ec345c2c 100644 --- a/charts/capsule/templates/configuration.yaml +++ b/charts/capsule/templates/configuration.yaml @@ -64,6 +64,38 @@ spec: {{- end }} webhooks: {{- $any := false -}} + {{- with .Values.webhooks.hooks.generic }} + {{- if .enabled }} + {{- $any = true }} + - name: generic.rules.validating.projectcapsule.dev + {{- with .opts }} + opts: + {{- toYaml . | nindent 10 }} + {{- end }} + admissionReviewVersions: + - v1 + - v1beta1 + path: "/rules/generic/validating" + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 10 }} + {{- end }} + rules: + {{- toYaml .rules | nindent 10 }} + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} + {{- end }} + {{- end }} {{- with .Values.webhooks.hooks.namespaces }} {{- if .enabled }} {{- $any = true }} @@ -817,6 +849,7 @@ spec: apiVersions: - v1beta2 operations: + - CREATE - UPDATE resources: - capsuleconfigurations diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index 4aec85e9..c3eeab25 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -302,8 +302,14 @@ "queuing": { "type": "object", "properties": { + "handSize": { + "type": "integer" + }, "queueLengthLimit": { "type": "integer" + }, + "queues": { + "type": "integer" } } }, @@ -426,6 +432,10 @@ "description": "Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server", "type": "string" }, + "cacheSyncTimeout": { + "description": "Timeout used when waiting for controller cache synchronization. Empty uses controller-runtime's default.", + "type": "string" + }, "capsuleConfiguration": { "description": "Change the default name of the capsule configuration name", "type": "string" @@ -1390,6 +1400,107 @@ } } }, + "generic": { + "description": "Generic Rules API", + "type": "object", + "properties": { + "enabled": { + "description": "Enable the Hook", + "type": "boolean" + }, + "failurePolicy": { + "description": "[FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)", + "type": "string" + }, + "matchConditions": { + "description": "[MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "array", + "items": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "matchPolicy": { + "description": "[MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "string" + }, + "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" + } + } + } + } + } + }, + "objectSelector": { + "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", + "type": "object" + }, + "opts": { + "description": "Capsule Hook Options", + "type": "object" + }, + "reinvocationPolicy": { + "description": "[ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)", + "type": "string" + }, + "rules": { + "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", + "type": "array", + "items": { + "type": "object", + "properties": { + "apiGroups": { + "type": "array", + "items": { + "type": "string" + } + }, + "apiVersions": { + "type": "array", + "items": { + "type": "string" + } + }, + "operations": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope": { + "type": "string" + } + } + } + } + } + }, "globalcustomquotas": { "type": "object", "properties": { diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index dc437513..5ef532db 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -133,11 +133,13 @@ manager: priorityLevelConfigurationSpec: type: Limited limited: - nominalConcurrencyShares: 10 + nominalConcurrencyShares: 100 limitResponse: - queuing: - queueLengthLimit: 50 type: Queue + queuing: + queues: 64 + handSize: 6 + queueLengthLimit: 100 # Manager RBAC rbac: @@ -230,6 +232,8 @@ manager: clientConnectionQPS: 20.0 # -- Burst to use for interacting with kubernetes apiserver clientConnectionBurst: 30 + # -- Timeout used when waiting for controller cache synchronization. Empty uses controller-runtime's default. + cacheSyncTimeout: "4m" # -- Define entities which are considered part of the Capsule construct. # Users not mentioned here will be ignored by Capsule users: @@ -666,6 +670,46 @@ webhooks: - '*' scope: Namespaced + # -- Generic Rules API + generic: + # -- Enable the Hook + enabled: true + # -- Capsule Hook Options + opts: {} + # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) + failurePolicy: Fail + # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchPolicy: Equivalent + # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) + objectSelector: {} + # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) + namespaceSelector: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists + # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchConditions: + - name: ignore-subresources + expression: '!has(request.subResource) || request.subResource == ""' + - name: ignore-events + expression: 'request.resource.resource != "events"' + + # -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) + reinvocationPolicy: Never + # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) + rules: + - apiGroups: + - '*' + apiVersions: + - '*' + operations: + - CREATE + - UPDATE + resources: + - '*' + scope: Namespaced + + resourcepools: pools: # -- Enable the Hook diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 0e19ba9a..3e010621 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -75,6 +75,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/pvc" "github.com/projectcapsule/capsule/internal/webhook/resourcepool" "github.com/projectcapsule/capsule/internal/webhook/route" + rulesgenericvalidation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/validation" podrules "github.com/projectcapsule/capsule/internal/webhook/rules/pods/validation" servicerules "github.com/projectcapsule/capsule/internal/webhook/rules/services/validation" "github.com/projectcapsule/capsule/internal/webhook/service" @@ -528,7 +529,7 @@ func main() { targetsCache := cache.NewCompiledTargetsCache[string]() if directCfg.EnableTLSConfiguration() { - if err = tlsReconciler.SetupWithManager(manager); err != nil { + if err = tlsReconciler.SetupWithManager(manager, controllerConfig); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Namespace") os.Exit(1) } @@ -572,6 +573,7 @@ func main() { // webhooks: the order matters, don't change it and just append webhooksList := append( make([]handlers.Webhook, 0), + rulesgenericvalidation.Register(regexCache), route.GenericReplicasHandler(), route.GenericManagedHandler(cfg), route.Pod( @@ -625,13 +627,14 @@ func main() { tenantvalidation.IngressClassRegexHandler(), tenantvalidation.StorageClassRegexHandler(), tenantvalidation.ContainerRegistryRegexHandler(), - tenantvalidation.RuleHandler(), + tenantvalidation.RuleHandler(manager.GetRESTMapper()), tenantvalidation.HostnameRegexHandler(), tenantvalidation.FreezedEmitter(), tenantvalidation.OwnersHandler(), tenantvalidation.ForbiddenAnnotationsRegexHandler(), tenantvalidation.ProtectedHandler(), tenantvalidation.RequiredMetadataHandler(), + // Must run last, because always returns response tenantvalidation.WarningHandler(cfg), ), ), @@ -679,12 +682,14 @@ func main() { ), route.ConfigValidation( cfgvalidation.Handler(cfg, - cfgvalidation.WarningHandler(), + cfgvalidation.ValidationHandler(regexCache), cfgvalidation.ServiceAccountHandler(), cfgvalidation.OwnerHandler(), + // Must run last, because always returns response + cfgvalidation.WarningHandler(), ), ), - route.RulesValidating(cfg), + route.RulesValidating(manager.GetRESTMapper(), cfg), ) nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion) @@ -730,21 +735,21 @@ func main() { if err = (&servicelabelscontroller.ServicesLabelsReconciler{ Log: ctrl.Log.WithName("capsule.ctrl").WithName("services"), - }).SetupWithManager(ctx, manager); err != nil { + }).SetupWithManager(ctx, manager, controllerConfig); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ServiceLabels") os.Exit(1) } if err = (&servicelabelscontroller.EndpointSlicesLabelsReconciler{ Log: ctrl.Log.WithName("capsule.ctrl").WithName("endpointslices"), - }).SetupWithManager(ctx, manager); err != nil { + }).SetupWithManager(ctx, manager, controllerConfig); err != nil { setupLog.Error(err, "unable to create controller", "controller", "EndpointSliceLabels") } if err = (&podlabelscontroller.MetadataReconciler{ Client: manager.GetClient(), Log: ctrl.Log.WithName("capsule.ctrl").WithName("pods"), - }).SetupWithManager(ctx, manager); err != nil { + }).SetupWithManager(ctx, manager, controllerConfig); err != nil { setupLog.Error(err, "unable to create controller", "controller", "PodLabels") os.Exit(1) } diff --git a/e2e/node_user_metadata_test.go b/e2e/config_node_user_metadata_test.go similarity index 89% rename from e2e/node_user_metadata_test.go rename to e2e/config_node_user_metadata_test.go index 7ca62e52..c7009609 100644 --- a/e2e/node_user_metadata_test.go +++ b/e2e/config_node_user_metadata_test.go @@ -286,4 +286,40 @@ var _ = Describe("modifying node labels and annotations", Ordered, Label("config }).ShouldNot(Succeed()) }) }) + + It("should reject malformed node metadata forbidden label regex", func() { + ExpectCapsuleConfigurationUpdateDenied( + func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.NodeMetadata = &capsulev1beta2.NodeMetadata{ + ForbiddenLabels: api.ForbiddenListSpec{ + Regex: "[", + }, + ForbiddenAnnotations: api.ForbiddenListSpec{ + Regex: "^valid-annotation-.*$", + }, + } + }, + "spec.nodeMetadata.forbiddenLabels.regex", + "[", + "not a valid regular expression", + ) + }) + + It("should reject malformed node metadata forbidden annotation regex", func() { + ExpectCapsuleConfigurationUpdateDenied( + func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.NodeMetadata = &capsulev1beta2.NodeMetadata{ + ForbiddenLabels: api.ForbiddenListSpec{ + Regex: "^valid-label-.*$", + }, + ForbiddenAnnotations: api.ForbiddenListSpec{ + Regex: "[", + }, + } + }, + "spec.nodeMetadata.forbiddenAnnotations.regex", + "[", + "not a valid regular expression", + ) + }) }) diff --git a/e2e/config_protected_regex_test.go b/e2e/config_protected_regex_test.go index 7f0fed9c..2940c0bb 100644 --- a/e2e/config_protected_regex_test.go +++ b/e2e/config_protected_regex_test.go @@ -88,4 +88,15 @@ var _ = Describe("creating a Namespace with a protected Namespace regex enabled" configuration.Spec.ProtectedNamespaceRegexpString = "" }) }) + + It("should reject malformed protected namespace regex", func() { + ExpectCapsuleConfigurationUpdateDenied( + func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.ProtectedNamespaceRegexpString = "[" + }, + "spec.protectedNamespaceRegex", + "[", + "not a valid regular expression", + ) + }) }) diff --git a/e2e/customquota_global_test.go b/e2e/customquota_global_test.go index 78d77d9d..38cd86c3 100644 --- a/e2e/customquota_global_test.go +++ b/e2e/customquota_global_test.go @@ -23,7 +23,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" capmeta "github.com/projectcapsule/capsule/pkg/api/meta" - "github.com/projectcapsule/capsule/pkg/runtime/gvk" + "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/quota" "github.com/projectcapsule/capsule/pkg/runtime/selectors" ) @@ -328,7 +328,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -394,7 +394,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -452,7 +452,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -504,7 +504,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -666,7 +666,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -700,7 +700,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -759,7 +759,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -794,7 +794,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("200m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -851,7 +851,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -894,7 +894,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -956,7 +956,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -991,7 +991,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1042,7 +1042,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1076,7 +1076,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("3"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1132,7 +1132,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1183,7 +1183,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1193,7 +1193,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1250,7 +1250,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1260,7 +1260,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1317,7 +1317,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1327,7 +1327,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1406,7 +1406,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1416,7 +1416,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1477,7 +1477,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1486,7 +1486,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1544,7 +1544,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1553,7 +1553,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1613,7 +1613,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1622,7 +1622,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1657,7 +1657,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1666,7 +1666,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1741,7 +1741,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("1000"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1797,7 +1797,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1861,7 +1861,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1910,7 +1910,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1979,7 +1979,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2039,7 +2039,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2100,7 +2100,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2168,7 +2168,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2227,7 +2227,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2287,7 +2287,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("3Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2297,7 +2297,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -2354,7 +2354,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2414,7 +2414,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2433,7 +2433,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -2511,7 +2511,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2530,7 +2530,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -2593,7 +2593,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2649,7 +2649,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2683,7 +2683,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2748,7 +2748,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("200m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2783,7 +2783,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2850,7 +2850,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2893,7 +2893,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2977,7 +2977,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("400m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3012,7 +3012,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("2Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3072,7 +3072,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3115,7 +3115,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3176,7 +3176,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("3"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3210,7 +3210,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("4"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3282,7 +3282,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("300m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -3317,7 +3317,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord Limit: resource.MustParse("3Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, diff --git a/e2e/customquota_namespaced_test.go b/e2e/customquota_namespaced_test.go index cbba61bb..43d3b3b6 100644 --- a/e2e/customquota_namespaced_test.go +++ b/e2e/customquota_namespaced_test.go @@ -9,7 +9,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" - "github.com/projectcapsule/capsule/pkg/runtime/gvk" + "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/quota" "github.com/projectcapsule/capsule/pkg/runtime/selectors" corev1 "k8s.io/api/core/v1" @@ -98,7 +98,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -149,7 +149,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -191,7 +191,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -274,7 +274,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -309,7 +309,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("2Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -360,7 +360,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -370,7 +370,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -417,7 +417,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -480,7 +480,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -530,7 +530,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -582,7 +582,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -641,7 +641,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -691,7 +691,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -742,7 +742,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("3Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -752,7 +752,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -800,7 +800,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -819,7 +819,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -888,7 +888,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -929,7 +929,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -939,7 +939,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -986,7 +986,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -996,7 +996,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1043,7 +1043,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1053,7 +1053,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1122,7 +1122,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1132,7 +1132,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1183,7 +1183,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1207,7 +1207,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1263,7 +1263,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("200m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1288,7 +1288,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1345,7 +1345,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1378,7 +1378,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1450,7 +1450,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("400m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1475,7 +1475,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("2Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1526,7 +1526,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("3"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1550,7 +1550,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1611,7 +1611,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("5"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1645,7 +1645,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("2"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1695,7 +1695,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("500m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1730,7 +1730,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("200m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1781,7 +1781,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1800,7 +1800,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, }, }, { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "PersistentVolumeClaim", }, @@ -1855,7 +1855,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1907,7 +1907,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1960,7 +1960,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -1993,7 +1993,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2045,7 +2045,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("3"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2069,7 +2069,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("4"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2123,7 +2123,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("300m"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2148,7 +2148,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("3Gi"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, @@ -2192,7 +2192,7 @@ var _ = Describe("when CustomQuota uses ledger-backed reconciliation", Ordered, Limit: resource.MustParse("10"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { - VersionKind: gvk.VersionKind{ + VersionKind: runtime.VersionKind{ APIVersion: "v1", Kind: "Pod", }, diff --git a/e2e/pool_resourcepool_test.go b/e2e/pool_resourcepool_test.go index 15fa4074..0382ce43 100644 --- a/e2e/pool_resourcepool_test.go +++ b/e2e/pool_resourcepool_test.go @@ -924,6 +924,223 @@ var _ = Describe("ResourcePool Tests", Ordered, Label("resourcepool", "pool"), f }) }) + It("ResourcePool Claim Resize - Recalculates Allocation", func() { + pool := &capsulev1beta2.ResourcePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "claim-resize-recalculation", + Labels: map[string]string{ + "e2e-resourcepool": "test", + }, + }, + Spec: capsulev1beta2.ResourcePoolSpec{ + Selectors: []selectors.NamespaceSelector{ + { + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "e2e.capsule.dev/test-suite": "claim-resize-recalculation", + }, + }, + }, + }, + Quota: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("10"), + }, + }, + }, + } + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-claim-resize-recalculation", + Labels: map[string]string{ + "e2e-resourcepool": "test", + "e2e.capsule.dev/test-suite": "claim-resize-recalculation", + }, + }, + } + + claim := &capsulev1beta2.ResourcePoolClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "resize", + Namespace: namespace.Name, + }, + Spec: capsulev1beta2.ResourcePoolClaimSpec{ + Pool: pool.Name, + ResourceClaims: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("5"), + }, + }, + } + + By("Create the ResourcePool", func() { + EventuallyCreation(func() error { + pool.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), pool) + }).Should(Succeed(), "Failed to create ResourcePool %s", pool) + }) + + By("Create source namespace", func() { + Expect(k8sClient.Create(context.TODO(), namespace)).To(Succeed(), "Failed to create Namespace %s", namespace) + }) + + By("Create an initially valid claim", func() { + Expect(k8sClient.Create(context.TODO(), claim)).To(Succeed(), "Failed to create Claim %s", claim) + + isSuccessfullyBoundAndUnsedToPool(pool, claim) + + ExpectPoolAllocation(pool.Name, capsulev1beta2.ResourcePoolQuotaStatus{ + Hard: pool.Spec.Quota.Hard, + Claimed: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("5"), + }, + Available: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("5"), + }, + }) + }) + + By("Resize the unused claim within pool capacity", func() { + Eventually(func() error { + current := &capsulev1beta2.ResourcePoolClaim{} + if err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(claim), current); err != nil { + return err + } + + current.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("8"), + } + + return k8sClient.Update(context.TODO(), current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + claim.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("8"), + } + + isSuccessfullyBoundAndUnsedToPool(pool, claim) + + ExpectPoolAllocation(pool.Name, capsulev1beta2.ResourcePoolQuotaStatus{ + Hard: pool.Spec.Quota.Hard, + Claimed: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("8"), + }, + Available: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("2"), + }, + }) + }) + + By("Resize the unused claim beyond pool capacity", func() { + Eventually(func() error { + current := &capsulev1beta2.ResourcePoolClaim{} + if err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(claim), current); err != nil { + return err + } + + current.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("55"), + } + + return k8sClient.Update(context.TODO(), current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + claim.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("55"), + } + + assertClaimExhausted(pool, claim, meta.PoolExhaustedReason, []string{ + "requested.requests.cpu=55", + "available.requests.cpu=10", + }) + + ExpectPoolAllocation(pool.Name, capsulev1beta2.ResourcePoolQuotaStatus{ + Hard: pool.Spec.Quota.Hard, + Claimed: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("0"), + }, + Available: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("10"), + }, + }) + }) + + By("Resize the exhausted claim back within pool capacity", func() { + Eventually(func() error { + current := &capsulev1beta2.ResourcePoolClaim{} + if err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(claim), current); err != nil { + return err + } + + current.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("8"), + } + + return k8sClient.Update(context.TODO(), current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + claim.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("8"), + } + + isSuccessfullyBoundAndUnsedToPool(pool, claim) + }) + + By("Create workload usage for the claim", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "resize-claim-pod", + Namespace: namespace.Name, + }, + Spec: corev1.PodSpec{ + SecurityContext: nobodyPodSecurityContext(), + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "pause", + Image: "registry.k8s.io/pause:3.9", + SecurityContext: restrictedContainerSecurityContext(), + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + }, + }, + } + + Expect(k8sClient.Create(context.TODO(), pod)).To(Succeed()) + isSuccessfullyBoundAndUsedToPool(pool, claim) + }) + + By("Deny resizing a claim that is in use", func() { + Eventually(func() error { + current := &capsulev1beta2.ResourcePoolClaim{} + if err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(claim), current); err != nil { + return err + } + + current.Spec.ResourceClaims = corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("9"), + } + + return k8sClient.Update(context.TODO(), current) + }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) + + ExpectPoolAllocation(pool.Name, capsulev1beta2.ResourcePoolQuotaStatus{ + Hard: pool.Spec.Quota.Hard, + Claimed: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("8"), + }, + Available: corev1.ResourceList{ + corev1.ResourceRequestsCPU: resource.MustParse("2"), + }, + }) + }) + }) + It("ResourcePool Scheduling - Ordered", func() { pool := &capsulev1beta2.ResourcePool{ ObjectMeta: metav1.ObjectMeta{ diff --git a/e2e/replications_globaltenantresource_test.go b/e2e/replications_globaltenantresource_test.go index 689f836f..1755d1cf 100644 --- a/e2e/replications_globaltenantresource_test.go +++ b/e2e/replications_globaltenantresource_test.go @@ -25,7 +25,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" apimeta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" - "github.com/projectcapsule/capsule/pkg/runtime/gvk" + capruntime "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/template" ) @@ -270,7 +270,7 @@ var _ = Describe("GlobalTenantResource", Ordered, Label("replications", "global" PruningOnDelete: ptr.To(true), Resources: []capsulev1beta2.ResourceSpec{{ NamespacedItems: []template.ResourceReference{{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -539,7 +539,7 @@ var _ = Describe("GlobalTenantResource", Ordered, Label("replications", "global" Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -626,7 +626,7 @@ data: PruningOnDelete: ptr.To(true), Resources: []capsulev1beta2.ResourceSpec{{ NamespacedItems: []template.ResourceReference{{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -1224,7 +1224,7 @@ data: Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, diff --git a/e2e/replications_tenantresource_test.go b/e2e/replications_tenantresource_test.go index 47e67f6f..39ebfedd 100644 --- a/e2e/replications_tenantresource_test.go +++ b/e2e/replications_tenantresource_test.go @@ -21,6 +21,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api" apimeta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" + capruntime "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/gvk" "github.com/projectcapsule/capsule/pkg/template" ) @@ -238,7 +239,7 @@ var _ = Describe("TenantResource SSA", Ordered, Label("replications", "namespace Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -371,7 +372,7 @@ data: Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -423,7 +424,7 @@ data: Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -488,7 +489,7 @@ data: Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -554,7 +555,7 @@ data: Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -630,7 +631,7 @@ data: ResyncPeriod: resyncPeriod, Resources: []capsulev1beta2.ResourceSpec{{ NamespacedItems: []template.ResourceReference{{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -804,7 +805,7 @@ data: ResyncPeriod: resyncPeriod, Resources: []capsulev1beta2.ResourceSpec{{ NamespacedItems: []template.ResourceReference{{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -1258,7 +1259,7 @@ data: Resources: []*template.TemplateResourceReference{{ Index: "secrets", ResourceReference: template.ResourceReference{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, @@ -1392,7 +1393,7 @@ data: PruningOnDelete: ptr.To(true), Resources: []capsulev1beta2.ResourceSpec{{ NamespacedItems: []template.ResourceReference{{ - VersionKind: gvk.VersionKind{ + VersionKind: capruntime.VersionKind{ APIVersion: "v1", Kind: "Secret", }, diff --git a/e2e/rules_enforce_metadata_test.go b/e2e/rules_enforce_metadata_test.go new file mode 100644 index 00000000..ceb2ac62 --- /dev/null +++ b/e2e/rules_enforce_metadata_test.go @@ -0,0 +1,2182 @@ +// Copyright 2020-2026 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("tenant", "rules", "enforce", "metadata", "generic"), func() { + const ownerName = "e2e-rules-metadata" + + var ( + tnt *capsulev1beta2.Tenant + tenantRules []*rules.NamespaceRuleBodyTenant + ) + + metadataByExpression := func(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ + Expression: expression, + }, + } + } + + metadataByNegatedExpression := func(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } + } + + metadataByExact := func(exact ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + Exact: exact, + } + } + + metadataByMatch := func(exact []string, expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ + Expression: expression, + }, + Exact: exact, + } + } + + metadataValueRule := func(required bool, values ...runtime.ExpressionMatch) rules.MetadataValueRule { + return rules.MetadataValueRule{ + Required: required, + Values: values, + } + } + + metadataRule := func( + action rules.ActionType, + apiVersion string, + kinds []string, + labels map[string]rules.MetadataValueRule, + annotations map[string]rules.MetadataValueRule, + ) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{apiVersion}, + Kinds: kinds, + }, + Labels: labels, + Annotations: annotations, + }, + }, + }, + }, + } + } + + selectedRule := func( + selector map[string]string, + rule *rules.NamespaceRuleBodyTenant, + ) *rules.NamespaceRuleBodyTenant { + rule.NamespaceSelector = &metav1.LabelSelector{ + MatchLabels: selector, + } + + return rule + } + + baseTenantRules := func() []*rules.NamespaceRuleBodyTenant { + return []*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "", + []string{ + "ConfigMap", + "Service", + }, + map[string]rules.MetadataValueRule{ + "example.corp/tenant": metadataValueRule( + true, + metadataByExact("prod", "test"), + ), + }, + map[string]rules.MetadataValueRule{ + "example.corp/cost-center": metadataValueRule( + false, + metadataByExpression("^INV-[0-9]{4}$"), + metadataByExact("prod", "test"), + ), + }, + ), + metadataRule( + rules.ActionTypeAudit, + "*", + []string{ + "ConfigMap", + "Service", + }, + map[string]rules.MetadataValueRule{ + "example.corp/audit": metadataValueRule( + false, + metadataByExpression("^audit-.*"), + ), + }, + nil, + ), + selectedRule( + map[string]string{ + "environment": "prod", + }, + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + nil, + map[string]rules.MetadataValueRule{ + "example.corp/approval": metadataValueRule( + true, + metadataByExact("approved"), + ), + }, + ), + ), + } + } + + newTenant := func() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-rule-metadata", + Labels: map[string]string{ + "env": "e2e", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: ownerName, + Kind: "User", + }, + }, + }, + }, + Rules: tenantRules, + }, + } + } + + type expectedMetadataPolicy struct { + required bool + expressions []string + exact [][]string + negated []bool + } + + type expectedMetadataStatusRule struct { + action rules.ActionType + apiGroups []string + kinds []string + labels map[string]expectedMetadataPolicy + annotations map[string]expectedMetadataPolicy + } + + expectMetadataPolicy := func(g Gomega, got rules.MetadataValueRule, expected expectedMetadataPolicy) { + g.Expect(got.Required).To(Equal(expected.required)) + + wantValues := len(expected.expressions) + if len(expected.exact) > wantValues { + wantValues = len(expected.exact) + } + if len(expected.negated) > wantValues { + wantValues = len(expected.negated) + } + + g.Expect(got.Values).To(HaveLen(wantValues)) + + for i := 0; i < wantValues; i++ { + value := got.Values[i] + + if len(expected.expressions) > i { + g.Expect(value.Expression).To(Equal(expected.expressions[i])) + } else { + g.Expect(value.Expression).To(BeEmpty()) + } + + if len(expected.exact) > i { + g.Expect(value.Exact).To(Equal(expected.exact[i])) + } else { + g.Expect(value.Exact).To(BeEmpty()) + } + + if len(expected.negated) > i { + g.Expect(value.Negate).To(Equal(expected.negated[i])) + } else { + g.Expect(value.Negate).To(BeFalse()) + } + } + } + + expectNamespaceStatusRules := func(nsName string, want []expectedMetadataStatusRule) { + Eventually(func(g Gomega) { + nsStatus := &capsulev1beta2.RuleStatus{} + + g.Expect(k8sClient.Get( + context.Background(), + client.ObjectKey{ + Name: meta.NameForManagedRuleStatus(), + Namespace: nsName, + }, + nsStatus, + )).To(Succeed()) + + g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want))) + + for i, expected := range want { + got := nsStatus.Status.Rules[i] + + g.Expect(got).NotTo(BeNil()) + g.Expect(got.Enforce).NotTo(BeNil()) + g.Expect(got.Enforce.Action).To(Equal(expected.action)) + g.Expect(got.Enforce.Metadata).To(HaveLen(1)) + + metadata := got.Enforce.Metadata[0] + + if len(expected.apiGroups) == 0 { + g.Expect(metadata.APIGroups).To(BeEmpty()) + } else { + g.Expect(metadata.APIGroups).To(Equal(expected.apiGroups)) + } + + g.Expect(metadata.Kinds).To(Equal(expected.kinds)) + + g.Expect(metadata.Labels).To(HaveLen(len(expected.labels))) + for key, policy := range expected.labels { + gotPolicy, ok := metadata.Labels[key] + g.Expect(ok).To(BeTrue(), "expected label policy %q", key) + expectMetadataPolicy(g, gotPolicy, policy) + } + + g.Expect(metadata.Annotations).To(HaveLen(len(expected.annotations))) + for key, policy := range expected.annotations { + gotPolicy, ok := metadata.Annotations[key] + g.Expect(ok).To(BeTrue(), "expected annotation policy %q", key) + expectMetadataPolicy(g, gotPolicy, policy) + } + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + baseStatusRules := func() []expectedMetadataStatusRule { + return []expectedMetadataStatusRule{ + { + action: rules.ActionTypeAllow, + apiGroups: []string{ + "v1", + }, + kinds: []string{ + "ConfigMap", + "Service", + }, + labels: map[string]expectedMetadataPolicy{ + "example.corp/tenant": { + required: true, + exact: [][]string{ + { + "prod", + "test", + }, + }, + }, + }, + annotations: map[string]expectedMetadataPolicy{ + "example.corp/cost-center": { + required: false, + expressions: []string{ + "^INV-[0-9]{4}$", + "", + }, + exact: [][]string{ + nil, + { + "prod", + "test", + }, + }, + }, + }, + }, + { + action: rules.ActionTypeAudit, + apiGroups: []string{ + "*", + }, + kinds: []string{ + "ConfigMap", + "Service", + }, + labels: map[string]expectedMetadataPolicy{ + "example.corp/audit": { + expressions: []string{ + "^audit-.*", + }, + }, + }, + }, + } + } + + updateTenantRules := func(next []*rules.NamespaceRuleBodyTenant) { + UpdateTenantEventually(tnt, func(current *capsulev1beta2.Tenant) { + current.Spec.Rules = next + }) + + tnt.Spec.Rules = next + } + + createNamespace := func(labels map[string]string) *corev1.Namespace { + if labels == nil { + labels = map[string]string{} + } + + labels[meta.TenantLabel] = tnt.GetName() + + ns := NewNamespace("", labels) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + return ns + } + + configMap := func(name string, labels map[string]string, annotations map[string]string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + Annotations: annotations, + }, + Data: map[string]string{ + "key": "value", + }, + } + } + + service := func(name string, labels map[string]string, annotations map[string]string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + Annotations: annotations, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + }, + }, + }, + } + } + + deployment := func(name string, labels map[string]string, annotations map[string]string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + Annotations: annotations, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx:1.25", + }, + }, + }, + }, + }, + } + } + + createConfigMapAndExpectAllowed := func(cs kubernetes.Interface, nsName string, cm *corev1.ConfigMap) { + EventuallyCreation(func() error { + _, err := cs.CoreV1().ConfigMaps(nsName).Create(context.Background(), cm, metav1.CreateOptions{}) + + return err + }).Should(Succeed()) + } + + createConfigMapAndExpectDenied := func(cs kubernetes.Interface, nsName string, cm *corev1.ConfigMap, substrings ...string) { + base := cm.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "cm" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().ConfigMaps(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().ConfigMaps(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected configmap create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + updateConfigMapAndExpectDenied := func( + cs kubernetes.Interface, + nsName string, + cmName string, + mutate func(*corev1.ConfigMap), + substrings ...string, + ) { + Eventually(func() error { + cm, err := cs.CoreV1().ConfigMaps(nsName).Get(context.Background(), cmName, metav1.GetOptions{}) + if err != nil { + return err + } + + mutate(cm) + + _, err = cs.CoreV1().ConfigMaps(nsName).Update(context.Background(), cm, metav1.UpdateOptions{}) + if err == nil { + return fmt.Errorf("expected configmap update to be denied, but it succeeded") + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + createServiceAndExpectAllowed := func(cs kubernetes.Interface, nsName string, svc *corev1.Service) { + EventuallyCreation(func() error { + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), svc, metav1.CreateOptions{}) + + return err + }).Should(Succeed()) + } + + createServiceAndExpectDenied := func(cs kubernetes.Interface, nsName string, svc *corev1.Service, substrings ...string) { + base := svc.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "svc" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Services(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected service create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + createDeploymentAndExpectAllowed := func(nsName string, deploy *appsv1.Deployment) { + EventuallyCreation(func() error { + return k8sClient.Create(context.Background(), deploy, &client.CreateOptions{}) + }).Should(Succeed()) + + EventuallyDeletion(deploy) + } + + createDeploymentAndExpectDenied := func(nsName string, deploy *appsv1.Deployment, substrings ...string) { + base := deploy.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "deployment" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + candidate.Namespace = nsName + candidate.Spec.Selector.MatchLabels["app"] = candidate.Name + candidate.Spec.Template.Labels["app"] = candidate.Name + + err := k8sClient.Create(context.Background(), candidate, &client.CreateOptions{}) + if err == nil { + _ = k8sClient.Delete(context.Background(), candidate) + + return fmt.Errorf("expected deployment create to be denied, but it succeeded") + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + expectAuditEvent := func( + cs kubernetes.Interface, + namespace string, + kind string, + objectName string, + substrings ...string, + ) { + Eventually(func() error { + evt, err := cs.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, e := range evt.Items { + if e.Reason != events.ReasonNamespaceRuleAudit { + continue + } + + if e.InvolvedObject.Kind != kind { + continue + } + + eventObjectName := e.InvolvedObject.Name + if eventObjectName != objectName && !strings.HasPrefix(eventObjectName, objectName+"-") { + continue + } + + message := e.Message + + matched := true + for _, substring := range substrings { + if !strings.Contains(message, substring) { + matched = false + + break + } + } + + if matched { + return nil + } + } + + return fmt.Errorf( + "expected audit event for %s %q containing %q", + kind, + objectName, + substrings, + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + BeforeEach(func() { + tenantRules = baseTenantRules() + }) + + JustBeforeEach(func() { + tnt = newTenant() + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + }) + + JustAfterEach(func() { + EventuallyDeletion(tnt) + }) + + It("stores matching tenant metadata rules as independent status rule blocks", func() { + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.GetName(), baseStatusRules()) + }) + + It("stores namespace-selector matched metadata rules as additional independent status rule blocks", func() { + ns := createNamespace(map[string]string{ + "environment": "prod", + }) + + want := baseStatusRules() + want = append(want, expectedMetadataStatusRule{ + action: rules.ActionTypeAllow, + apiGroups: []string{ + "*", + }, + kinds: []string{ + "ConfigMap", + }, + annotations: map[string]expectedMetadataPolicy{ + "example.corp/approval": { + required: true, + exact: [][]string{ + { + "approved", + }, + }, + }, + }, + }) + + expectNamespaceStatusRules(ns.GetName(), want) + }) + + It("denies creation when a required label is missing", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("required-label-missing", nil, nil), + "metadata", + "example.corp/tenant", + "required", + ) + }) + + It("allows creation when a required label is present and exact value matches", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "required-label-allowed", + map[string]string{ + "example.corp/tenant": "prod", + }, + nil, + ), + ) + }) + + It("denies creation when a required label is present but value is not allowed", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "required-label-invalid", + map[string]string{ + "example.corp/tenant": "stage", + }, + nil, + ), + "metadata", + "example.corp/tenant", + "stage", + "not allowed", + ) + }) + + It("allows missing optional annotations but denies present non-matching annotation values", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "optional-annotation-missing", + map[string]string{ + "example.corp/tenant": "prod", + }, + nil, + ), + ) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "optional-annotation-invalid", + map[string]string{ + "example.corp/tenant": "prod", + }, + map[string]string{ + "example.corp/cost-center": "BAD-1234", + }, + ), + "metadata", + "example.corp/cost-center", + "BAD-1234", + "not allowed", + ) + }) + + It("allows optional annotation values matching regex, exact, or combined matchers", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "annotation-regex-allowed", + map[string]string{ + "example.corp/tenant": "prod", + }, + map[string]string{ + "example.corp/cost-center": "INV-1234", + }, + ), + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "annotation-exact-allowed", + map[string]string{ + "example.corp/tenant": "prod", + }, + map[string]string{ + "example.corp/cost-center": "test", + }, + ), + ) + }) + + It("applies one metadata rule to multiple core kinds", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("multi-kind-configmap-denied", nil, nil), + "metadata", + "example.corp/tenant", + "required", + ) + + createServiceAndExpectDenied( + cs, + ns.Name, + service("multi-kind-service-denied", nil, nil), + "metadata", + "example.corp/tenant", + "required", + ) + + createServiceAndExpectAllowed( + cs, + ns.Name, + service( + "multi-kind-service-allowed", + map[string]string{ + "example.corp/tenant": "prod", + }, + nil, + ), + ) + }) + + It("treats empty apiVersion as core v1 and does not match grouped resources", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "", + []string{ + "*", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("core-v1-configmap-denied", nil, nil), + "metadata", + "env", + "required", + ) + + deploy := deployment("apps-deployment-not-matched", nil, nil) + deploy.Namespace = ns.Name + + createDeploymentAndExpectAllowed(ns.Name, deploy) + }) + + It("matches grouped apiVersion and selected kinds", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "apps/v1", + []string{ + "Deployment", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + + createDeploymentAndExpectDenied( + ns.Name, + deployment("apps-deployment-missing-env", nil, nil), + "metadata", + "env", + "required", + ) + + deploy := deployment( + "apps-deployment-env-allowed", + map[string]string{ + "env": "prod", + }, + nil, + ) + deploy.Namespace = ns.Name + + createDeploymentAndExpectAllowed(ns.Name, deploy) + }) + + It("denies a later matching deny rule after an earlier allow rule matched", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod", "test"), + ), + }, + nil, + ), + metadataRule( + rules.ActionTypeDeny, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + false, + metadataByExact("test"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "later-deny-prod-allowed", + map[string]string{ + "env": "prod", + }, + nil, + ), + ) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "later-deny-test-denied", + map[string]string{ + "env": "test", + }, + nil, + ), + "metadata", + "env", + "test", + "denied", + ) + }) + + It("allows a later matching allow rule after an earlier deny rule did not match", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeDeny, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + false, + metadataByExact("blocked"), + ), + }, + nil, + ), + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "later-allow-prod-allowed", + map[string]string{ + "env": "prod", + }, + nil, + ), + ) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "later-allow-blocked-denied", + map[string]string{ + "env": "blocked", + }, + nil, + ), + "metadata", + "env", + "blocked", + "denied", + ) + }) + + It("supports negated metadata matchers", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeDeny, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "team": metadataValueRule( + false, + metadataByNegatedExpression("^trusted-.*"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "negated-denied", + map[string]string{ + "team": "untrusted", + }, + nil, + ), + "metadata", + "team", + "untrusted", + "denied", + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "negated-allowed", + map[string]string{ + "team": "trusted-platform", + }, + nil, + ), + ) + }) + + It("audits matching metadata but does not deny when a separate allow rule matches", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAudit, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "example.corp/audit": metadataValueRule( + false, + metadataByExpression("^audit-.*"), + ), + }, + nil, + ), + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + cm := configMap( + "metadata-audit-allowed", + map[string]string{ + "env": "prod", + "example.corp/audit": "audit-this", + }, + nil, + ) + + createConfigMapAndExpectAllowed(cs, ns.Name, cm) + + expectAuditEvent( + clusterAdminClient(), + ns.Name, + "ConfigMap", + cm.Name, + "metadata", + "example.corp/audit", + "audit-this", + ) + }) + + It("applies namespace-selector matched required annotation only to selected namespaces", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + selectedRule( + map[string]string{ + "environment": "prod", + }, + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + nil, + map[string]rules.MetadataValueRule{ + "example.corp/approval": metadataValueRule( + true, + metadataByExact("approved"), + ), + }, + ), + ), + }) + + devNS := createNamespace(map[string]string{ + "environment": "dev", + }) + prodNS := createNamespace(map[string]string{ + "environment": "prod", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + devNS.Name, + configMap("selected-rule-dev-allowed", nil, nil), + ) + + createConfigMapAndExpectDenied( + cs, + prodNS.Name, + configMap("selected-rule-prod-denied", nil, nil), + "metadata", + "example.corp/approval", + "required", + ) + + createConfigMapAndExpectAllowed( + cs, + prodNS.Name, + configMap( + "selected-rule-prod-allowed", + nil, + map[string]string{ + "example.corp/approval": "approved", + }, + ), + ) + }) + + It("denies an update when a metadata value becomes invalid", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod", "test"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + cm := configMap( + "metadata-update-denied", + map[string]string{ + "env": "prod", + }, + nil, + ) + + createConfigMapAndExpectAllowed(cs, ns.Name, cm) + + updateConfigMapAndExpectDenied( + cs, + ns.Name, + cm.Name, + func(cm *corev1.ConfigMap) { + cm.Labels["env"] = "stage" + }, + "metadata", + "env", + "stage", + "not allowed", + ) + }) + + It("supports required metadata without value constraints", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "presence-only": metadataValueRule(true), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("presence-only-missing", nil, nil), + "metadata", + "presence-only", + "required", + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "presence-only-present", + map[string]string{ + "presence-only": "any-value", + }, + nil, + ), + ) + }) + + It("does not enforce rules for Capsule managed metadata keys", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + meta.TenantLabel: metadataValueRule( + true, + metadataByExact("some-other-tenant"), + ), + }, + map[string]rules.MetadataValueRule{ + meta.ReconcileAnnotation: metadataValueRule( + true, + metadataByExact("must-not-matter"), + ), + }, + ), + metadataRule( + rules.ActionTypeDeny, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + meta.ManagedByCapsuleLabel: metadataValueRule( + false, + metadataByExact("blocked"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "managed-metadata-ignored", + map[string]string{ + meta.ManagedByCapsuleLabel: "blocked", + }, + nil, + ), + ) + }) + + It("skips generic metadata validation for controller-managed objects", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "controller-managed-skipped", + map[string]string{ + meta.NewManagedByCapsuleLabel: meta.ValueController, + "env": "prod", + }, + nil, + ), + ) + }) + + It("enforces non-skipped objects even when a similar managed-by value does not match the skip rule", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "managed-by-other-not-skipped", + map[string]string{ + meta.ManagedByCapsuleLabel: "human", + }, + nil, + ), + "metadata", + "env", + "required", + ) + }) + + It("supports combined exact and regex value matchers for metadata labels", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{ + "ConfigMap", + }, + map[string]rules.MetadataValueRule{ + "release": metadataValueRule( + true, + metadataByMatch( + []string{ + "stable", + }, + "^release-[0-9]+$", + ), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "combined-exact-allowed", + map[string]string{ + "release": "stable", + }, + nil, + ), + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "combined-regex-allowed", + map[string]string{ + "release": "release-42", + }, + nil, + ), + ) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "combined-denied", + map[string]string{ + "release": "canary", + }, + nil, + ), + "metadata", + "release", + "canary", + "not allowed", + ) + }) + + It("treats optional allow metadata as non-required but still validates it when present", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "optional-env": metadataValueRule( + false, + metadataByExact("prod", "test"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap("optional-label-missing-allowed", nil, nil), + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "optional-label-valid-allowed", + map[string]string{ + "optional-env": "prod", + }, + nil, + ), + ) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "optional-label-invalid-denied", + map[string]string{ + "optional-env": "stage", + }, + nil, + ), + "metadata", + "optional-env", + "stage", + "not allowed", + ) + }) + + It("does not treat required on deny rules as a presence requirement", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeDeny, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "blocked": metadataValueRule( + true, + metadataByExact("true"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap("deny-required-missing-allowed", nil, nil), + ) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "deny-required-present-denied", + map[string]string{ + "blocked": "true", + }, + nil, + ), + "metadata", + "blocked", + "true", + "denied", + ) + }) + + It("does not treat required on audit rules as a presence requirement", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAudit, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "audited": metadataValueRule( + true, + metadataByExact("true"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap("audit-required-missing-allowed", nil, nil), + ) + + cm := configMap( + "audit-required-present-allowed", + map[string]string{ + "audited": "true", + }, + nil, + ) + + createConfigMapAndExpectAllowed(cs, ns.Name, cm) + + expectAuditEvent( + clusterAdminClient(), + ns.Name, + "ConfigMap", + cm.Name, + "metadata", + "audited", + "true", + ) + }) + + It("enforces multiple required metadata keys independently and reports the missing key", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + "team": metadataValueRule( + true, + metadataByExact("platform"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "multi-required-team-missing", + map[string]string{ + "env": "prod", + }, + nil, + ), + "metadata", + "team", + "required", + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "multi-required-all-present", + map[string]string{ + "env": "prod", + "team": "platform", + }, + nil, + ), + ) + }) + + It("denies update when a required label is removed", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + cm := configMap( + "required-label-remove-denied", + map[string]string{ + "env": "prod", + }, + nil, + ) + + createConfigMapAndExpectAllowed(cs, ns.Name, cm) + + updateConfigMapAndExpectDenied( + cs, + ns.Name, + cm.Name, + func(cm *corev1.ConfigMap) { + delete(cm.Labels, "env") + }, + "metadata", + "env", + "required", + ) + }) + + It("skips empty metadata values during value evaluation", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "empty-ok": metadataValueRule( + true, + metadataByExact(""), + ), + "empty-not-ok": metadataValueRule( + false, + metadataByExact("prod"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "empty-label-exact-allowed", + map[string]string{ + "empty-ok": "", + }, + nil, + ), + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "empty-label-invalid-skipped", + map[string]string{ + "empty-ok": "", + "empty-not-ok": "", + }, + nil, + ), + ) + }) + + It("matches apiVersion wildcard and kind wildcard across core and grouped resources", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"*"}, + map[string]rules.MetadataValueRule{ + "global-required": metadataValueRule( + true, + metadataByExact("yes"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("wildcard-core-denied", nil, nil), + "metadata", + "global-required", + "required", + ) + + createDeploymentAndExpectDenied( + ns.Name, + deployment("wildcard-grouped-denied", nil, nil), + "metadata", + "global-required", + "required", + ) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "wildcard-core-allowed", + map[string]string{ + "global-required": "yes", + }, + nil, + ), + ) + }) + + It("matches kind wildcard only inside default core v1 apiVersion", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "", + []string{"*"}, + map[string]rules.MetadataValueRule{ + "core-required": metadataValueRule( + true, + metadataByExact("yes"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("core-wildcard-configmap-denied", nil, nil), + "metadata", + "core-required", + "required", + ) + + deploy := deployment("core-wildcard-deployment-not-matched", nil, nil) + deploy.Namespace = ns.Name + + createDeploymentAndExpectAllowed(ns.Name, deploy) + }) + + It("matches partial kind wildcard without matching unrelated kinds", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "v1", + []string{"*Map"}, + map[string]rules.MetadataValueRule{ + "map-required": metadataValueRule( + true, + metadataByExact("yes"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap("partial-kind-configmap-denied", nil, nil), + "metadata", + "map-required", + "required", + ) + + createServiceAndExpectAllowed( + cs, + ns.Name, + service("partial-kind-service-not-matched", nil, nil), + ) + }) + + It("does not let one optional matching key satisfy another required key", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "optional": metadataValueRule( + false, + metadataByExact("ok"), + ), + "required": metadataValueRule( + true, + metadataByExact("ok"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "optional-does-not-satisfy-required", + map[string]string{ + "optional": "ok", + }, + nil, + ), + "metadata", + "required", + "required", + ) + }) + + It("does not let a matching annotation satisfy a required label with the same key", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "shared-key": metadataValueRule( + true, + metadataByExact("label-value"), + ), + }, + map[string]rules.MetadataValueRule{ + "shared-key": metadataValueRule( + false, + metadataByExact("annotation-value"), + ), + }, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "annotation-does-not-satisfy-label", + nil, + map[string]string{ + "shared-key": "annotation-value", + }, + ), + "metadata", + "metadata.labels", + "shared-key", + "required", + ) + }) + + It("ignores managed annotation prefixes when collecting controlled metadata entries", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + nil, + map[string]rules.MetadataValueRule{ + meta.ResourceQuotaAnnotationPrefix + "cpu": metadataValueRule( + true, + metadataByExact("must-not-matter"), + ), + meta.ResourceUsedAnnotationPrefix + "memory": metadataValueRule( + true, + metadataByExact("must-not-matter"), + ), + }, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap("managed-prefix-annotations-ignored", nil, nil), + ) + }) + + It("skips controller-managed objects even when a deny rule would otherwise match", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeDeny, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "blocked": metadataValueRule( + false, + metadataByExact("true"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "controller-managed-deny-skipped", + map[string]string{ + meta.ManagedByCapsuleLabel: meta.ValueController, + "blocked": "false", + }, + nil, + ), + ) + }) + + It("still enforces controller skip rule case-sensitively", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeDeny, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "blocked": metadataValueRule( + false, + metadataByExact("true"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "controller-managed-case-sensitive", + map[string]string{ + meta.ManagedByCapsuleLabel: "Controller", + "blocked": "true", + }, + nil, + ), + "metadata", + "blocked", + "true", + "denied", + ) + }) + + It("allows value after later allow overrides an earlier deny for the same key", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeDeny, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + false, + metadataByExact("test"), + ), + }, + nil, + ), + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("test"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed( + cs, + ns.Name, + configMap( + "later-allow-overrides-earlier-deny", + map[string]string{ + "env": "test", + }, + nil, + ), + ) + }) + + It("denies value after later deny overrides an earlier allow for the same key", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + true, + metadataByExact("test"), + ), + }, + nil, + ), + metadataRule( + rules.ActionTypeDeny, + "*", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "env": metadataValueRule( + false, + metadataByExact("test"), + ), + }, + nil, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectDenied( + cs, + ns.Name, + configMap( + "later-deny-overrides-earlier-allow", + map[string]string{ + "env": "test", + }, + nil, + ), + "metadata", + "env", + "test", + "denied", + ) + }) +}) diff --git a/e2e/rules_enforce_registry_test.go b/e2e/rules_enforce_registry_test.go index 6c012074..1fc5933d 100644 --- a/e2e/rules_enforce_registry_test.go +++ b/e2e/rules_enforce_registry_test.go @@ -18,10 +18,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -47,8 +47,8 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( registryByExpression := func(expression string) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, }, @@ -57,8 +57,8 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( registryByNegatedExpression := func(expression string) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, Negate: true, }, @@ -68,7 +68,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( registryByExact := func(exact ...string) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: exact, }, } @@ -76,8 +76,8 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( registryByMatch := func(exact []string, expression string) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, Exact: exact, @@ -284,8 +284,8 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Targets: targetContainers, Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "policy/.*", }, }, diff --git a/e2e/rules_enforce_scheduler_test.go b/e2e/rules_enforce_scheduler_test.go index bedc4008..d7d9b375 100644 --- a/e2e/rules_enforce_scheduler_test.go +++ b/e2e/rules_enforce_scheduler_test.go @@ -18,10 +18,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label("tenant", "rules", "enforce", "workloads", "scheduler"), func() { @@ -54,7 +54,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeDeny, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "forbidden-scheduler", @@ -71,7 +71,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeAudit, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "audited-scheduler", @@ -92,7 +92,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeAllow, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "forbidden-scheduler", @@ -113,7 +113,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeDeny, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "audited-scheduler", @@ -134,9 +134,9 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeAllow, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "^team-[a-z0-9-]+$", }, }, @@ -155,9 +155,9 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeDeny, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "^team-blocked-[a-z0-9-]+$", }, }, @@ -174,7 +174,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" type expectedSchedulerStatusRule struct { action rules.ActionType targets []rules.WorkloadValidationTarget - schedulers []api.ExpressionMatch + schedulers []runtime.ExpressionMatch } expectNamespaceStatusRules := func(nsName string, want []expectedSchedulerStatusRule) { @@ -322,7 +322,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" expectNamespaceStatusRules(ns.GetName(), []expectedSchedulerStatusRule{ { action: rules.ActionTypeDeny, - schedulers: []api.ExpressionMatch{ + schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "forbidden-scheduler", @@ -333,7 +333,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" }, { action: rules.ActionTypeAudit, - schedulers: []api.ExpressionMatch{ + schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "audited-scheduler", @@ -356,7 +356,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" expectNamespaceStatusRules(ns.GetName(), []expectedSchedulerStatusRule{ { action: rules.ActionTypeDeny, - schedulers: []api.ExpressionMatch{ + schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "forbidden-scheduler", @@ -367,7 +367,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" }, { action: rules.ActionTypeAudit, - schedulers: []api.ExpressionMatch{ + schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "audited-scheduler", @@ -377,7 +377,7 @@ var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label(" }, { action: rules.ActionTypeAllow, - schedulers: []api.ExpressionMatch{ + schedulers: []runtime.ExpressionMatch{ { Exact: []string{ "forbidden-scheduler", diff --git a/e2e/rules_enforce_services_test.go b/e2e/rules_enforce_services_test.go index 2833f97d..169be86a 100644 --- a/e2e/rules_enforce_services_test.go +++ b/e2e/rules_enforce_services_test.go @@ -24,6 +24,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -35,32 +36,32 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " tenantRules []*rules.NamespaceRuleBodyTenant ) - externalNameByExpression := func(expression string) capsuleapi.ExpressionMatch { - return capsuleapi.ExpressionMatch{ - ExpressionRegex: capsuleapi.ExpressionRegex{ + externalNameByExpression := func(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, } } - externalNameByNegatedExpression := func(expression string) capsuleapi.ExpressionMatch { - return capsuleapi.ExpressionMatch{ - ExpressionRegex: capsuleapi.ExpressionRegex{ + externalNameByNegatedExpression := func(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, Negate: true, }, } } - externalNameByExact := func(exact ...string) capsuleapi.ExpressionMatch { - return capsuleapi.ExpressionMatch{ + externalNameByExact := func(exact ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ Exact: exact, } } - externalNameByMatch := func(exact []string, expression string) capsuleapi.ExpressionMatch { - return capsuleapi.ExpressionMatch{ - ExpressionRegex: capsuleapi.ExpressionRegex{ + externalNameByMatch := func(exact []string, expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, Exact: exact, @@ -95,7 +96,7 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " } } - externalNameRule := func(action rules.ActionType, hostnames ...capsuleapi.ExpressionMatch) *rules.NamespaceRuleBodyTenant { + externalNameRule := func(action rules.ActionType, hostnames ...runtime.ExpressionMatch) *rules.NamespaceRuleBodyTenant { return &rules.NamespaceRuleBodyTenant{ NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ Enforce: &rules.NamespaceRuleEnforceBody{ diff --git a/e2e/utils_test.go b/e2e/utils_test.go index 2fa4551a..534a71d0 100644 --- a/e2e/utils_test.go +++ b/e2e/utils_test.go @@ -571,6 +571,46 @@ func ModifyCapsuleConfigurationOpts(fn func(configuration *capsulev1beta2.Capsul Expect(err).ToNot(HaveOccurred()) } +func ExpectCapsuleConfigurationUpdateDenied( + mutate func(*capsulev1beta2.CapsuleConfiguration), + expectedSubstrings ...string, +) { + Eventually(func() error { + configuration := &capsulev1beta2.CapsuleConfiguration{} + if err := k8sClient.Get( + context.Background(), + client.ObjectKey{Name: defaultConfigurationName}, + configuration, + ); err != nil { + return err + } + + mutate(configuration) + + err := k8sClient.Update(context.Background(), configuration) + if err == nil { + return fmt.Errorf("expected CapsuleConfiguration update to be denied, but it succeeded") + } + + if apierrors.IsConflict(err) { + return err + } + + if !apierrors.IsForbidden(err) { + return fmt.Errorf("expected forbidden error, got: %w", err) + } + + message := err.Error() + for _, expected := range expectedSubstrings { + if !strings.Contains(message, expected) { + return fmt.Errorf("expected error to contain %q, got: %s", expected, message) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) +} + func CheckForOwnerRoleBindings(ns *corev1.Namespace, owner rbac.OwnerSpec, roles map[string]bool) func() error { if roles == nil { roles = map[string]bool{ diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index 3a7e0b2d..52dc0c19 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -105,6 +105,35 @@ spec: ports: - from: 30000 to: 32767 + - enforce: + action: "allow" + # generic: + # - kinds: + # - "ConfigMap" + # path: ".data.key" + # entity: "Any/TenantOwner/CapsuleUser" + # operator: "Equals/Truthy" + # values: + # - exact: + # - "sad" + # from: ".status.classes.ingress" + metadata: + - kinds: + - "ConfigMap" + labels: + "corp.com/tenant": + required: true + values: + - exact: + - test + annotations: + "example.corp/cost-center": + required: true + values: + - exp: "^INV-[0-9]{4}$" + exact: + - prod + - test --- apiVersion: capsule.clastix.io/v1beta2 kind: Tenant diff --git a/internal/cache/regex.go b/internal/cache/regex.go index a5fcc577..25ace0f2 100644 --- a/internal/cache/regex.go +++ b/internal/cache/regex.go @@ -11,7 +11,7 @@ import ( "strings" "sync" - "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) type CompiledRegex struct { @@ -46,7 +46,7 @@ func NewRegexCache() *RegexCache { } } -func (c *RegexCache) GetOrCompile(expr api.ExpressionRegex) (*CompiledRegex, bool, error) { +func (c *RegexCache) GetOrCompile(expr runtime.ExpressionRegex) (*CompiledRegex, bool, error) { if c == nil { return nil, false, fmt.Errorf("regex cache is nil") } @@ -129,7 +129,7 @@ func (c *RegexCache) Reset() { c.re = make(map[string]*CompiledRegex) } -func (c *RegexCache) MatchRegex(expr api.ExpressionRegex, value string) (bool, error) { +func (c *RegexCache) MatchRegex(expr runtime.ExpressionRegex, value string) (bool, error) { compiled, _, err := c.GetOrCompile(expr) if err != nil { return false, err @@ -138,7 +138,7 @@ func (c *RegexCache) MatchRegex(expr api.ExpressionRegex, value string) (bool, e return compiled.MatchString(value), nil } -func HashRegex(expr api.ExpressionRegex) string { +func HashRegex(expr runtime.ExpressionRegex) string { var b strings.Builder b.WriteString(strings.TrimSpace(expr.Expression)) diff --git a/internal/cache/regex_test.go b/internal/cache/regex_test.go index 41bccede..794eb02f 100644 --- a/internal/cache/regex_test.go +++ b/internal/cache/regex_test.go @@ -6,7 +6,7 @@ package cache import ( "testing" - "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) func TestCompiledRegexMatchString(t *testing.T) { @@ -14,13 +14,13 @@ func TestCompiledRegexMatchString(t *testing.T) { tests := []struct { name string - expression api.ExpressionRegex + expression runtime.ExpressionRegex value string want bool }{ { name: "normal expression matches matching value", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: "trusted/.*", }, value: "trusted/team/app:1", @@ -28,7 +28,7 @@ func TestCompiledRegexMatchString(t *testing.T) { }, { name: "normal expression does not match non matching value", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: "trusted/.*", }, value: "docker.io/team/app:1", @@ -36,7 +36,7 @@ func TestCompiledRegexMatchString(t *testing.T) { }, { name: "negated expression does not match matching value", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, @@ -45,7 +45,7 @@ func TestCompiledRegexMatchString(t *testing.T) { }, { name: "negated expression matches non matching value", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, @@ -79,7 +79,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { tests := []struct { name string - expression api.ExpressionRegex + expression runtime.ExpressionRegex value string wantMatch bool wantErr bool @@ -88,7 +88,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }{ { name: "compile matching regex", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, }, value: "ghcr.io/projectcapsule/capsule:latest", @@ -99,7 +99,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "compile non matching regex", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, }, value: "docker.io/library/nginx:latest", @@ -110,7 +110,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "compile negated matching regex", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, Negate: true, }, @@ -122,7 +122,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "compile negated non matching regex", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, Negate: true, }, @@ -134,7 +134,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "reject empty expression", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: "", }, value: "ghcr.io/projectcapsule/capsule:latest", @@ -143,7 +143,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "reject invalid regex", - expression: api.ExpressionRegex{ + expression: runtime.ExpressionRegex{ Expression: `[`, }, value: "ghcr.io/projectcapsule/capsule:latest", @@ -207,7 +207,7 @@ func TestRegexCache_GetOrCompile_ReusesCachedRegex(t *testing.T) { c := NewRegexCache() - expr := api.ExpressionRegex{ + expr := runtime.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, } @@ -241,11 +241,11 @@ func TestRegexCache_GetOrCompile_ReusesCachedRegex(t *testing.T) { func TestRegexCache_HashRegex_UsesNegate(t *testing.T) { t.Parallel() - positive := HashRegex(api.ExpressionRegex{ + positive := HashRegex(runtime.ExpressionRegex{ Expression: `^ghcr\.io/.*`, }) - negative := HashRegex(api.ExpressionRegex{ + negative := HashRegex(runtime.ExpressionRegex{ Expression: `^ghcr\.io/.*`, Negate: true, }) @@ -260,7 +260,7 @@ func TestRegexCache_Reset(t *testing.T) { c := NewRegexCache() - compiled, _, err := c.GetOrCompile(api.ExpressionRegex{ + compiled, _, err := c.GetOrCompile(runtime.ExpressionRegex{ Expression: `^ghcr\.io/.*`, }) if err != nil { diff --git a/internal/cache/registries.go b/internal/cache/registries.go index 9a91ffc1..6011ca1d 100644 --- a/internal/cache/registries.go +++ b/internal/cache/registries.go @@ -13,8 +13,8 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) type RuleSet struct { @@ -23,7 +23,7 @@ type RuleSet struct { } type CompiledRule struct { - Match api.ExpressionMatch + Match runtime.ExpressionMatch // RegexID is empty when Match.Expression is empty. RegexID string diff --git a/internal/cache/registries_test.go b/internal/cache/registries_test.go index 506583ae..2deda65a 100644 --- a/internal/cache/registries_test.go +++ b/internal/cache/registries_test.go @@ -10,8 +10,8 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) func TestNewRegistryRuleSetCache(t *testing.T) { @@ -94,7 +94,7 @@ func TestRegistryRuleSetCacheGetOrBuild(t *testing.T) { { name: "registry with negated expression builds ruleset", rules: []rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -242,7 +242,7 @@ func TestRegistryRuleSetCacheBuildRuleSet(t *testing.T) { specRules := []rules.OCIRegistry{ registry("harbor/.*"), registryWithPolicy("ghcr.io/.*", corev1.PullAlways, corev1.PullIfNotPresent), - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -363,7 +363,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "negated expression matches non-matching reference", rules: []rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -377,7 +377,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "negated expression does not match matching reference", rules: []rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -406,7 +406,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "nested regex expression wins over legacy url", rules: []rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "nested/.*", }), }, @@ -417,7 +417,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "legacy url is ignored when nested regex expression is set", rules: []rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "nested/.*", }), }, @@ -526,7 +526,7 @@ func TestRegistryRuleSetCacheMatchRuleSetWithPullPolicy(t *testing.T) { { name: "negated expression respects pull policy", rules: []rules.OCIRegistry{ - registryWithExpressionAndPolicy(api.ExpressionRegex{ + registryWithExpressionAndPolicy(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, corev1.PullIfNotPresent), @@ -541,7 +541,7 @@ func TestRegistryRuleSetCacheMatchRuleSetWithPullPolicy(t *testing.T) { { name: "negated expression still rejects forbidden pull policy", rules: []rules.OCIRegistry{ - registryWithExpressionAndPolicy(api.ExpressionRegex{ + registryWithExpressionAndPolicy(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, corev1.PullNever), @@ -714,14 +714,14 @@ func TestRegistryRuleSetCacheHashRules(t *testing.T) { c := NewRegistryRuleSetCache(nil) hashA := c.HashRules([]rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: false, }), }) hashB := c.HashRules([]rules.OCIRegistry{ - registryWithExpression(api.ExpressionRegex{ + registryWithExpression(runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -924,8 +924,8 @@ func TestRegistryRuleSetCacheInsertForTest(t *testing.T) { func registry(expression string) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, Negate: false, }, @@ -935,8 +935,8 @@ func registry(expression string) rules.OCIRegistry { func registryWithPolicy(expression string, policies ...corev1.PullPolicy) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, Negate: false, }, @@ -945,20 +945,20 @@ func registryWithPolicy(expression string, policies ...corev1.PullPolicy) rules. } } -func registryWithExpression(expression api.ExpressionRegex) rules.OCIRegistry { +func registryWithExpression(expression runtime.ExpressionRegex) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ ExpressionRegex: expression, }, } } func registryWithExpressionAndPolicy( - expression api.ExpressionRegex, + expression runtime.ExpressionRegex, policies ...corev1.PullPolicy, ) rules.OCIRegistry { return rules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ ExpressionRegex: expression, }, Policy: policies, diff --git a/internal/controllers/cfg/invalidator/clients.go b/internal/controllers/cfg/invalidator/clients.go index 6ae7d72d..08e1169b 100644 --- a/internal/controllers/cfg/invalidator/clients.go +++ b/internal/controllers/cfg/invalidator/clients.go @@ -137,7 +137,7 @@ func (r *CacheInvalidator) rebuildImpersonationCache( func (r *CacheInvalidator) invalidateServiceAccount( ctx context.Context, - sa *corev1.ServiceAccount, + sa client.Object, ) error { hasReference, err := r.checkServiceAccountReferences(ctx, sa) if err != nil { @@ -155,7 +155,7 @@ func (r *CacheInvalidator) invalidateServiceAccount( func (r *CacheInvalidator) checkServiceAccountReferences( ctx context.Context, - sa *corev1.ServiceAccount, + sa client.Object, ) (ref bool, err error) { key := sa.GetNamespace() + "/" + sa.GetName() diff --git a/internal/controllers/cfg/invalidator/manager.go b/internal/controllers/cfg/invalidator/manager.go index 10d782de..219c5cc6 100644 --- a/internal/controllers/cfg/invalidator/manager.go +++ b/internal/controllers/cfg/invalidator/manager.go @@ -90,7 +90,7 @@ func (r *CacheInvalidator) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.C predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). - Watches( + WatchesMetadata( &corev1.ServiceAccount{}, handler.Funcs{ DeleteFunc: func( @@ -98,15 +98,10 @@ func (r *CacheInvalidator) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.C e event.TypedDeleteEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request], ) { - sa, ok := e.Object.(*corev1.ServiceAccount) - if !ok { - return - } - - if err := r.invalidateServiceAccount(ctx, sa); err != nil { + if err := r.invalidateServiceAccount(ctx, e.Object); err != nil { r.Log.Error(err, "unable to invalidate serviceaccount cache", - "namespace", sa.GetNamespace(), - "name", sa.GetName(), + "namespace", e.Object.GetNamespace(), + "name", e.Object.GetName(), ) } }, @@ -127,6 +122,7 @@ func (r *CacheInvalidator) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.C }, ), ). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) if err != nil { return err diff --git a/internal/controllers/cfg/invalidator/regex.go b/internal/controllers/cfg/invalidator/regex.go index 8775f81f..f11b8904 100644 --- a/internal/controllers/cfg/invalidator/regex.go +++ b/internal/controllers/cfg/invalidator/regex.go @@ -11,8 +11,8 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/cache" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) func (r *CacheInvalidator) rebuildRegexCache(ctx context.Context, log logr.Logger) error { @@ -28,7 +28,7 @@ func (r *CacheInvalidator) rebuildRegexCache(ctx context.Context, log logr.Logge r.RegexCache.Reset() - expressions := make(map[string]api.ExpressionRegex) + expressions := make(map[string]runtime.ExpressionRegex) for i := range ruleStatuses.Items { rs := &ruleStatuses.Items[i] @@ -52,7 +52,7 @@ func (r *CacheInvalidator) rebuildRegexCache(ctx context.Context, log logr.Logge } func collectRegexExpressionsFromNamespaceRules( - set map[string]api.ExpressionRegex, + set map[string]runtime.ExpressionRegex, r []*rules.NamespaceRuleBodyNamespace, ) { for _, rule := range r { @@ -61,7 +61,7 @@ func collectRegexExpressionsFromNamespaceRules( } func collectRegexExpressionsFromNamespaceRule( - set map[string]api.ExpressionRegex, + set map[string]runtime.ExpressionRegex, rule *rules.NamespaceRuleBodyNamespace, ) { if rule == nil { diff --git a/internal/controllers/cfg/status/manager.go b/internal/controllers/cfg/status/manager.go index f6d2e79a..fecdd778 100644 --- a/internal/controllers/cfg/status/manager.go +++ b/internal/controllers/cfg/status/manager.go @@ -141,6 +141,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller }, }), ). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/customquotas/global_custom_quota_controller.go b/internal/controllers/customquotas/global_custom_quota_controller.go index 55a31aa3..e4c3fbf4 100644 --- a/internal/controllers/customquotas/global_custom_quota_controller.go +++ b/internal/controllers/customquotas/global_custom_quota_controller.go @@ -187,7 +187,7 @@ func (r *clusterCustomQuotaClaimController) mapNamespaceToGlobalCustomQuotas( } var quotaList capsulev1beta2.GlobalCustomQuotaList - if err := r.List(ctx, "aList); err != nil { + if err := r.reader.List(ctx, "aList); err != nil { r.log.Error(err, "cannot list GlobalCustomQuota objects for namespace event", "namespace", ns.Name) return nil diff --git a/internal/controllers/pod/metadata.go b/internal/controllers/pod/metadata.go index 4e0c3cb8..f2738df1 100644 --- a/internal/controllers/pod/metadata.go +++ b/internal/controllers/pod/metadata.go @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + controllerutils "github.com/projectcapsule/capsule/internal/controllers/utils" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" "github.com/projectcapsule/capsule/pkg/tenant" "github.com/projectcapsule/capsule/pkg/utils" @@ -31,10 +32,11 @@ type MetadataReconciler struct { Log logr.Logger } -func (m *MetadataReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { +func (m *MetadataReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlConfig controllerutils.ControllerOptions) error { return ctrl.NewControllerManagedBy(mgr). Named("capsule/pod"). For(&corev1.Pod{}, m.forOptionPerInstanceName(ctx)). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(m) } diff --git a/internal/controllers/rbac/manager.go b/internal/controllers/rbac/manager.go index d8f1b29c..dddad5cf 100644 --- a/internal/controllers/rbac/manager.go +++ b/internal/controllers/rbac/manager.go @@ -65,7 +65,7 @@ func (r *Manager) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlCo } }, }). - Watches(&corev1.ServiceAccount{}, handler.Funcs{ + WatchesMetadata(&corev1.ServiceAccount{}, handler.Funcs{ CreateFunc: func(ctx context.Context, e event.TypedCreateEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request]) { r.handleSAChange(ctx, e.Object) }, diff --git a/internal/controllers/resourcepools/pool_controller.go b/internal/controllers/resourcepools/pool_controller.go index 5ddc7686..90db1604 100644 --- a/internal/controllers/resourcepools/pool_controller.go +++ b/internal/controllers/resourcepools/pool_controller.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "reflect" "sort" "strings" @@ -354,7 +355,26 @@ func (r *resourcePoolController) reconcileResourceClaim( ) (err error) { t := pool.GetClaimFromStatus(claim) if t != nil { - // TBD: Future Implementation for Claim Resizing here + if reflect.DeepEqual(t.Claims, claim.Spec.ResourceClaims) { + return r.handleClaimToPoolBinding(ctx, pool, claim) + } + + exhaustions := canClaimWithinPoolExcludingClaim(log, pool, claim, t) + if len(exhaustions) != 0 { + log.V(5).Info("resized claim exhausts resources", "amount", len(exhaustions)) + + pool.RemoveClaimFromStatus(claim) + pool.CalculateClaimedResources() + + return r.handleClaimResourceExhaustion( + ctx, + pool, + claim, + exhaustions, + exhaustion, + ) + } + return r.handleClaimToPoolBinding(ctx, pool, claim) } @@ -400,8 +420,26 @@ func (r *resourcePoolController) canClaimWithinNamespace( log logr.Logger, pool *capsulev1beta2.ResourcePool, claim *capsulev1beta2.ResourcePoolClaim, +) (res map[string]api.PoolExhaustionResource) { + return canClaimWithinPoolExcludingClaim(log, pool, claim, nil) +} + +func canClaimWithinPoolExcludingClaim( + log logr.Logger, + pool *capsulev1beta2.ResourcePool, + claim *capsulev1beta2.ResourcePoolClaim, + excluded *capsulev1beta2.ResourcePoolClaimsItem, ) (res map[string]api.PoolExhaustionResource) { claimable := pool.GetAvailableClaimableResources() + + if excluded != nil { + for resourceName, qt := range excluded.Claims { + available := claimable[resourceName] + available.Add(qt) + claimable[resourceName] = available + } + } + log.V(5).Info("claimable resources", "claimable", claimable) _, namespaceClaimed := pool.GetNamespaceClaims(claim.Namespace) diff --git a/internal/controllers/resourcepools/utils_test.go b/internal/controllers/resourcepools/utils_test.go index 0440e305..5cb2abf8 100644 --- a/internal/controllers/resourcepools/utils_test.go +++ b/internal/controllers/resourcepools/utils_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -56,6 +57,22 @@ func setEq(got map[string]struct{}, want []string) bool { return true } +func poolWithClaims(hard corev1.ResourceList, claims ...capsulev1beta2.ResourcePoolClaim) *capsulev1beta2.ResourcePool { + pool := &capsulev1beta2.ResourcePool{ + Status: capsulev1beta2.ResourcePoolStatus{ + Allocation: capsulev1beta2.ResourcePoolQuotaStatus{ + Hard: hard, + }, + }, + } + + for i := range claims { + pool.AddClaimToStatus(&claims[i]) + } + + return pool +} + // ---------- filterResourceListByKeys tests ---------- func TestFilterResourceListByKeys(t *testing.T) { @@ -131,6 +148,81 @@ func TestFilterResourceListByKeys(t *testing.T) { }) } +func TestCanClaimWithinPoolExcludingClaim(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("allows resize when new claim fits after excluding previous allocation", func(t *testing.T) { + t.Parallel() + + existing := claim(t, "u1", "ns", "claim", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "5"})) + pool := poolWithClaims( + rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}), + existing, + ) + + resized := existing + resized.Spec.ResourceClaims = rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}) + + got := canClaimWithinPoolExcludingClaim(logr.Discard(), pool, &resized, pool.GetClaimFromStatus(&existing)) + if len(got) != 0 { + t.Fatalf("expected resize to fit, got exhaustions=%v", got) + } + }) + + t.Run("rejects resize that would exceed pool capacity", func(t *testing.T) { + t.Parallel() + + existing := claim(t, "u1", "ns", "claim", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "5"})) + pool := poolWithClaims( + rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}), + existing, + ) + + resized := existing + resized.Spec.ResourceClaims = rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "55"}) + + got := canClaimWithinPoolExcludingClaim(logr.Discard(), pool, &resized, pool.GetClaimFromStatus(&existing)) + if len(got) != 1 { + t.Fatalf("expected one exhaustion, got=%v", got) + } + + exhaustion := got[string(corev1.ResourceCPU)] + if exhaustion.Available.Cmp(q("10")) != 0 { + t.Fatalf("expected available=10, got=%s", exhaustion.Available.String()) + } + if exhaustion.Requesting.Cmp(q("55")) != 0 { + t.Fatalf("expected requesting=55, got=%s", exhaustion.Requesting.String()) + } + }) + + t.Run("accounts for other claims in the pool", func(t *testing.T) { + t.Parallel() + + existing := claim(t, "u1", "ns", "claim", base, rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "5"})) + other := claim(t, "u2", "ns", "other", base.Add(time.Second), rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "4"})) + pool := poolWithClaims( + rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "10"}), + existing, + other, + ) + + resized := existing + resized.Spec.ResourceClaims = rl(map[corev1.ResourceName]string{corev1.ResourceCPU: "7"}) + + got := canClaimWithinPoolExcludingClaim(logr.Discard(), pool, &resized, pool.GetClaimFromStatus(&existing)) + if len(got) != 1 { + t.Fatalf("expected resize to exhaust remaining capacity, got=%v", got) + } + + exhaustion := got[string(corev1.ResourceCPU)] + if exhaustion.Available.Cmp(q("6")) != 0 { + t.Fatalf("expected available=6, got=%s", exhaustion.Available.String()) + } + }) +} + // ---------- resourceListAllZero tests ---------- func TestResourceListAllZero(t *testing.T) { diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index 99a8a35c..ff6970ea 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -147,8 +147,14 @@ func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleSta continue } + enforce := rule.Enforce.DeepCopy() + + for i := range enforce.Metadata { + enforce.Metadata[i].APIGroups = enforce.Metadata[i].StatusAPIGroups() + } + ruleStatus = append(ruleStatus, &rules.NamespaceRuleBodyNamespace{ - Enforce: rule.Enforce.DeepCopy(), + Enforce: enforce, }) } diff --git a/internal/controllers/servicelabels/endpoint_slices.go b/internal/controllers/servicelabels/endpoint_slices.go index 7fff40bd..29be9584 100644 --- a/internal/controllers/servicelabels/endpoint_slices.go +++ b/internal/controllers/servicelabels/endpoint_slices.go @@ -9,6 +9,8 @@ import ( "github.com/go-logr/logr" discoveryv1 "k8s.io/api/discovery/v1" ctrl "sigs.k8s.io/controller-runtime" + + "github.com/projectcapsule/capsule/internal/controllers/utils" ) type EndpointSlicesLabelsReconciler struct { @@ -19,7 +21,7 @@ type EndpointSlicesLabelsReconciler struct { VersionMajor uint } -func (r *EndpointSlicesLabelsReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { +func (r *EndpointSlicesLabelsReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.abstractServiceLabelsReconciler = abstractServiceLabelsReconciler{ obj: &discoveryv1.EndpointSlice{}, client: mgr.GetClient(), @@ -30,5 +32,6 @@ func (r *EndpointSlicesLabelsReconciler) SetupWithManager(ctx context.Context, m Named("endpointslices"). For(r.abstractServiceLabelsReconciler.obj, r.abstractServiceLabelsReconciler.forOptionPerInstanceName(ctx)). Named("capsule/endpointslices"). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/servicelabels/service.go b/internal/controllers/servicelabels/service.go index 78948fa9..40fb8844 100644 --- a/internal/controllers/servicelabels/service.go +++ b/internal/controllers/servicelabels/service.go @@ -9,6 +9,8 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" + + "github.com/projectcapsule/capsule/internal/controllers/utils" ) type ServicesLabelsReconciler struct { @@ -17,7 +19,7 @@ type ServicesLabelsReconciler struct { Log logr.Logger } -func (r *ServicesLabelsReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { +func (r *ServicesLabelsReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.abstractServiceLabelsReconciler = abstractServiceLabelsReconciler{ obj: &corev1.Service{}, client: mgr.GetClient(), @@ -28,5 +30,6 @@ func (r *ServicesLabelsReconciler) SetupWithManager(ctx context.Context, mgr ctr Named("service"). For(r.abstractServiceLabelsReconciler.obj, r.abstractServiceLabelsReconciler.forOptionPerInstanceName(ctx)). Named("capsule/services"). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index b637412c..f2ebd5d4 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -268,8 +268,6 @@ func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result c reconcileError := r.reconcile(ctx, log, instance) defer func() { - r.syncTenantStatusMetrics(instance) - if statusErr := r.updateTenantStatus(ctx, instance, reconcileError); statusErr != nil { statusErr = fmt.Errorf("cannot update tenant status: %w", statusErr) @@ -278,7 +276,11 @@ func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result c } else { err = errors.Join(err, statusErr) } + + return } + + r.syncTenantStatusMetrics(instance) }() if e := patchHelper.Patch(ctx, instance); e != nil { diff --git a/internal/controllers/tenant/metrics.go b/internal/controllers/tenant/metrics.go index ebd8bbd6..a730f716 100644 --- a/internal/controllers/tenant/metrics.go +++ b/internal/controllers/tenant/metrics.go @@ -4,7 +4,6 @@ package tenant import ( - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -18,6 +17,10 @@ func (r *Manager) syncTenantStatusMetrics(tenant *capsulev1beta2.Tenant) { r.Metrics.TenantNamespaceRelationshipGauge.WithLabelValues(tenant.GetName(), ns).Set(1) } + for _, namespace := range tenant.Status.Spaces { + r.syncNamespaceStatusMetrics(tenant, namespace) + } + // Expose cordoned status r.Metrics.TenantNamespaceCounterGauge.WithLabelValues(tenant.Name).Set(float64(tenant.Status.Size)) @@ -41,11 +44,11 @@ func (r *Manager) syncTenantStatusMetrics(tenant *capsulev1beta2.Tenant) { } // Exposing Status Metrics for tenant. -func (r *Manager) syncNamespaceStatusMetrics(tenant *capsulev1beta2.Tenant, namespace *corev1.Namespace) { +func (r *Manager) syncNamespaceStatusMetrics(tenant *capsulev1beta2.Tenant, namespace *capsulev1beta2.TenantStatusNamespaceItem) { for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { var value float64 - cond := tenant.Status.Conditions.GetConditionByType(status) + cond := namespace.Conditions.GetConditionByType(status) if cond == nil { r.Metrics.DeleteTenantNamespaceConditionMetricByType(namespace.Name, status) @@ -56,6 +59,6 @@ func (r *Manager) syncNamespaceStatusMetrics(tenant *capsulev1beta2.Tenant, name value = 1 } - r.Metrics.TenantNamespaceConditionGauge.WithLabelValues(tenant.GetName(), namespace.GetName(), status).Set(value) + r.Metrics.TenantNamespaceConditionGauge.WithLabelValues(tenant.GetName(), namespace.Name, status).Set(value) } } diff --git a/internal/controllers/tenant/namespaces.go b/internal/controllers/tenant/namespaces.go index 0ce98507..79622bf1 100644 --- a/internal/controllers/tenant/namespaces.go +++ b/internal/controllers/tenant/namespaces.go @@ -291,8 +291,6 @@ func (r *Manager) reconcileNamespace( } stat.Conditions.UpdateConditionByType(cordonedCondition) - - r.syncNamespaceStatusMetrics(tnt, namespace) }() // Verify if namespace is still active or terminating. diff --git a/internal/controllers/tls/manager.go b/internal/controllers/tls/manager.go index 2a2ca45c..11343045 100644 --- a/internal/controllers/tls/manager.go +++ b/internal/controllers/tls/manager.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/pkg/runtime/cert" "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/predicates" @@ -46,7 +47,7 @@ type Reconciler struct { Configuration configuration.Configuration } -func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { enqueueFn := handler.EnqueueRequestsFromMapFunc(func(context.Context, client.Object) []reconcile.Request { return []reconcile.Request{ { @@ -68,7 +69,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { ), ). Named("capsule/tls"). - Watches( + WatchesMetadata( &admissionregistrationv1.ValidatingWebhookConfiguration{}, enqueueFn, builder.WithPredicates( @@ -77,7 +78,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { }, ), ). - Watches( + WatchesMetadata( &admissionregistrationv1.MutatingWebhookConfiguration{}, enqueueFn, builder.WithPredicates( @@ -86,7 +87,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { }, ), ). - Watches( + WatchesMetadata( &apiextensionsv1.CustomResourceDefinition{}, enqueueFn, builder.WithPredicates( @@ -95,6 +96,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { }, ), ). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/webhook/cfg/handler.go b/internal/webhook/cfg/handler.go index f87ccb4a..7dc53e9a 100644 --- a/internal/webhook/cfg/handler.go +++ b/internal/webhook/cfg/handler.go @@ -16,7 +16,10 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Handler(configuration configuration.Configuration, handlers ...handlers.TypedHandler[*capsulev1beta2.CapsuleConfiguration]) handlers.Handler { +func Handler( + configuration configuration.Configuration, + handlers ...handlers.TypedHandler[*capsulev1beta2.CapsuleConfiguration], +) handlers.Handler { return &handler{ cfg: configuration, handlers: handlers, diff --git a/internal/webhook/cfg/validation.go b/internal/webhook/cfg/validation.go new file mode 100644 index 00000000..46e6d51a --- /dev/null +++ b/internal/webhook/cfg/validation.go @@ -0,0 +1,115 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cfg + +import ( + "context" + "fmt" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api/runtime" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type validationHandler struct { + regexCache *cache.RegexCache +} + +func ValidationHandler(regexCache *cache.RegexCache) handlers.TypedHandler[*capsulev1beta2.CapsuleConfiguration] { + return &validationHandler{ + regexCache: regexCache, + } +} + +func (h *validationHandler) OnCreate( + _ client.Client, + _ client.Reader, + cfg *capsulev1beta2.CapsuleConfiguration, + _ admission.Decoder, + _ events.EventRecorder, +) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + return h.handle(cfg, req) + } +} + +func (h *validationHandler) OnDelete( + client.Client, + client.Reader, + *capsulev1beta2.CapsuleConfiguration, + admission.Decoder, + events.EventRecorder, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *validationHandler) OnUpdate( + _ client.Client, + _ client.Reader, + cfg *capsulev1beta2.CapsuleConfiguration, + old *capsulev1beta2.CapsuleConfiguration, + _ admission.Decoder, + _ events.EventRecorder, +) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + return h.handle(cfg, req) + } +} + +func (h *validationHandler) handle( + config *capsulev1beta2.CapsuleConfiguration, + req admission.Request, +) *admission.Response { + if err := h.validateRegex( + "spec.protectedNamespaceRegex", + config.Spec.ProtectedNamespaceRegexpString, + ); err != nil { + return ad.Deny(err.Error()) + } + + if err := h.validateRegex( + "spec.nodeMetadata.forbiddenAnnotations.regex", + config.Spec.NodeMetadata.ForbiddenAnnotations.Regex, + ); err != nil { + return ad.Deny(err.Error()) + } + + if err := h.validateRegex( + "spec.nodeMetadata.forbiddenLabels.regex", + config.Spec.NodeMetadata.ForbiddenLabels.Regex, + ); err != nil { + return ad.Deny(err.Error()) + } + + return nil +} + +func (h *validationHandler) validateRegex(fieldPath string, value string) error { + if strings.TrimSpace(value) == "" { + return nil + } + + if _, _, err := h.regexCache.GetOrCompile(runtime.ExpressionRegex{ + Expression: value, + Negate: false, + }); err != nil { + return fmt.Errorf( + "%s %q is not a valid regular expression: %w", + fieldPath, + value, + err, + ) + } + + return nil +} diff --git a/internal/webhook/route/rules.go b/internal/webhook/route/rules.go index d8c65acb..ff6b4fa0 100644 --- a/internal/webhook/route/rules.go +++ b/internal/webhook/route/rules.go @@ -4,6 +4,8 @@ package route import ( + k8smeta "k8s.io/apimachinery/pkg/api/meta" + "github.com/projectcapsule/capsule/internal/webhook/rules/status" "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/handlers" @@ -11,17 +13,19 @@ import ( type rulesValidating struct { configuration configuration.Configuration + mapper k8smeta.RESTMapper } -func RulesValidating(configuration configuration.Configuration) handlers.Webhook { +func RulesValidating(mapper k8smeta.RESTMapper, configuration configuration.Configuration) handlers.Webhook { return &rulesValidating{ configuration: configuration, + mapper: mapper, } } func (w *rulesValidating) GetHandlers() []handlers.Handler { return []handlers.Handler{ - status.RuleStatusValidationHandler(w.configuration), + status.RuleStatusValidationHandler(w.mapper, w.configuration), } } diff --git a/internal/webhook/rules/generic/validation/factory.go b/internal/webhook/rules/generic/validation/factory.go new file mode 100644 index 00000000..d963e3e8 --- /dev/null +++ b/internal/webhook/rules/generic/validation/factory.go @@ -0,0 +1,224 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api/meta" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/ruleengine" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type genericObject = *metav1.PartialObjectMetadata + +type genericRuleSet[R any] = ruleengine.Set[R, genericObject] + +func evaluateGenericRules[R any]( + obj genericObject, + enforceBodies []*apirules.NamespaceRuleEnforceBody, + set genericRuleSet[R], +) (*ruleengine.Evaluation, error) { + if obj == nil || len(enforceBodies) == 0 { + return nil, nil + } + + return ruleengine.EvaluateEnforce( + obj, + enforceBodies, + set, + ) +} + +type genericRuleValidator func( + genericObject, + schema.GroupVersionKind, + []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) + +type genericRules struct { + rules []genericRuleValidator + regexCache *cache.RegexCache + managedMetadata meta.ManagedMetadata + objectSkipRules []meta.ObjectSkipRule +} + +func GenericRules( + regexCache *cache.RegexCache, +) handlers.TypedHandlerWithTenantWithRuleset[genericObject] { + if regexCache == nil { + regexCache = cache.NewRegexCache() + } + + h := &genericRules{ + regexCache: regexCache, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + h.rules = []genericRuleValidator{ + h.validateMetadata, + } + + return h +} + +func (h *genericRules) OnCreate( + _ client.Client, + _ client.Reader, + obj genericObject, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + gvk, err := groupVersionKind(req) + if err != nil { + return ad.Deny(err.Error()) + } + + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validateGenericRules(ctx, req, obj, gvk, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *genericRules) OnUpdate( + _ client.Client, + _ client.Reader, + _ genericObject, + obj genericObject, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + gvk, err := groupVersionKind(req) + if err != nil { + return ad.Deny(err.Error()) + } + + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validateGenericRules(ctx, req, obj, gvk, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *genericRules) OnDelete( + _ client.Client, + _ client.Reader, + _ genericObject, + _ admission.Decoder, + _ events.EventRecorder, + _ *capsulev1beta2.Tenant, + _ []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *genericRules) validateGenericRules( + ctx context.Context, + req admission.Request, + obj genericObject, + gvk schema.GroupVersionKind, + tnt *capsulev1beta2.Tenant, + recorder events.EventRecorder, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) error { + if obj == nil { + return nil + } + + obj.SetGroupVersionKind(gvk) + + if meta.ShouldSkipObjectByRules(obj, h.objectSkipRules) { + return nil + } + + for _, evaluate := range h.rules { + evaluation, err := evaluate(obj, gvk, enforceBodies) + if err != nil { + return err + } + + if evaluation == nil { + continue + } + + for _, audit := range evaluation.Audits { + recorder.LabeledEvent( + obj, + corev1.EventTypeNormal, + events.ReasonNamespaceRuleAudit, + events.ActionRuleAudit, + audit.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + if err := evaluation.BlockingError(); err != nil { + var decisionErr *ruleengine.DecisionError + + if errors.As(err, &decisionErr) && decisionErr.Decision != nil { + recorder.LabeledEvent( + obj, + corev1.EventTypeWarning, + decisionErr.Decision.EventReason, + events.ActionValidationDenied, + decisionErr.Decision.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + return err + } + } + + return nil +} + +func groupVersionKind(req admission.Request) (schema.GroupVersionKind, error) { + gvk := schema.GroupVersionKind{ + Group: req.Kind.Group, + Version: req.Kind.Version, + Kind: req.Kind.Kind, + } + + if gvk.Version == "" || gvk.Kind == "" { + return schema.GroupVersionKind{}, fmt.Errorf("admission request kind is incomplete: %s", gvk.String()) + } + + return gvk, nil +} diff --git a/internal/webhook/rules/generic/validation/factory_test.go b/internal/webhook/rules/generic/validation/factory_test.go new file mode 100644 index 00000000..2c70bed1 --- /dev/null +++ b/internal/webhook/rules/generic/validation/factory_test.go @@ -0,0 +1,1087 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "errors" + "strings" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api/meta" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestGenericRules(t *testing.T) { + t.Parallel() + + got := GenericRules(nil) + + h, ok := got.(*genericRules) + if !ok { + t.Fatalf("expected *genericRules, got %T", got) + } + + if h.regexCache == nil { + t.Fatalf("expected regex cache") + } + + if len(h.rules) != 1 { + t.Fatalf("expected one generic validator, got %d", len(h.rules)) + } + + if !h.managedMetadata.HasLabel(meta.TenantLabel) { + t.Fatalf("expected default managed metadata to include %q", meta.TenantLabel) + } + + if len(h.objectSkipRules) == 0 { + t.Fatalf("expected default object skip rules") + } +} + +func TestEvaluateGenericRules(t *testing.T) { + t.Parallel() + + t.Run("nil object returns nil", func(t *testing.T) { + t.Parallel() + + got, err := evaluateGenericRules[runtime.ExpressionMatch]( + nil, + []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + genericRuleSet[runtime.ExpressionMatch]{}, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != nil { + t.Fatalf("expected nil evaluation, got %#v", got) + } + }) + + t.Run("empty enforce bodies returns nil", func(t *testing.T) { + t.Parallel() + + got, err := evaluateGenericRules[runtime.ExpressionMatch]( + genericMetadataObject(nil, nil), + nil, + genericRuleSet[runtime.ExpressionMatch]{}, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != nil { + t.Fatalf("expected nil evaluation, got %#v", got) + } + }) + + t.Run("delegates to ruleengine", func(t *testing.T) { + t.Parallel() + + set := genericRuleSet[runtime.ExpressionMatch]{ + Name: "test", + EventReason: events.ReasonForbiddenMetadata, + AllowedDescription: "Allowed test values", + Values: func(obj genericObject) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: obj.GetLabels()["env"], + Path: `metadata.labels["env"]`, + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch { + return []runtime.ExpressionMatch{ + { + Exact: []string{"prod"}, + }, + } + }, + Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + return ruleengine.Match{ + Matched: value.Value == "prod", + }, nil + }, + RuleDescription: func(match runtime.ExpressionMatch) string { + return runtime.DescribeExpressionMatch(match) + }, + } + + got, err := evaluateGenericRules( + genericMetadataObject(map[string]string{"env": "prod"}, nil), + []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + set, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got == nil { + t.Fatalf("expected evaluation") + } + if err := got.BlockingError(); err != nil { + t.Fatalf("expected no blocking error, got %v", err) + } + }) +} + +func TestValidateGenericRules(t *testing.T) { + t.Parallel() + + baseGVK := schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + } + + t.Run("nil object returns nil and does not call validators", func(t *testing.T) { + t.Parallel() + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + t.Fatalf("validator must not be called") + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + nil, + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("sets gvk before running validators", func(t *testing.T) { + t.Parallel() + + obj := genericMetadataObject(nil, nil) + + h := &genericRules{ + rules: []genericRuleValidator{ + func(obj genericObject, got schema.GroupVersionKind, _ []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + if got != baseGVK { + t.Fatalf("expected gvk %s, got %s", baseGVK.String(), got.String()) + } + + if obj.GetObjectKind().GroupVersionKind() != baseGVK { + t.Fatalf( + "expected object gvk %s, got %s", + baseGVK.String(), + obj.GetObjectKind().GroupVersionKind().String(), + ) + } + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + obj, + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("skips controller managed objects before validators", func(t *testing.T) { + t.Parallel() + + called := false + skipRules := meta.DefaultObjectSkipRules() + + if len(skipRules) == 0 { + t.Fatalf("expected default object skip rules") + } + + labels := map[string]string{} + for key, value := range skipRules[0].Labels { + labels[key] = value + } + + if len(labels) == 0 { + t.Fatalf("expected default object skip rule to contain labels") + } + + h := &genericRules{ + rules: []genericRuleValidator{ + func( + genericObject, + schema.GroupVersionKind, + []*apirules.NamespaceRuleEnforceBody, + ) (*ruleengine.Evaluation, error) { + called = true + + return nil, nil + }, + }, + regexCache: cache.NewRegexCache(), + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: skipRules, + } + + obj := genericMetadataObject(labels, nil) + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + obj, + coreGVK("ConfigMap"), + &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a", + }, + }, + testEventRecorder{}, + []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if called { + t.Fatalf("validator must not be called for skipped object") + } + }) + + t.Run("does not skip non matching controller managed label value", func(t *testing.T) { + t.Parallel() + + called := false + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + called = true + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + genericMetadataObject(map[string]string{ + "managed-by": "human", + }, nil), + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !called { + t.Fatalf("expected validator to be called") + } + }) + + t.Run("runs validators in order", func(t *testing.T) { + t.Parallel() + + calls := []string{} + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + calls = append(calls, "first") + + return nil, nil + }, + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + calls = append(calls, "second") + + return &ruleengine.Evaluation{}, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + genericMetadataObject(nil, nil), + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got, want := strings.Join(calls, ","), "first,second"; got != want { + t.Fatalf("expected calls %q, got %q", want, got) + } + }) + + t.Run("passes enforce bodies to validators", func(t *testing.T) { + t.Parallel() + + enforceBodies := []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + { + Action: apirules.ActionTypeDeny, + }, + } + + h := &genericRules{ + rules: []genericRuleValidator{ + func(_ genericObject, _ schema.GroupVersionKind, got []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + if len(got) != len(enforceBodies) { + t.Fatalf("expected %d enforce bodies, got %d", len(enforceBodies), len(got)) + } + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + genericMetadataObject(nil, nil), + baseGVK, + testTenant(), + testEventRecorder{}, + enforceBodies, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("propagates validator error and stops", func(t *testing.T) { + t.Parallel() + + expected := errors.New("boom") + secondCalled := false + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + return nil, expected + }, + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + secondCalled = true + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + genericMetadataObject(nil, nil), + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if !errors.Is(err, expected) { + t.Fatalf("expected %v, got %v", expected, err) + } + if secondCalled { + t.Fatalf("second validator must not be called") + } + }) + + t.Run("handles audit-only evaluation and continues", func(t *testing.T) { + t.Parallel() + + calls := 0 + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + calls++ + + return &ruleengine.Evaluation{ + Audits: []*ruleengine.Decision{ + { + SetName: "metadata label", + EventReason: events.ReasonForbiddenMetadata, + Action: apirules.ActionTypeAudit, + Value: ruleengine.Value{ + Value: "audit", + Path: `metadata.labels["audit"]`, + }, + Message: "audit message", + }, + }, + }, nil + }, + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + calls++ + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + genericMetadataObject(nil, nil), + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if calls != 2 { + t.Fatalf("expected two validators to be called, got %d", calls) + } + }) + + t.Run("returns blocking evaluation error and stops", func(t *testing.T) { + t.Parallel() + + secondCalled := false + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + return &ruleengine.Evaluation{ + Blocking: &ruleengine.Decision{ + SetName: "metadata label", + EventReason: events.ReasonForbiddenMetadata, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: "stage", + Path: `metadata.labels["env"]`, + }, + Message: "blocked", + }, + }, nil + }, + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + secondCalled = true + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + err := h.validateGenericRules( + context.Background(), + admission.Request{}, + genericMetadataObject(nil, nil), + baseGVK, + testTenant(), + testEventRecorder{}, + nil, + ) + if err == nil { + t.Fatalf("expected blocking error") + } + + var decisionErr *ruleengine.DecisionError + if !errors.As(err, &decisionErr) { + t.Fatalf("expected DecisionError, got %T: %v", err, err) + } + + if decisionErr.Decision == nil || decisionErr.Decision.Message != "blocked" { + t.Fatalf("unexpected decision error: %#v", decisionErr.Decision) + } + + if secondCalled { + t.Fatalf("second validator must not be called after blocking decision") + } + }) +} + +func TestGenericRulesOnCreate(t *testing.T) { + t.Parallel() + + t.Run("allows when validators return nil", func(t *testing.T) { + t.Parallel() + + called := false + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + called = true + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + fn := h.OnCreate( + nil, + nil, + genericMetadataObject(nil, nil), + nil, + testEventRecorder{}, + testTenant(), + []*apirules.NamespaceRuleBodyNamespace{ + { + Enforce: &apirules.NamespaceRuleEnforceBody{ + Action: apirules.ActionTypeAllow, + }, + }, + }, + ) + + resp := fn(context.Background(), admissionRequest("v1", "ConfigMap")) + if resp != nil { + t.Fatalf("expected nil response, got %#v", resp) + } + if !called { + t.Fatalf("expected validator to be called") + } + }) + + t.Run("denies incomplete admission kind", func(t *testing.T) { + t.Parallel() + + h := &genericRules{ + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + fn := h.OnCreate( + nil, + nil, + genericMetadataObject(nil, nil), + nil, + testEventRecorder{}, + testTenant(), + nil, + ) + + resp := fn(context.Background(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Version: "", + Kind: "ConfigMap", + }, + }, + }) + if resp == nil { + t.Fatalf("expected denial response") + } + if resp.Allowed { + t.Fatalf("expected denied response") + } + if !strings.Contains(resp.Result.Message, "admission request kind is incomplete") { + t.Fatalf("expected incomplete kind error, got %q", resp.Result.Message) + } + }) + + t.Run("denies validator error", func(t *testing.T) { + t.Parallel() + + h := &genericRules{ + rules: []genericRuleValidator{ + func(genericObject, schema.GroupVersionKind, []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + return nil, errors.New("validator failed") + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + fn := h.OnCreate( + nil, + nil, + genericMetadataObject(nil, nil), + nil, + testEventRecorder{}, + testTenant(), + nil, + ) + + resp := fn(context.Background(), admissionRequest("v1", "ConfigMap")) + if resp == nil { + t.Fatalf("expected denial response") + } + if resp.Allowed { + t.Fatalf("expected denied response") + } + if !strings.Contains(resp.Result.Message, "validator failed") { + t.Fatalf("expected validator error, got %q", resp.Result.Message) + } + }) +} + +func TestGenericRulesOnUpdate(t *testing.T) { + t.Parallel() + + t.Run("uses new object and allows when validators return nil", func(t *testing.T) { + t.Parallel() + + calledWithNewObject := false + + oldObj := genericMetadataObject(map[string]string{"old": "true"}, nil) + newObj := genericMetadataObject(map[string]string{"new": "true"}, nil) + + h := &genericRules{ + rules: []genericRuleValidator{ + func(obj genericObject, _ schema.GroupVersionKind, _ []*apirules.NamespaceRuleEnforceBody) (*ruleengine.Evaluation, error) { + calledWithNewObject = obj.GetLabels()["new"] == "true" + + return nil, nil + }, + }, + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + fn := h.OnUpdate( + nil, + nil, + oldObj, + newObj, + nil, + testEventRecorder{}, + testTenant(), + nil, + ) + + resp := fn(context.Background(), admissionRequest("v1", "ConfigMap")) + if resp != nil { + t.Fatalf("expected nil response, got %#v", resp) + } + if !calledWithNewObject { + t.Fatalf("expected validator to receive the new object") + } + }) + + t.Run("denies incomplete admission kind", func(t *testing.T) { + t.Parallel() + + h := &genericRules{ + managedMetadata: meta.NewManagedMetadata(nil, nil), + objectSkipRules: meta.DefaultObjectSkipRules(), + } + + fn := h.OnUpdate( + nil, + nil, + genericMetadataObject(nil, nil), + genericMetadataObject(nil, nil), + nil, + testEventRecorder{}, + testTenant(), + nil, + ) + + resp := fn(context.Background(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "", + }, + }, + }) + if resp == nil { + t.Fatalf("expected denial response") + } + if resp.Allowed { + t.Fatalf("expected denied response") + } + if !strings.Contains(resp.Result.Message, "admission request kind is incomplete") { + t.Fatalf("expected incomplete kind error, got %q", resp.Result.Message) + } + }) +} + +func TestGenericRulesOnDelete(t *testing.T) { + t.Parallel() + + h := &genericRules{} + + fn := h.OnDelete( + nil, + nil, + genericMetadataObject(nil, nil), + nil, + testEventRecorder{}, + testTenant(), + nil, + ) + + resp := fn(context.Background(), admissionRequest("v1", "ConfigMap")) + if resp != nil { + t.Fatalf("expected nil response, got %#v", resp) + } +} + +func TestGroupVersionKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req admission.Request + want schema.GroupVersionKind + wantErr string + }{ + { + name: "core v1 kind", + req: admissionRequest("v1", "ConfigMap"), + want: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + }, + { + name: "grouped kind", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + }, + }, + want: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + }, + { + name: "missing version", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "", + Kind: "ConfigMap", + }, + }, + }, + wantErr: "admission request kind is incomplete", + }, + { + name: "missing kind", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "", + }, + }, + }, + wantErr: "admission request kind is incomplete", + }, + { + name: "empty request", + req: admission.Request{}, + wantErr: "admission request kind is incomplete", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := groupVersionKind(tt.req) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got != tt.want { + t.Fatalf("expected %s, got %s", tt.want.String(), got.String()) + } + }) + } +} + +func genericMetadataObject( + labels map[string]string, + annotations map[string]string, +) genericObject { + return &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Name: "object", + Namespace: "tenant-a", + Labels: labels, + Annotations: annotations, + }, + } +} + +var ( + _ events.EventRecorder = testEventRecorder{} + _ events.LabeledEvent = (*testLabeledEvent)(nil) +) + +type testEventRecorder struct{} + +func (testEventRecorder) Eventf( + k8sruntime.Object, + k8sruntime.Object, + string, + string, + string, + string, + ...interface{}, +) { +} + +func (testEventRecorder) LabeledEvent( + regarding k8sruntime.Object, + eventType string, + reason string, + action string, + note string, +) events.LabeledEvent { + return &testLabeledEvent{ + regarding: regarding, + eventType: eventType, + reason: reason, + action: action, + note: note, + labels: map[string]string{}, + annotations: map[string]string{}, + } +} + +type testLabeledEvent struct { + regarding k8sruntime.Object + related k8sruntime.Object + + eventType string + reason string + action string + note string + + labels map[string]string + annotations map[string]string +} + +func (*testLabeledEvent) Emit(context.Context) {} + +func (e *testLabeledEvent) WithRelated(obj k8sruntime.Object) events.LabeledEvent { + e.related = obj + + return e +} + +func (e *testLabeledEvent) WithLabels(labels map[string]string) events.LabeledEvent { + for key, value := range labels { + e.labels[key] = value + } + + return e +} + +func (e *testLabeledEvent) WithAnnotations(annotations map[string]string) events.LabeledEvent { + for key, value := range annotations { + e.annotations[key] = value + } + + return e +} + +func (e *testLabeledEvent) WithTenantLabel(tnt *capsulev1beta2.Tenant) events.LabeledEvent { + if tnt != nil { + e.labels[meta.NewTenantLabel] = tnt.Name + } + + return e +} + +func (e *testLabeledEvent) WithRequestAnnotations(req admission.Request) events.LabeledEvent { + if req.UID != "" { + e.annotations[meta.AuditRequestUID] = string(req.UID) + } + + if req.UserInfo.Username != "" { + e.annotations[meta.AuditUsername] = req.UserInfo.Username + } + + return e +} + +func (e *testLabeledEvent) Reason() string { + return e.reason +} + +func (e *testLabeledEvent) Action() string { + return e.action +} + +func (e *testLabeledEvent) Regarding() k8sruntime.Object { + return e.regarding +} + +func (e *testLabeledEvent) Labels() map[string]string { + return e.labels +} + +func (e *testLabeledEvent) Annotations() map[string]string { + return e.annotations +} + +func (e *testLabeledEvent) Note() string { + return e.note +} + +func (e *testLabeledEvent) EventType() string { + return e.eventType +} + +func (e *testLabeledEvent) Related() k8sruntime.Object { + return e.related +} + +func admissionRequest( + apiVersion string, + kind string, +) admission.Request { + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + panic(err) + } + + return admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: kind, + }, + }, + } +} + +func testTenant() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a", + }, + } +} + +func TestGenericRulesWithRealMetadataValidatorSmoke(t *testing.T) { + t.Parallel() + + h := GenericRules(cache.NewRegexCache()).(*genericRules) + + evaluation, err := h.validateMetadata( + genericMetadataObject(map[string]string{"env": "prod"}, nil), + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + Metadata: []apirules.MetadataRule{ + { + VersionKinds: gvkVersionKinds([]string{"*"}, "ConfigMap"), + Labels: map[string]apirules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + Exact: []string{"prod"}, + }, + }, + }, + }, + }, + }, + }, + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if evaluation == nil { + t.Fatalf("expected evaluation") + } + if err := evaluation.BlockingError(); err != nil { + t.Fatalf("expected no blocking error, got %v", err) + } +} + +func gvkVersionKinds(apiVersion []string, kinds ...string) runtime.VersionKinds { + return runtime.VersionKinds{ + APIGroups: apiVersion, + Kinds: kinds, + } +} diff --git a/internal/webhook/rules/generic/validation/metadata.go b/internal/webhook/rules/generic/validation/metadata.go new file mode 100644 index 00000000..b80acb66 --- /dev/null +++ b/internal/webhook/rules/generic/validation/metadata.go @@ -0,0 +1,270 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "sort" + + "k8s.io/apimachinery/pkg/runtime/schema" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +type metadataField string + +const ( + metadataFieldLabel metadataField = "label" + metadataFieldAnnotation metadataField = "annotation" +) + +type metadataEntry struct { + Field metadataField + Key string + Value string + Path string + Present bool + Required bool +} + +func (h *genericRules) validateMetadata( + obj genericObject, + gvk schema.GroupVersionKind, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if obj == nil || len(enforceBodies) == 0 { + return nil, nil + } + + entries := h.controlledMetadataEntries(obj, gvk, enforceBodies) + if len(entries) == 0 { + return nil, nil + } + + out := &ruleengine.Evaluation{} + + for i := range entries { + entry := entries[i] + + if !entry.Present { + if entry.Required { + out.Blocking = metadataRequiredDecision(entry) + + return out, nil + } + + continue + } + + evaluation, err := evaluateGenericRules( + obj, + enforceBodies, + h.metadataSet(gvk, entry), + ) + if err != nil { + return out, err + } + + out.Append(evaluation) + + //nolint:nilerr + if evaluation != nil && evaluation.BlockingError() != nil { + return out, nil + } + } + + return out, nil +} + +func metadataRequiredDecision(entry metadataEntry) *ruleengine.Decision { + return &ruleengine.Decision{ + SetName: metadataSetName(entry.Field), + EventReason: events.ReasonForbiddenMetadata, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: "", + Path: entry.Path, + }, + Message: fmt.Sprintf( + "metadata %s %q is required at %s", + entry.Field, + entry.Key, + entry.Path, + ), + } +} + +func (h *genericRules) metadataSet( + gvk schema.GroupVersionKind, + entry metadataEntry, +) genericRuleSet[runtime.ExpressionMatch] { + return genericRuleSet[runtime.ExpressionMatch]{ + Name: metadataSetName(entry.Field), + EventReason: events.ReasonForbiddenMetadata, + + Values: func(_ genericObject) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: entry.Value, + Path: entry.Path, + }, + } + }, + + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch { + if enforce == nil || len(enforce.Metadata) == 0 { + return nil + } + + var out []runtime.ExpressionMatch + + for i := range enforce.Metadata { + rule := enforce.Metadata[i] + if !rule.MatchesGroupVersionKind(gvk) { + continue + } + + switch entry.Field { + case metadataFieldLabel: + policy, ok := rule.Labels[entry.Key] + if ok { + out = append(out, policy.Values...) + } + case metadataFieldAnnotation: + policy, ok := rule.Annotations[entry.Key] + if ok { + out = append(out, policy.Values...) + } + } + } + + return out + }, + Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + return ruleengine.Match{ + Matched: matched, + }, nil + }, + RuleDescription: runtime.DescribeExpressionMatch, + AllowedDescription: "Allowed metadata values", + } +} + +func (h *genericRules) controlledMetadataEntries( + obj genericObject, + gvk schema.GroupVersionKind, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) []metadataEntry { + labels := obj.GetLabels() + annotations := obj.GetAnnotations() + + seen := make(map[string]metadataEntry) + + for _, enforce := range enforceBodies { + if enforce == nil || len(enforce.Metadata) == 0 { + continue + } + + action := enforce.Action.OrDefault() + + for i := range enforce.Metadata { + rule := enforce.Metadata[i] + if !rule.MatchesGroupVersionKind(gvk) { + continue + } + + for key, policy := range rule.Labels { + if h.managedMetadata.HasLabel(key) { + continue + } + + value, exists := labels[key] + required := action == apirules.ActionTypeAllow && policy.Required + + if !exists && !required { + continue + } + + path := metadataLabelPath(key) + + entry := seen[path] + entry.Field = metadataFieldLabel + entry.Key = key + entry.Path = path + entry.Present = exists + entry.Value = value + entry.Required = entry.Required || required + + seen[path] = entry + } + + for key, policy := range rule.Annotations { + if h.managedMetadata.HasAnnotation(key) { + continue + } + + value, exists := annotations[key] + required := action == apirules.ActionTypeAllow && policy.Required + + if !exists && !required { + continue + } + + path := metadataAnnotationPath(key) + + entry := seen[path] + entry.Field = metadataFieldAnnotation + entry.Key = key + entry.Path = path + entry.Present = exists + entry.Value = value + entry.Required = entry.Required || required + + seen[path] = entry + } + } + } + + if len(seen) == 0 { + return nil + } + + out := make([]metadataEntry, 0, len(seen)) + for _, entry := range seen { + out = append(out, entry) + } + + sort.Slice(out, func(i, j int) bool { + return out[i].Path < out[j].Path + }) + + return out +} + +func metadataSetName(field metadataField) string { + switch field { + case metadataFieldLabel: + return "metadata label" + case metadataFieldAnnotation: + return "metadata annotation" + default: + return "metadata" + } +} + +func metadataLabelPath(key string) string { + return fmt.Sprintf("metadata.labels[%q]", key) +} + +func metadataAnnotationPath(key string) string { + return fmt.Sprintf("metadata.annotations[%q]", key) +} diff --git a/internal/webhook/rules/generic/validation/metadata_test.go b/internal/webhook/rules/generic/validation/metadata_test.go new file mode 100644 index 00000000..e6d95052 --- /dev/null +++ b/internal/webhook/rules/generic/validation/metadata_test.go @@ -0,0 +1,1300 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "reflect" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api/meta" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestValidateMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj genericObject + gvk schema.GroupVersionKind + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantAudits int + wantMessage string + wantPath string + }{ + { + name: "nil object returns nil", + obj: nil, + gvk: coreGVK("ConfigMap"), + wantNil: true, + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + }, + { + name: "empty enforce bodies returns nil", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + wantNil: true, + }, + { + name: "required missing label denies", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + wantBlocking: true, + wantMessage: `metadata label "env" is required`, + wantPath: `metadata.labels["env"]`, + }, + { + name: "required present matching label allows", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + }, + { + name: "required present non matching label denies", + obj: metadataObject( + map[string]string{"env": "stage"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + wantBlocking: true, + wantMessage: "Allowed metadata values", + wantPath: `metadata.labels["env"]`, + }, + { + name: "required without values enforces presence only", + obj: metadataObject( + map[string]string{"env": "anything"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true), + }, + nil, + ), + }, + }, + { + name: "required without values denies when missing", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true), + }, + nil, + ), + }, + wantBlocking: true, + wantMessage: `metadata label "env" is required`, + wantPath: `metadata.labels["env"]`, + }, + { + name: "optional missing label is ignored", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(false, exact("prod")), + }, + nil, + ), + }, + wantNil: true, + }, + { + name: "optional present invalid label denies", + obj: metadataObject( + map[string]string{"env": "stage"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(false, exact("prod")), + }, + nil, + ), + }, + wantBlocking: true, + wantMessage: "Allowed metadata values", + wantPath: `metadata.labels["env"]`, + }, + { + name: "annotation regex match allows", + obj: metadataObject( + nil, + map[string]string{"example.corp/cost-center": "INV-1234"}, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + nil, + map[string]apirules.MetadataValueRule{ + "example.corp/cost-center": metadataPolicy(false, expression("^INV-[0-9]{4}$")), + }, + ), + }, + }, + { + name: "deny matching label blocks", + obj: metadataObject( + map[string]string{"env": "blocked"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeDeny, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(false, exact("blocked")), + }, + nil, + ), + }, + wantBlocking: true, + wantMessage: "denied", + wantPath: `metadata.labels["env"]`, + }, + { + name: "deny required missing label is ignored", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeDeny, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("blocked")), + }, + nil, + ), + }, + wantNil: true, + }, + { + name: "audit matching label emits audit and does not block", + obj: metadataObject( + map[string]string{"audit": "audit-this"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAudit, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "audit": metadataPolicy(false, expression("^audit-.*")), + }, + nil, + ), + }, + wantAudits: 1, + }, + { + name: "audit required missing label is ignored", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAudit, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "audit": metadataPolicy(true, exact("true")), + }, + nil, + ), + }, + wantNil: true, + }, + { + name: "non matching gvk returns nil", + obj: metadataObject( + map[string]string{"env": "stage"}, + nil, + ), + gvk: coreGVK("Secret"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + wantNil: true, + }, + { + name: "empty metadata value is evaluated", + obj: metadataObject( + map[string]string{"env": ""}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("")), + }, + nil, + ), + }, + }, + { + name: "invalid regex returns error", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, expression("[")), + }, + nil, + ), + }, + wantBlocking: false, + wantMessage: "error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + + got, err := h.validateMetadata(tt.obj, tt.gvk, tt.enforceBodies) + if tt.wantMessage == "error" { + if err == nil { + t.Fatalf("expected error") + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if got != nil { + t.Fatalf("expected nil evaluation, got %#v", got) + } + + return + } + + if got == nil { + t.Fatalf("expected evaluation") + } + + if len(got.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(got.Audits)) + } + + blockingErr := got.BlockingError() + if tt.wantBlocking && blockingErr == nil { + t.Fatalf("expected blocking error") + } + if !tt.wantBlocking && blockingErr != nil { + t.Fatalf("expected no blocking error, got %v", blockingErr) + } + + if tt.wantMessage != "" { + var message string + if got.Blocking != nil { + message = got.Blocking.Message + } else if len(got.Audits) > 0 { + message = got.Audits[0].Message + } + + if !strings.Contains(message, tt.wantMessage) { + t.Fatalf("expected message to contain %q, got %q", tt.wantMessage, message) + } + } + + if tt.wantPath != "" { + if got.Blocking == nil { + t.Fatalf("expected blocking decision with path %q", tt.wantPath) + } + + if got.Blocking.Value.Path != tt.wantPath { + t.Fatalf("expected path %q, got %q", tt.wantPath, got.Blocking.Value.Path) + } + } + }) + } +} + +func TestControlledMetadataEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + managedLabels []string + managedAnnotations []string + obj genericObject + gvk schema.GroupVersionKind + enforceBodies []*apirules.NamespaceRuleEnforceBody + want []metadataEntry + }{ + { + name: "no matching metadata returns nil", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: nil, + want: nil, + }, + { + name: "collects present label and annotation entries", + obj: metadataObject( + map[string]string{"env": "prod"}, + map[string]string{"example.corp/cost-center": "INV-1234"}, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(false, exact("prod")), + }, + map[string]apirules.MetadataValueRule{ + "example.corp/cost-center": metadataPolicy(false, expression("^INV-")), + }, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldAnnotation, + Key: "example.corp/cost-center", + Value: "INV-1234", + Path: `metadata.annotations["example.corp/cost-center"]`, + Present: true, + }, + { + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + Present: true, + }, + }, + }, + { + name: "includes missing required allow label", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldLabel, + Key: "env", + Path: `metadata.labels["env"]`, + Required: true, + }, + }, + }, + { + name: "skips missing optional allow label", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(false, exact("prod")), + }, + nil, + ), + }, + want: nil, + }, + { + name: "skips missing required deny and audit labels", + obj: metadataObject(nil, nil), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeDeny, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "deny-required": metadataPolicy(true, exact("true")), + }, + nil, + ), + enforceMetadata( + apirules.ActionTypeAudit, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "audit-required": metadataPolicy(true, exact("true")), + }, + nil, + ), + }, + want: nil, + }, + { + name: "required flag is merged across matching rules", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(false, exact("prod")), + }, + nil, + ), + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + Present: true, + Required: true, + }, + }, + }, + { + name: "does not collect non matching gvk rule", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: coreGVK("Secret"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: nil, + }, + { + name: "apiVersion empty matches only core v1", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{""}, + []string{"Deployment"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: nil, + }, + { + name: "apiVersion wildcard matches grouped resources", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"Deployment"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + Present: true, + Required: true, + }, + }, + }, + { + name: "multiple kinds match selected kind", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: coreGVK("Service"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap", "Service"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + Present: true, + Required: true, + }, + }, + }, + { + name: "kind wildcard matches any kind", + obj: metadataObject( + map[string]string{"env": "prod"}, + nil, + ), + gvk: coreGVK("Service"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"*"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + Present: true, + Required: true, + }, + }, + }, + { + name: "managed label and annotation are skipped", + obj: metadataObject( + map[string]string{ + meta.TenantLabel: "tenant-a", + }, + map[string]string{ + meta.ReconcileAnnotation: "true", + }, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + meta.TenantLabel: metadataPolicy(true, exact("tenant-b")), + }, + map[string]apirules.MetadataValueRule{ + meta.ReconcileAnnotation: metadataPolicy(true, exact("false")), + }, + ), + }, + want: nil, + }, + { + name: "custom parameters add label and annotation to managed metadata", + managedLabels: []string{ + meta.TenantLabel, + }, + managedAnnotations: []string{ + meta.ReconcileAnnotation, + }, + obj: metadataObject( + map[string]string{ + meta.TenantLabel: "tenant-a", + }, + map[string]string{ + meta.ReconcileAnnotation: "true", + }, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + meta.TenantLabel: metadataPolicy(true, exact("tenant-a")), + }, + map[string]apirules.MetadataValueRule{ + meta.ReconcileAnnotation: metadataPolicy(true, exact("true")), + }, + ), + }, + want: nil, + }, + { + name: "managed annotation prefixes are skipped", + obj: metadataObject( + nil, + map[string]string{ + meta.ResourceQuotaAnnotationPrefix + "cpu": "1", + }, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + nil, + map[string]apirules.MetadataValueRule{ + meta.ResourceQuotaAnnotationPrefix + "cpu": metadataPolicy(true, exact("2")), + }, + ), + }, + want: nil, + }, + { + name: "same key in labels and annotations is tracked independently", + obj: metadataObject( + map[string]string{"shared": "label"}, + map[string]string{"shared": "annotation"}, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "shared": metadataPolicy(true, exact("label")), + }, + map[string]apirules.MetadataValueRule{ + "shared": metadataPolicy(true, exact("annotation")), + }, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldAnnotation, + Key: "shared", + Value: "annotation", + Path: `metadata.annotations["shared"]`, + Present: true, + Required: true, + }, + { + Field: metadataFieldLabel, + Key: "shared", + Value: "label", + Path: `metadata.labels["shared"]`, + Present: true, + Required: true, + }, + }, + }, + { + name: "output is sorted by path", + obj: metadataObject( + map[string]string{ + "z": "1", + "a": "1", + }, + map[string]string{ + "m": "1", + }, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "z": metadataPolicy(false, exact("1")), + "a": metadataPolicy(false, exact("1")), + }, + map[string]apirules.MetadataValueRule{ + "m": metadataPolicy(false, exact("1")), + }, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldAnnotation, + Key: "m", + Value: "1", + Path: `metadata.annotations["m"]`, + Present: true, + }, + { + Field: metadataFieldLabel, + Key: "a", + Value: "1", + Path: `metadata.labels["a"]`, + Present: true, + }, + { + Field: metadataFieldLabel, + Key: "z", + Value: "1", + Path: `metadata.labels["z"]`, + Present: true, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(tt.managedLabels, tt.managedAnnotations) + + got := h.controlledMetadataEntries(tt.obj, tt.gvk, tt.enforceBodies) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("unexpected entries\nwant: %#v\n got: %#v", tt.want, got) + } + }) + } +} + +func TestMetadataSet(t *testing.T) { + t.Parallel() + + t.Run("values returns the captured metadata entry value", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + entry := metadataEntry{ + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + } + + set := h.metadataSet(coreGVK("ConfigMap"), entry) + got := set.Values(metadataObject(nil, nil)) + + want := []ruleengine.Value{ + { + Value: "prod", + Path: `metadata.labels["env"]`, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %#v, got %#v", want, got) + } + }) + + t.Run("rules returns label values from matching gvk only", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + entry := metadataEntry{ + Field: metadataFieldLabel, + Key: "env", + Value: "prod", + Path: `metadata.labels["env"]`, + } + + set := h.metadataSet(coreGVK("ConfigMap"), entry) + + got := set.Rules(enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod"), expression("^p")), + }, + nil, + )) + + if len(got) != 2 { + t.Fatalf("expected two matchers, got %d", len(got)) + } + if got[0].Exact[0] != "prod" { + t.Fatalf("expected first exact matcher, got %#v", got[0]) + } + if got[1].Expression != "^p" { + t.Fatalf("expected second expression matcher, got %#v", got[1]) + } + }) + + t.Run("rules returns annotation values from matching gvk only", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + entry := metadataEntry{ + Field: metadataFieldAnnotation, + Key: "cost-center", + Value: "INV-1234", + Path: `metadata.annotations["cost-center"]`, + } + + set := h.metadataSet(coreGVK("ConfigMap"), entry) + + got := set.Rules(enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + nil, + map[string]apirules.MetadataValueRule{ + "cost-center": metadataPolicy(true, expression("^INV-[0-9]{4}$")), + }, + )) + + if len(got) != 1 { + t.Fatalf("expected one matcher, got %d", len(got)) + } + if got[0].Expression != "^INV-[0-9]{4}$" { + t.Fatalf("expected expression matcher, got %#v", got[0]) + } + }) + + t.Run("rules returns nil for nil enforce", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("ConfigMap"), metadataEntry{ + Field: metadataFieldLabel, + Key: "env", + }) + + if got := set.Rules(nil); got != nil { + t.Fatalf("expected nil, got %#v", got) + } + }) + + t.Run("rules returns nil for non matching gvk", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("Secret"), metadataEntry{ + Field: metadataFieldLabel, + Key: "env", + }) + + got := set.Rules(enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "env": metadataPolicy(true, exact("prod")), + }, + nil, + )) + + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } + }) + + t.Run("matches exact value", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("ConfigMap"), metadataEntry{}) + + got, err := set.Matches(exact("prod"), ruleengine.Value{Value: "prod"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !got.Matched { + t.Fatalf("expected match") + } + }) + + t.Run("matches regex value", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("ConfigMap"), metadataEntry{}) + + got, err := set.Matches(expression("^prod|test$"), ruleengine.Value{Value: "prod"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !got.Matched { + t.Fatalf("expected match") + } + }) + + t.Run("returns regex error", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("ConfigMap"), metadataEntry{}) + + if _, err := set.Matches(expression("["), ruleengine.Value{Value: "prod"}); err == nil { + t.Fatalf("expected regex error") + } + }) + + t.Run("rule description delegates to runtime description", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("ConfigMap"), metadataEntry{}) + + got := set.RuleDescription(expression("^prod$")) + if !strings.Contains(got, "^prod$") { + t.Fatalf("expected description to contain expression, got %q", got) + } + }) + + t.Run("set metadata fields are stable", func(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + set := h.metadataSet(coreGVK("ConfigMap"), metadataEntry{ + Field: metadataFieldAnnotation, + Key: "cost-center", + }) + + if set.Name != "metadata annotation" { + t.Fatalf("expected metadata annotation set name, got %q", set.Name) + } + if set.EventReason != events.ReasonForbiddenMetadata { + t.Fatalf("expected event reason %q, got %q", events.ReasonForbiddenMetadata, set.EventReason) + } + if set.AllowedDescription != "Allowed metadata values" { + t.Fatalf("expected allowed description, got %q", set.AllowedDescription) + } + }) +} + +func TestMetadataRequiredDecision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + entry metadataEntry + want *ruleengine.Decision + }{ + { + name: "label decision", + entry: metadataEntry{ + Field: metadataFieldLabel, + Key: "env", + Path: `metadata.labels["env"]`, + }, + want: &ruleengine.Decision{ + SetName: "metadata label", + EventReason: events.ReasonForbiddenMetadata, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: "", + Path: `metadata.labels["env"]`, + }, + Message: `metadata label "env" is required at metadata.labels["env"]`, + }, + }, + { + name: "annotation decision", + entry: metadataEntry{ + Field: metadataFieldAnnotation, + Key: "cost-center", + Path: `metadata.annotations["cost-center"]`, + }, + want: &ruleengine.Decision{ + SetName: "metadata annotation", + EventReason: events.ReasonForbiddenMetadata, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: "", + Path: `metadata.annotations["cost-center"]`, + }, + Message: `metadata annotation "cost-center" is required at metadata.annotations["cost-center"]`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := metadataRequiredDecision(tt.entry) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("unexpected decision\nwant: %#v\n got: %#v", tt.want, got) + } + }) + } +} + +func TestMetadataHelpers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + got string + want string + }{ + { + name: "label set name", + got: metadataSetName(metadataFieldLabel), + want: "metadata label", + }, + { + name: "annotation set name", + got: metadataSetName(metadataFieldAnnotation), + want: "metadata annotation", + }, + { + name: "unknown set name", + got: metadataSetName(metadataField("unknown")), + want: "metadata", + }, + { + name: "label path", + got: metadataLabelPath("example.com/key"), + want: `metadata.labels["example.com/key"]`, + }, + { + name: "annotation path", + got: metadataAnnotationPath("example.com/key"), + want: `metadata.annotations["example.com/key"]`, + }, + { + name: "label path quotes key", + got: metadataLabelPath(`a"b`), + want: `metadata.labels["a\"b"]`, + }, + { + name: "annotation path quotes key", + got: metadataAnnotationPath(`a"b`), + want: `metadata.annotations["a\"b"]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, tt.got) + } + }) + } +} + +func newMetadataTestRules( + managedLabels []string, + managedAnnotations []string, +) *genericRules { + return &genericRules{ + regexCache: cache.NewRegexCache(), + managedMetadata: meta.NewManagedMetadata( + managedLabels, + managedAnnotations, + ), + } +} + +func metadataObject( + labels map[string]string, + annotations map[string]string, +) genericObject { + return &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + Annotations: annotations, + }, + } +} + +func coreGVK(kind string) schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: kind, + } +} + +func enforceMetadata( + action apirules.ActionType, + apiVersion []string, + kinds []string, + labels map[string]apirules.MetadataValueRule, + annotations map[string]apirules.MetadataValueRule, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Metadata: []apirules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: apiVersion, + Kinds: kinds, + }, + Labels: labels, + Annotations: annotations, + }, + }, + } +} + +func metadataPolicy( + required bool, + values ...runtime.ExpressionMatch, +) apirules.MetadataValueRule { + return apirules.MetadataValueRule{ + Required: required, + Values: values, + } +} + +func exact(values ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + Exact: values, + } +} + +func expression(value string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ + Expression: value, + }, + } +} diff --git a/internal/webhook/rules/generic/validation/register.go b/internal/webhook/rules/generic/validation/register.go new file mode 100644 index 00000000..8a64be2a --- /dev/null +++ b/internal/webhook/rules/generic/validation/register.go @@ -0,0 +1,46 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +const Path = "/rules/generic/validating" + +type genericValidating struct { + regexCache *cache.RegexCache +} + +func Register(regexCache *cache.RegexCache) handlers.Webhook { + return &genericValidating{ + regexCache: regexCache, + } +} + +func (w *genericValidating) GetHandlers() []handlers.Handler { + return []handlers.Handler{ + genericHandler( + GenericRules(w.regexCache), + ), + } +} + +func (genericValidating) GetPath() string { + return Path +} + +func genericHandler( + handler ...handlers.TypedHandlerWithTenantWithRuleset[*metav1.PartialObjectMetadata], +) handlers.Handler { + return &handlers.TypedTenantWithRulesetHandler[*metav1.PartialObjectMetadata]{ + Factory: func() *metav1.PartialObjectMetadata { + return &metav1.PartialObjectMetadata{} + }, + Handlers: handler, + } +} diff --git a/internal/webhook/rules/pods/validation/registry.go b/internal/webhook/rules/pods/validation/registry.go index 54cd5e01..41e250a6 100644 --- a/internal/webhook/rules/pods/validation/registry.go +++ b/internal/webhook/rules/pods/validation/registry.go @@ -11,8 +11,8 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/internal/cache" - rulesutils "github.com/projectcapsule/capsule/internal/webhook/rules" apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -170,7 +170,7 @@ func describeRegistryRuleSet(rule registryRuleSet) string { for _, registry := range rule.Registries { description := strings.TrimSpace( - rulesutils.DescribeExpressionMatch(registry.ExpressionMatch), + runtime.DescribeExpressionMatch(registry.ExpressionMatch), ) if description == "" { continue diff --git a/internal/webhook/rules/pods/validation/registry_test.go b/internal/webhook/rules/pods/validation/registry_test.go index 0c155165..f3d902a6 100644 --- a/internal/webhook/rules/pods/validation/registry_test.go +++ b/internal/webhook/rules/pods/validation/registry_test.go @@ -10,8 +10,8 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/internal/cache" - "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -545,11 +545,11 @@ func TestDescribeRegistryRuleSet(t *testing.T) { rule: registryRuleSet{ Registries: []apirules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ "harbor/platform/app:1.0.0", }, - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "harbor/shared/.*", }, }, @@ -583,8 +583,8 @@ func TestDescribeRegistryRuleSet(t *testing.T) { rule: registryRuleSet{ Registries: []apirules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, @@ -797,7 +797,7 @@ func TestRegistryRuleDescription(t *testing.T) { { name: "unknown empty rule", matched: &cache.CompiledRule{ - Match: api.ExpressionMatch{}, + Match: runtime.ExpressionMatch{}, }, want: "", }, @@ -821,8 +821,8 @@ func TestRegistryRuleDescription(t *testing.T) { name: "negated expression", matched: compiledRegistryRuleForTest( apirules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, @@ -836,11 +836,11 @@ func TestRegistryRuleDescription(t *testing.T) { name: "exact and expression", matched: compiledRegistryRuleForTest( apirules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ "harbor/platform/app:1.0.0", }, - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "harbor/shared/.*", }, }, @@ -1087,7 +1087,7 @@ func registryEnforceForTest( func registryExactForTest(values ...string) apirules.OCIRegistry { return apirules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: values, }, } @@ -1095,8 +1095,8 @@ func registryExactForTest(values ...string) apirules.OCIRegistry { func registryExpressionForTest(expression string) apirules.OCIRegistry { return apirules.OCIRegistry{ - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, }, diff --git a/internal/webhook/rules/pods/validation/schedulers.go b/internal/webhook/rules/pods/validation/schedulers.go index 3628ae74..efeee192 100644 --- a/internal/webhook/rules/pods/validation/schedulers.go +++ b/internal/webhook/rules/pods/validation/schedulers.go @@ -8,9 +8,8 @@ import ( corev1 "k8s.io/api/core/v1" - rulesutils "github.com/projectcapsule/capsule/internal/webhook/rules" - "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -19,28 +18,33 @@ func (h *podRules) validateSchedulers( pod *corev1.Pod, enforceBodies []*apirules.NamespaceRuleEnforceBody, ) (*ruleengine.Evaluation, error) { - return evaluatePodRules[api.ExpressionMatch]( + return evaluatePodRules[runtime.ExpressionMatch]( pod, enforceBodies, - podRuleSet[api.ExpressionMatch]{ + podRuleSet[runtime.ExpressionMatch]{ Name: "scheduler", EventReason: events.ReasonForbiddenPodScheduler, Values: func(pod *corev1.Pod) []ruleengine.Value { + schedulerName := strings.TrimSpace(pod.Spec.SchedulerName) + if schedulerName == "" { + return nil + } + return []ruleengine.Value{ { - Value: strings.TrimSpace(pod.Spec.SchedulerName), + Value: schedulerName, Path: "spec.schedulerName", }, } }, - Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []api.ExpressionMatch { + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch { if enforce == nil { return nil } return enforce.Workloads.Schedulers }, - Matches: func(match api.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) if err != nil { return ruleengine.Match{}, err @@ -50,7 +54,7 @@ func (h *podRules) validateSchedulers( Matched: matched, }, nil }, - RuleDescription: rulesutils.DescribeExpressionMatch, + RuleDescription: runtime.DescribeExpressionMatch, AllowedDescription: "Allowed schedulers", }, ) diff --git a/internal/webhook/rules/pods/validation/schedulers_test.go b/internal/webhook/rules/pods/validation/schedulers_test.go index b8bb69f0..87e934e4 100644 --- a/internal/webhook/rules/pods/validation/schedulers_test.go +++ b/internal/webhook/rules/pods/validation/schedulers_test.go @@ -9,8 +9,8 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -116,11 +116,11 @@ func TestPodRulesValidateSchedulers(t *testing.T) { enforceBodies: []*apirules.NamespaceRuleEnforceBody{ schedulerEnforceForTest( apirules.ActionTypeAllow, - api.ExpressionMatch{ + runtime.ExpressionMatch{ Exact: []string{ "default-scheduler", }, - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "tenant-[a-z0-9-]+", }, }, @@ -445,7 +445,7 @@ func TestPodRulesValidateSchedulers(t *testing.T) { func schedulerEnforceForTest( action apirules.ActionType, - schedulers ...api.ExpressionMatch, + schedulers ...runtime.ExpressionMatch, ) *apirules.NamespaceRuleEnforceBody { return &apirules.NamespaceRuleEnforceBody{ Action: action, @@ -469,32 +469,32 @@ func schedulerPodForTest(schedulerName string) *corev1.Pod { } } -func schedulerExactForTest(values ...string) api.ExpressionMatch { - return api.ExpressionMatch{ +func schedulerExactForTest(values ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ Exact: values, } } -func schedulerExpressionForTest(expression string) api.ExpressionMatch { - return api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ +func schedulerExpressionForTest(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, } } -func schedulerNegatedExactForTest(values ...string) api.ExpressionMatch { - return api.ExpressionMatch{ +func schedulerNegatedExactForTest(values ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ Exact: values, - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Negate: true, }, } } -func schedulerNegatedExpressionForTest(expression string) api.ExpressionMatch { - return api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ +func schedulerNegatedExpressionForTest(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, Negate: true, }, diff --git a/internal/webhook/rules/services/validation/external_name.go b/internal/webhook/rules/services/validation/external_name.go index d58e09a1..f3a4ca44 100644 --- a/internal/webhook/rules/services/validation/external_name.go +++ b/internal/webhook/rules/services/validation/external_name.go @@ -9,8 +9,8 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -27,10 +27,10 @@ func (h *serviceRules) validateExternalNames( return nil, nil } - return evaluateServiceRules[api.ExpressionMatch]( + return evaluateServiceRules[runtime.ExpressionMatch]( svc, enforceBodies, - serviceRuleSet[api.ExpressionMatch]{ + serviceRuleSet[runtime.ExpressionMatch]{ Name: "externalName hostname", EventReason: events.ReasonForbiddenExternalName, Values: func(svc *corev1.Service) []ruleengine.Value { @@ -41,14 +41,14 @@ func (h *serviceRules) validateExternalNames( }, } }, - Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []api.ExpressionMatch { + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch { if enforce == nil || enforce.Services.ExternalNames == nil { return nil } return enforce.Services.ExternalNames.Hostnames }, - Matches: func(match api.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) if err != nil { return ruleengine.Match{}, err @@ -71,7 +71,7 @@ func (h *serviceRules) validateExternalNames( ) } -func describeExpressionMatch(match api.ExpressionMatch) string { +func describeExpressionMatch(match runtime.ExpressionMatch) string { parts := make([]string, 0, 2) if len(match.Exact) > 0 { diff --git a/internal/webhook/rules/services/validation/external_name_test.go b/internal/webhook/rules/services/validation/external_name_test.go index 85e0346a..3f93c34f 100644 --- a/internal/webhook/rules/services/validation/external_name_test.go +++ b/internal/webhook/rules/services/validation/external_name_test.go @@ -10,8 +10,8 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" - "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" ) @@ -98,11 +98,11 @@ func TestServiceRulesValidateExternalNames(t *testing.T) { enforceBodies: []*apirules.NamespaceRuleEnforceBody{ externalNameEnforceForTest( apirules.ActionTypeAllow, - api.ExpressionMatch{ + runtime.ExpressionMatch{ Exact: []string{ "combined.internal.git.com", }, - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "combined\\..*\\.example\\.com", }, }, @@ -382,17 +382,17 @@ func TestServiceRulesValidateExternalNames(t *testing.T) { func TestDescribeExpressionMatch(t *testing.T) { tests := []struct { name string - match api.ExpressionMatch + match runtime.ExpressionMatch want string }{ { name: "empty matcher", - match: api.ExpressionMatch{}, + match: runtime.ExpressionMatch{}, want: "", }, { name: "exact only", - match: api.ExpressionMatch{ + match: runtime.ExpressionMatch{ Exact: []string{ "internal.git.com", "api.example.com", @@ -402,8 +402,8 @@ func TestDescribeExpressionMatch(t *testing.T) { }, { name: "expression only", - match: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + match: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: ".*\\.example\\.com", }, }, @@ -411,11 +411,11 @@ func TestDescribeExpressionMatch(t *testing.T) { }, { name: "exact and expression", - match: api.ExpressionMatch{ + match: runtime.ExpressionMatch{ Exact: []string{ "internal.git.com", }, - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: ".*\\.example\\.com", }, }, @@ -423,8 +423,8 @@ func TestDescribeExpressionMatch(t *testing.T) { }, { name: "negate is currently not included in description", - match: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + match: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "trusted\\..*", Negate: true, }, @@ -445,7 +445,7 @@ func TestDescribeExpressionMatch(t *testing.T) { func externalNameEnforceForTest( action apirules.ActionType, - hostnames ...api.ExpressionMatch, + hostnames ...runtime.ExpressionMatch, ) *apirules.NamespaceRuleEnforceBody { return &apirules.NamespaceRuleEnforceBody{ Action: action, @@ -488,23 +488,23 @@ func clusterIPServiceForExternalNameTest(name string) *corev1.Service { } } -func exactMatchForTest(values ...string) api.ExpressionMatch { - return api.ExpressionMatch{ +func exactMatchForTest(values ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ Exact: values, } } -func expressionMatchForTest(expression string) api.ExpressionMatch { - return api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ +func expressionMatchForTest(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, }, } } -func negatedExpressionMatchForTest(expression string) api.ExpressionMatch { - return api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ +func negatedExpressionMatchForTest(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: expression, Negate: true, }, diff --git a/internal/webhook/rules/status/validation.go b/internal/webhook/rules/status/validation.go index f18a28c6..ffb2a8de 100644 --- a/internal/webhook/rules/status/validation.go +++ b/internal/webhook/rules/status/validation.go @@ -6,6 +6,7 @@ package status import ( "context" + k8smeta "k8s.io/apimachinery/pkg/api/meta" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -19,9 +20,10 @@ import ( type ruleStatusHandler struct { configuration configuration.Configuration + mapper k8smeta.RESTMapper } -func RuleStatusValidationHandler(configuration configuration.Configuration) handlers.Handler { +func RuleStatusValidationHandler(mapper k8smeta.RESTMapper, configuration configuration.Configuration) handlers.Handler { return &ruleStatusHandler{ configuration: configuration, } @@ -71,7 +73,7 @@ func (r *ruleStatusHandler) OnUpdate( } func (r *ruleStatusHandler) handle(rs *capsulev1beta2.RuleStatus) *admission.Response { - err := ruleengine.ValidateRuleStatusBody(rs.Spec) + err := ruleengine.ValidateRuleStatusBody(r.mapper, rs.Spec) if err != nil { return ad.Deny(err.Error()) } diff --git a/internal/webhook/rules/utils.go b/internal/webhook/rules/utils.go deleted file mode 100644 index 7d4ba4b2..00000000 --- a/internal/webhook/rules/utils.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package rules - -import ( - "fmt" - "strings" - - "github.com/projectcapsule/capsule/pkg/api" -) - -func DescribeExpressionMatch(match api.ExpressionMatch) string { - parts := make([]string, 0, 3) - - prefix := "" - if match.Negate { - prefix = "not " - } - - if len(match.Exact) > 0 { - parts = append(parts, fmt.Sprintf("%sexact: %s", prefix, strings.Join(match.Exact, ", "))) - } - - if match.Expression != "" { - parts = append(parts, fmt.Sprintf("%sexp: %s", prefix, match.Expression)) - } - - if len(parts) == 0 && match.Negate { - return "not " - } - - return strings.Join(parts, "; ") -} diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go index 07b46b05..092fa904 100644 --- a/internal/webhook/tenant/validation/rule_validator.go +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -6,6 +6,7 @@ package validation import ( "context" + k8smeta "k8s.io/apimachinery/pkg/api/meta" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -17,10 +18,14 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -type RuleValidationHandler struct{} +type RuleValidationHandler struct { + mapper k8smeta.RESTMapper +} -func RuleHandler() handlers.TypedHandler[*capsulev1beta2.Tenant] { - return &RuleValidationHandler{} +func RuleHandler(mapper k8smeta.RESTMapper) handlers.TypedHandler[*capsulev1beta2.Tenant] { + return &RuleValidationHandler{ + mapper: mapper, + } } func (h *RuleValidationHandler) OnCreate( @@ -68,31 +73,34 @@ func (h *RuleValidationHandler) OnUpdate( } } -func (h *RuleValidationHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { - if tnt == nil { +func (h *RuleValidationHandler) handle( + tnt *capsulev1beta2.Tenant, + req admission.Request, +) *admission.Response { + if tnt == nil || len(tnt.Spec.Rules) == 0 { return nil } - if len(tnt.Spec.Rules) == 0 { - return nil - } - - var bodies []*rules.NamespaceRuleBodyNamespace + bodies := make([]*rules.NamespaceRuleBodyNamespace, 0, len(tnt.Spec.Rules)) for _, rule := range tnt.Spec.Rules { - if rule == nil { + if rule == nil || rule.NamespaceRuleBodyNamespace == nil { continue } body := rule.NamespaceRuleBodyNamespace - if body == nil { + if body.Enforce == nil { continue } bodies = append(bodies, body) } - if err := ruleengine.ValidateRuleStatusBody(bodies); err != nil { + if len(bodies) == 0 { + return nil + } + + if err := ruleengine.ValidateRuleStatusBody(h.mapper, bodies); err != nil { return ad.Deny(err.Error()) } diff --git a/pkg/api/meta/labels.go b/pkg/api/meta/labels.go index 8860834f..e747273e 100644 --- a/pkg/api/meta/labels.go +++ b/pkg/api/meta/labels.go @@ -33,6 +33,7 @@ const ( CreatedByCapsuleLabel = "projectcapsule.dev/created-by" CustomResourcesLabel = "projectcapsule.dev/custom-resources" + ResourceOriginLabel = "projectcapsule.dev/resource-origin" NewManagedByCapsuleLabel = "projectcapsule.dev/managed-by" ManagedByCapsuleLabel = "capsule.clastix.io/managed-by" diff --git a/pkg/api/meta/metadata.go b/pkg/api/meta/metadata.go new file mode 100644 index 00000000..3bf67182 --- /dev/null +++ b/pkg/api/meta/metadata.go @@ -0,0 +1,213 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package meta + +import ( + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ManagedMetadata struct { + labels map[string]struct{} + annotations map[string]struct{} + annotationPrefixes []string +} + +func NewManagedMetadata( + labels []string, + annotations []string, +) ManagedMetadata { + m := ManagedMetadata{ + labels: stringSet( + ResourcesLabel, + TenantNameLabel, + TenantLabel, + NewTenantLabel, + ResourcePoolLabel, + FreezeLabel, + OwnerPromotionLabel, + ServiceAccountPromotionLabel, + CordonedLabel, + CapsuleNameLabel, + CreatedByCapsuleLabel, + CustomResourcesLabel, + NewManagedByCapsuleLabel, + ManagedByCapsuleLabel, + LimitRangeLabel, + NetworkPolicyLabel, + ResourceQuotaLabel, + RolebindingLabel, + ), + annotations: stringSet( + ReleaseAnnotation, + ReconcileAnnotation, + AvailableIngressClassesAnnotation, + AvailableIngressClassesRegexpAnnotation, + AvailableStorageClassesAnnotation, + AvailableStorageClassesRegexpAnnotation, + AllowedRegistriesAnnotation, + AllowedRegistriesRegexpAnnotation, + ForbiddenNamespaceLabelsAnnotation, + ForbiddenNamespaceLabelsRegexpAnnotation, + ForbiddenNamespaceAnnotationsAnnotation, + ForbiddenNamespaceAnnotationsRegexpAnnotation, + ProtectedTenantAnnotation, + ), + annotationPrefixes: compactStrings( + ResourceQuotaAnnotationPrefix, + ResourceUsedAnnotationPrefix, + ), + } + + m.addLabels(labels...) + m.addAnnotations(annotations...) + + return m +} + +func (m ManagedMetadata) HasLabel(key string) bool { + _, ok := m.labels[key] + + return ok +} + +func (m ManagedMetadata) HasAnnotation(key string) bool { + if _, ok := m.annotations[key]; ok { + return true + } + + for _, prefix := range m.annotationPrefixes { + if strings.HasPrefix(key, prefix) { + return true + } + } + + return false +} + +func (m ManagedMetadata) addLabels(values ...string) { + addStrings(m.labels, values...) +} + +func (m ManagedMetadata) addAnnotations(values ...string) { + addStrings(m.annotations, values...) +} + +func addStrings(set map[string]struct{}, values ...string) { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + + set[value] = struct{}{} + } +} + +func stringSet(values ...string) map[string]struct{} { + if len(values) == 0 { + return nil + } + + out := make(map[string]struct{}, len(values)) + + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + + out[value] = struct{}{} + } + + return out +} + +func compactStrings(values ...string) []string { + if len(values) == 0 { + return nil + } + + out := make([]string, 0, len(values)) + + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + + out = append(out, value) + } + + return out +} + +type ObjectSkipRule struct { + // Labels with values which indicate a skip condition. + Labels map[string]string + + // Annotations with values which indicate a skip condition. + Annotations map[string]string +} + +func (s *ObjectSkipRule) ShouldSkip( + labels map[string]string, + annotations map[string]string, +) bool { + if s == nil { + return false + } + + for key, expected := range s.Labels { + value, ok := labels[key] + if !ok || value != expected { + return false + } + } + + for key, expected := range s.Annotations { + value, ok := annotations[key] + if !ok || value != expected { + return false + } + } + + return len(s.Labels) > 0 || len(s.Annotations) > 0 +} + +func DefaultObjectSkipRules() []ObjectSkipRule { + return []ObjectSkipRule{ + { + Labels: map[string]string{ + NewManagedByCapsuleLabel: ValueController, + }, + }, + { + Labels: map[string]string{ + NewManagedByCapsuleLabel: ValueControllerResources, + }, + }, + } +} + +func ShouldSkipObjectByRules( + obj *metav1.PartialObjectMetadata, + rules []ObjectSkipRule, +) bool { + if obj == nil || len(rules) == 0 { + return false + } + + labels := obj.GetLabels() + annotations := obj.GetAnnotations() + + for _, rule := range rules { + if rule.ShouldSkip(labels, annotations) { + return true + } + } + + return false +} diff --git a/pkg/api/meta/metadata_test.go b/pkg/api/meta/metadata_test.go new file mode 100644 index 00000000..916f0689 --- /dev/null +++ b/pkg/api/meta/metadata_test.go @@ -0,0 +1,850 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package meta + +import ( + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewManagedMetadata(t *testing.T) { + t.Parallel() + + t.Run("contains default managed labels", func(t *testing.T) { + t.Parallel() + + m := NewManagedMetadata(nil, nil) + + for _, key := range []string{ + ResourcesLabel, + TenantNameLabel, + TenantLabel, + NewTenantLabel, + ResourcePoolLabel, + FreezeLabel, + OwnerPromotionLabel, + ServiceAccountPromotionLabel, + CordonedLabel, + CapsuleNameLabel, + CreatedByCapsuleLabel, + CustomResourcesLabel, + NewManagedByCapsuleLabel, + ManagedByCapsuleLabel, + LimitRangeLabel, + NetworkPolicyLabel, + ResourceQuotaLabel, + RolebindingLabel, + } { + if !m.HasLabel(key) { + t.Fatalf("expected managed label %q", key) + } + } + }) + + t.Run("contains default managed annotations", func(t *testing.T) { + t.Parallel() + + m := NewManagedMetadata(nil, nil) + + for _, key := range []string{ + ReleaseAnnotation, + ReconcileAnnotation, + AvailableIngressClassesAnnotation, + AvailableIngressClassesRegexpAnnotation, + AvailableStorageClassesAnnotation, + AvailableStorageClassesRegexpAnnotation, + AllowedRegistriesAnnotation, + AllowedRegistriesRegexpAnnotation, + ForbiddenNamespaceLabelsAnnotation, + ForbiddenNamespaceLabelsRegexpAnnotation, + ForbiddenNamespaceAnnotationsAnnotation, + ForbiddenNamespaceAnnotationsRegexpAnnotation, + ProtectedTenantAnnotation, + } { + if !m.HasAnnotation(key) { + t.Fatalf("expected managed annotation %q", key) + } + } + }) + + t.Run("contains default managed annotation prefixes", func(t *testing.T) { + t.Parallel() + + m := NewManagedMetadata(nil, nil) + + for _, key := range []string{ + ResourceQuotaAnnotationPrefix + "cpu", + ResourceQuotaAnnotationPrefix + "memory", + ResourceUsedAnnotationPrefix + "cpu", + ResourceUsedAnnotationPrefix + "memory", + } { + if !m.HasAnnotation(key) { + t.Fatalf("expected managed annotation prefix match for %q", key) + } + } + }) + + t.Run("adds custom managed labels and annotations", func(t *testing.T) { + t.Parallel() + + m := NewManagedMetadata( + []string{ + "example.corp/label", + " example.corp/trimmed-label ", + "", + " ", + }, + []string{ + "example.corp/annotation", + " example.corp/trimmed-annotation ", + "", + " ", + }, + ) + + for _, key := range []string{ + "example.corp/label", + "example.corp/trimmed-label", + } { + if !m.HasLabel(key) { + t.Fatalf("expected custom managed label %q", key) + } + } + + for _, key := range []string{ + "example.corp/annotation", + "example.corp/trimmed-annotation", + } { + if !m.HasAnnotation(key) { + t.Fatalf("expected custom managed annotation %q", key) + } + } + + if m.HasLabel("") { + t.Fatalf("empty label must not be managed") + } + + if m.HasAnnotation("") { + t.Fatalf("empty annotation must not be managed") + } + }) + + t.Run("matching is exact and case-sensitive", func(t *testing.T) { + t.Parallel() + + m := NewManagedMetadata( + []string{ + "example.corp/managed", + }, + []string{ + "example.corp/managed", + }, + ) + + if !m.HasLabel("example.corp/managed") { + t.Fatalf("expected exact label match") + } + + if m.HasLabel("Example.Corp/managed") { + t.Fatalf("expected label lookup to be case-sensitive") + } + + if m.HasLabel(" example.corp/managed ") { + t.Fatalf("expected label lookup not to trim lookup key") + } + + if !m.HasAnnotation("example.corp/managed") { + t.Fatalf("expected exact annotation match") + } + + if m.HasAnnotation("Example.Corp/managed") { + t.Fatalf("expected annotation lookup to be case-sensitive") + } + + if m.HasAnnotation(" example.corp/managed ") { + t.Fatalf("expected annotation lookup not to trim lookup key") + } + }) + + t.Run("unknown metadata is not managed", func(t *testing.T) { + t.Parallel() + + m := NewManagedMetadata(nil, nil) + + if m.HasLabel("example.corp/not-managed") { + t.Fatalf("unexpected managed label") + } + + if m.HasAnnotation("example.corp/not-managed") { + t.Fatalf("unexpected managed annotation") + } + }) +} + +func TestStringSet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in []string + want map[string]struct{} + }{ + { + name: "empty input returns nil", + in: nil, + want: nil, + }, + { + name: "trims values skips blanks and deduplicates", + in: []string{ + "alpha", + " alpha ", + "", + " ", + "beta", + }, + want: map[string]struct{}{ + "alpha": {}, + "beta": {}, + }, + }, + { + name: "case sensitive", + in: []string{ + "alpha", + "Alpha", + }, + want: map[string]struct{}{ + "alpha": {}, + "Alpha": {}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := stringSet(tt.in...) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestCompactStrings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in []string + want []string + }{ + { + name: "empty input returns nil", + in: nil, + want: nil, + }, + { + name: "trims values and skips blanks", + in: []string{ + "alpha", + " alpha ", + "", + " ", + "beta", + }, + want: []string{ + "alpha", + "alpha", + "beta", + }, + }, + { + name: "does not deduplicate prefixes", + in: []string{ + "alpha/", + "alpha/", + }, + want: []string{ + "alpha/", + "alpha/", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := compactStrings(tt.in...) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestAddStrings(t *testing.T) { + t.Parallel() + + set := map[string]struct{}{ + "existing": {}, + } + + addStrings( + set, + "alpha", + " alpha ", + "", + " ", + "beta", + ) + + want := map[string]struct{}{ + "existing": {}, + "alpha": {}, + "beta": {}, + } + + if !reflect.DeepEqual(set, want) { + t.Fatalf("expected %#v, got %#v", want, set) + } +} + +func TestManagedMetadataAddMethods(t *testing.T) { + t.Parallel() + + m := ManagedMetadata{ + labels: map[string]struct{}{}, + annotations: map[string]struct{}{}, + } + + m.addLabels("example.corp/label", " example.corp/trimmed-label ", "") + m.addAnnotations("example.corp/annotation", " example.corp/trimmed-annotation ", "") + + if !m.HasLabel("example.corp/label") { + t.Fatalf("expected added label") + } + + if !m.HasLabel("example.corp/trimmed-label") { + t.Fatalf("expected trimmed added label") + } + + if !m.HasAnnotation("example.corp/annotation") { + t.Fatalf("expected added annotation") + } + + if !m.HasAnnotation("example.corp/trimmed-annotation") { + t.Fatalf("expected trimmed added annotation") + } + + if m.HasLabel("") { + t.Fatalf("empty label must not be added") + } + + if m.HasAnnotation("") { + t.Fatalf("empty annotation must not be added") + } +} + +func TestManagedMetadataHasAnnotationPrefix(t *testing.T) { + t.Parallel() + + m := ManagedMetadata{ + annotations: map[string]struct{}{ + "example.corp/exact": {}, + }, + annotationPrefixes: []string{ + "example.corp/prefix/", + }, + } + + tests := []struct { + name string + key string + want bool + }{ + { + name: "exact annotation", + key: "example.corp/exact", + want: true, + }, + { + name: "prefix annotation", + key: "example.corp/prefix/value", + want: true, + }, + { + name: "prefix itself also matches", + key: "example.corp/prefix/", + want: true, + }, + { + name: "similar prefix does not match", + key: "example.corp/prefix-other/value", + want: false, + }, + { + name: "unknown annotation", + key: "example.corp/unknown", + want: false, + }, + { + name: "case sensitive prefix", + key: "Example.Corp/prefix/value", + want: false, + }, + { + name: "empty key", + key: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := m.HasAnnotation(tt.key) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestObjectSkipRuleShouldSkip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rule ObjectSkipRule + labels map[string]string + annotations map[string]string + want bool + }{ + { + name: "empty rule does not skip", + rule: ObjectSkipRule{}, + want: false, + }, + { + name: "matching label skips", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + labels: map[string]string{ + "managed-by": "controller", + }, + want: true, + }, + { + name: "missing label does not skip", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + labels: nil, + want: false, + }, + { + name: "non matching label value does not skip", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + labels: map[string]string{ + "managed-by": "human", + }, + want: false, + }, + { + name: "label match is case-sensitive", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + labels: map[string]string{ + "managed-by": "Controller", + }, + want: false, + }, + { + name: "matching annotation skips", + rule: ObjectSkipRule{ + Annotations: map[string]string{ + "example.corp/skip": "true", + }, + }, + annotations: map[string]string{ + "example.corp/skip": "true", + }, + want: true, + }, + { + name: "missing annotation does not skip", + rule: ObjectSkipRule{ + Annotations: map[string]string{ + "example.corp/skip": "true", + }, + }, + annotations: nil, + want: false, + }, + { + name: "non matching annotation value does not skip", + rule: ObjectSkipRule{ + Annotations: map[string]string{ + "example.corp/skip": "true", + }, + }, + annotations: map[string]string{ + "example.corp/skip": "false", + }, + want: false, + }, + { + name: "all labels must match", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + "owner": "capsule", + }, + }, + labels: map[string]string{ + "managed-by": "controller", + "owner": "other", + }, + want: false, + }, + { + name: "all annotations must match", + rule: ObjectSkipRule{ + Annotations: map[string]string{ + "example.corp/skip": "true", + "example.corp/owner": "capsule", + }, + }, + annotations: map[string]string{ + "example.corp/skip": "true", + "example.corp/owner": "other", + }, + want: false, + }, + { + name: "labels and annotations must both match", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + Annotations: map[string]string{ + "example.corp/skip": "true", + }, + }, + labels: map[string]string{ + "managed-by": "controller", + }, + annotations: map[string]string{ + "example.corp/skip": "true", + }, + want: true, + }, + { + name: "matching label but missing required annotation does not skip", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + Annotations: map[string]string{ + "example.corp/skip": "true", + }, + }, + labels: map[string]string{ + "managed-by": "controller", + }, + annotations: nil, + want: false, + }, + { + name: "missing key must not match empty expected value", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "empty": "", + }, + }, + labels: nil, + want: false, + }, + { + name: "present empty value matches empty expected value", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "empty": "", + }, + }, + labels: map[string]string{ + "empty": "", + }, + want: true, + }, + { + name: "extra labels and annotations do not prevent match", + rule: ObjectSkipRule{ + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + labels: map[string]string{ + "managed-by": "controller", + "extra": "value", + }, + annotations: map[string]string{ + "extra": "value", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.rule.ShouldSkip(tt.labels, tt.annotations) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestObjectSkipRuleShouldSkipNilReceiver(t *testing.T) { + t.Parallel() + + var rule *ObjectSkipRule + + if rule.ShouldSkip( + map[string]string{ + "managed-by": "controller", + }, + nil, + ) { + t.Fatalf("nil rule must not skip") + } +} + +func TestDefaultObjectSkipRules(t *testing.T) { + t.Parallel() + + rules := DefaultObjectSkipRules() + if len(rules) != 2 { + t.Fatalf("expected three default skip rules, got %d", len(rules)) + } + + if got := rules[0].Labels[NewManagedByCapsuleLabel]; got != ValueController { + t.Fatalf("expected default skip label %q=%q, got %q", NewManagedByCapsuleLabel, ValueController, got) + } + + if got := rules[1].Labels[NewManagedByCapsuleLabel]; got != ValueControllerResources { + t.Fatalf("expected legacy default skip label %q=%q, got %q", NewManagedByCapsuleLabel, ValueControllerResources, got) + } + + for _, rule := range rules { + if len(rule.Annotations) != 0 { + t.Fatalf("expected no default skip annotations, got %#v", rule.Annotations) + } + } +} + +func TestShouldSkipObjectByRules(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj *metav1.PartialObjectMetadata + rules []ObjectSkipRule + want bool + }{ + { + name: "nil object does not skip", + obj: nil, + rules: DefaultObjectSkipRules(), + want: false, + }, + { + name: "nil rules do not skip", + obj: objectWithMetadata( + map[string]string{ + NewManagedByCapsuleLabel: ValueController, + }, + nil, + ), + rules: nil, + want: false, + }, + { + name: "empty rules do not skip", + obj: objectWithMetadata( + map[string]string{ + NewManagedByCapsuleLabel: ValueController, + }, + nil, + ), + rules: []ObjectSkipRule{}, + want: false, + }, + { + name: "default Capsule controller-managed object skips", + obj: objectWithMetadata( + map[string]string{ + NewManagedByCapsuleLabel: ValueController, + }, + nil, + ), + rules: DefaultObjectSkipRules(), + want: true, + }, + { + name: "legacy Capsule resources object skips", + obj: objectWithMetadata( + map[string]string{ + NewManagedByCapsuleLabel: ValueControllerResources, + }, + nil, + ), + rules: DefaultObjectSkipRules(), + want: true, + }, + { + name: "default skip is case-sensitive", + obj: objectWithMetadata( + map[string]string{ + NewManagedByCapsuleLabel: "Controller", + }, + nil, + ), + rules: DefaultObjectSkipRules(), + want: false, + }, + { + name: "plain managed-by controller is not skipped by default", + obj: objectWithMetadata( + map[string]string{ + "managed-by": "controller", + }, + nil, + ), + rules: DefaultObjectSkipRules(), + want: false, + }, + { + name: "non matching object does not skip", + obj: objectWithMetadata( + map[string]string{ + NewManagedByCapsuleLabel: "human", + }, + nil, + ), + rules: DefaultObjectSkipRules(), + want: false, + }, + { + name: "any matching rule skips", + obj: objectWithMetadata( + map[string]string{ + "app": "demo", + }, + map[string]string{ + "example.corp/skip": "true", + }, + ), + rules: []ObjectSkipRule{ + { + Labels: map[string]string{ + "app": "other", + }, + }, + { + Annotations: map[string]string{ + "example.corp/skip": "true", + }, + }, + }, + want: true, + }, + { + name: "object without labels and annotations does not skip", + obj: objectWithMetadata(nil, nil), + rules: []ObjectSkipRule{ + { + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + }, + want: false, + }, + { + name: "empty skip rule does not skip object", + obj: objectWithMetadata( + map[string]string{ + "app": "demo", + }, + nil, + ), + rules: []ObjectSkipRule{ + {}, + }, + want: false, + }, + { + name: "custom plain managed-by rule skips", + obj: objectWithMetadata( + map[string]string{ + "managed-by": "controller", + }, + nil, + ), + rules: []ObjectSkipRule{ + { + Labels: map[string]string{ + "managed-by": "controller", + }, + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ShouldSkipObjectByRules(tt.obj, tt.rules) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func objectWithMetadata( + labels map[string]string, + annotations map[string]string, +) *metav1.PartialObjectMetadata { + return &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + Annotations: annotations, + }, + } +} diff --git a/pkg/api/meta/selectors.go b/pkg/api/meta/selectors.go index 0db94e17..389ff199 100644 --- a/pkg/api/meta/selectors.go +++ b/pkg/api/meta/selectors.go @@ -14,7 +14,6 @@ var WithoutCapsuleManagedResourcesLabelSelector = func() string { selection.NotIn, []string{ ValueController, - ValueControllerResources, }, ) @@ -27,7 +26,6 @@ var WithCapsuleManagedResourcesLabelSelector = func() string { selection.In, []string{ ValueController, - ValueControllerResources, }, ) diff --git a/pkg/api/registry.go b/pkg/api/registry.go deleted file mode 100644 index 2106e4ab..00000000 --- a/pkg/api/registry.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package api - -import corev1 "k8s.io/api/core/v1" - -// +kubebuilder:validation:Enum=Always;Never;IfNotPresent -type ImagePullPolicySpec string - -func (i ImagePullPolicySpec) String() string { - return string(i) -} - -// +kubebuilder:validation:Enum=pod/images;pod/volumes -type RegistryValidationTarget string - -const ( - ValidateImages RegistryValidationTarget = "pod/images" - ValidateVolumes RegistryValidationTarget = "pod/volumes" -) - -// +kubebuilder:object:generate=true -type OCIRegistry struct { - // OCI Registry endpoint, is treated as regular expression. - Registry string `json:"url,omitzero"` - - // Allowed PullPolicy for the given registry. Supplying no value allows all policies. - // +optional - // +kubebuilder:validation:Items:Enum=Always;Never;IfNotPresent - Policy []corev1.PullPolicy `json:"policy,omitempty"` - - // Requesting Resources - //+kubebuilder:default:={pod/images,pod/volumes} - Validation []RegistryValidationTarget `json:"validation,omitempty"` -} diff --git a/pkg/api/rules/enforce_metadata_types.go b/pkg/api/rules/enforce_metadata_types.go new file mode 100644 index 00000000..93629ced --- /dev/null +++ b/pkg/api/rules/enforce_metadata_types.go @@ -0,0 +1,45 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +// MetadataRule defines metadata constraints for namespaced resources. +// +// +kubebuilder:object:generate=true +// +kubebuilder:validation:XValidation:rule="has(self.labels) || has(self.annotations)",message="at least one of labels or annotations must be set" +type MetadataRule struct { + runtime.VersionKinds `json:",inline"` + + // Labels defines metadata policies by label key. + // + // +optional + Labels map[string]MetadataValueRule `json:"labels,omitempty"` + + // Annotations defines metadata policies by annotation key. + // + // +optional + Annotations map[string]MetadataValueRule `json:"annotations,omitempty"` +} + +// +kubebuilder:object:generate=true +type MetadataValueRule struct { + // Required enforces that the metadata key must be present. + // + // This is mainly meaningful with action=allow. Deny and audit rules remain + // value matchers and do not require missing metadata to exist. + // + // +optional + // +kubebuilder:default:=false + Required bool `json:"required,omitempty"` + + // Values defines allowed, denied, or audited values for the metadata key. + // + // If Required=true and Values is empty, only presence is enforced. + // + // +optional + Values []runtime.ExpressionMatch `json:"values,omitempty"` +} diff --git a/pkg/api/rules/enforce_services_types.go b/pkg/api/rules/enforce_services_types.go index 1f047b4c..19dacee7 100644 --- a/pkg/api/rules/enforce_services_types.go +++ b/pkg/api/rules/enforce_services_types.go @@ -3,7 +3,7 @@ package rules -import "github.com/projectcapsule/capsule/pkg/api" +import "github.com/projectcapsule/capsule/pkg/api/runtime" // +kubebuilder:object:generate=true type NamespaceRuleEnforceServicesBody struct { @@ -55,7 +55,7 @@ type ServiceExternalNameRule struct { // Hostnames restricts spec.externalName. // Empty means no additional hostname restriction once ExternalName is allowed by types. // +optional - Hostnames []api.ExpressionMatch `json:"hostnames,omitempty"` + Hostnames []runtime.ExpressionMatch `json:"hostnames,omitempty"` } // +kubebuilder:object:generate=true diff --git a/pkg/api/rules/enforce_types.go b/pkg/api/rules/enforce_types.go index 584c4c9c..64b4076f 100644 --- a/pkg/api/rules/enforce_types.go +++ b/pkg/api/rules/enforce_types.go @@ -18,4 +18,9 @@ type NamespaceRuleEnforceBody struct { // Enforcement for Services. // +optional Services NamespaceRuleEnforceServicesBody `json:"services,omitempty"` + + // Enforcement for object metadata on namespaced resources. + // + // +optional + Metadata []MetadataRule `json:"metadata,omitempty"` } diff --git a/pkg/api/rules/enforce_workloads_registry_types.go b/pkg/api/rules/enforce_workloads_registry_types.go index a17cd6f6..e3c12393 100644 --- a/pkg/api/rules/enforce_workloads_registry_types.go +++ b/pkg/api/rules/enforce_workloads_registry_types.go @@ -6,7 +6,7 @@ package rules import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) // +kubebuilder:validation:Enum=Always;Never;IfNotPresent @@ -18,7 +18,7 @@ func (i ImagePullPolicySpec) String() string { // +kubebuilder:object:generate=true type OCIRegistry struct { - api.ExpressionMatch `json:",inline"` + runtime.ExpressionMatch `json:",inline"` // Allowed PullPolicy for the given registry. Supplying no value allows all policies. // +optional diff --git a/pkg/api/rules/enforce_workloads_types.go b/pkg/api/rules/enforce_workloads_types.go index 830abd72..f862d4ed 100644 --- a/pkg/api/rules/enforce_workloads_types.go +++ b/pkg/api/rules/enforce_workloads_types.go @@ -6,7 +6,7 @@ package rules import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) // +kubebuilder:validation:Enum=pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes @@ -44,5 +44,5 @@ type NamespaceRuleEnforceWorkloadsBody struct { // Empty schedulerName is ignored and is not normalized to default-scheduler. // // +optional - Schedulers []api.ExpressionMatch `json:"schedulers,omitempty"` + Schedulers []runtime.ExpressionMatch `json:"schedulers,omitempty"` } diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go index 68c23daa..03146b9a 100644 --- a/pkg/api/rules/zz_generated.deepcopy.go +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -8,11 +8,63 @@ package rules import ( - "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/runtime" "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetadataRule) DeepCopyInto(out *MetadataRule) { + *out = *in + in.VersionKinds.DeepCopyInto(&out.VersionKinds) + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]MetadataValueRule, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]MetadataValueRule, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataRule. +func (in *MetadataRule) DeepCopy() *MetadataRule { + if in == nil { + return nil + } + out := new(MetadataRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetadataValueRule) DeepCopyInto(out *MetadataValueRule) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]runtime.ExpressionMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataValueRule. +func (in *MetadataValueRule) DeepCopy() *MetadataValueRule { + if in == nil { + return nil + } + out := new(MetadataValueRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespace) { *out = *in @@ -64,6 +116,13 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) *out = *in in.Workloads.DeepCopyInto(&out.Workloads) in.Services.DeepCopyInto(&out.Services) + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = make([]MetadataRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. @@ -133,7 +192,7 @@ func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnfo } if in.Schedulers != nil { in, out := &in.Schedulers, &out.Schedulers - *out = make([]api.ExpressionMatch, len(*in)) + *out = make([]runtime.ExpressionMatch, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -227,7 +286,7 @@ func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) { *out = *in if in.Hostnames != nil { in, out := &in.Hostnames, &out.Hostnames - *out = make([]api.ExpressionMatch, len(*in)) + *out = make([]runtime.ExpressionMatch, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/pkg/api/expression.go b/pkg/api/runtime/expression.go similarity index 81% rename from pkg/api/expression.go rename to pkg/api/runtime/expression.go index ff107d3a..902b94c1 100644 --- a/pkg/api/expression.go +++ b/pkg/api/runtime/expression.go @@ -1,12 +1,13 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package api +package runtime import ( "fmt" "regexp" "slices" + "strings" ) // At least one of Exact or Exp must be set. @@ -79,6 +80,10 @@ func (m ExpressionMatch) MatchesWithExpressionMatcher( return matched, nil } +func (m ExpressionMatch) Describe() string { + return DescribeExpressionMatch(m) +} + func (m ExpressionMatch) matches(value string) (bool, error) { if len(m.Exact) == 0 && m.Expression == "" { return false, fmt.Errorf("expression match must define at least one of exact or exp") @@ -111,3 +116,26 @@ func (m ExpressionMatch) applyNegate(matched bool) bool { return matched } + +func DescribeExpressionMatch(match ExpressionMatch) string { + parts := make([]string, 0, 3) + + prefix := "" + if match.Negate { + prefix = "not " + } + + if len(match.Exact) > 0 { + parts = append(parts, fmt.Sprintf("%sexact: %s", prefix, strings.Join(match.Exact, ", "))) + } + + if match.Expression != "" { + parts = append(parts, fmt.Sprintf("%sexp: %s", prefix, match.Expression)) + } + + if len(parts) == 0 && match.Negate { + return "not " + } + + return strings.Join(parts, "; ") +} diff --git a/pkg/api/expression_test.go b/pkg/api/runtime/expression_test.go similarity index 99% rename from pkg/api/expression_test.go rename to pkg/api/runtime/expression_test.go index 9f36c8ca..5be20873 100644 --- a/pkg/api/expression_test.go +++ b/pkg/api/runtime/expression_test.go @@ -1,7 +1,7 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package api +package runtime import ( "errors" diff --git a/pkg/api/runtime/version_kind_types.go b/pkg/api/runtime/version_kind_types.go new file mode 100644 index 00000000..e96150c7 --- /dev/null +++ b/pkg/api/runtime/version_kind_types.go @@ -0,0 +1,440 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package runtime + +import ( + "fmt" + "strings" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + WildcardVersionKindMatcher = "*" + CoreAPIVersion = "v1" +) + +// +kubebuilder:object:generate=true +type VersionKind struct { + // Kind of the referent. + // + // Use "*" to match all kinds. + // + // +kubebuilder:validation:MinLength=1 + Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"` + + // API version, API group, or API group/version selector of the referent. + // + // Empty APIVersion means the core Kubernetes API version "v1". + // Use "*" to explicitly match all API groups and versions. + // + // Examples: + // - "" means core "v1". + // - "v1" means core "v1". + // - "apps" means any version in the "apps" API group. + // - "apps/v1" means the "apps/v1" API group/version. + // - "apps/*" means any version in the "apps" API group. + // + // +optional + APIVersion string `json:"apiVersion,omitempty" protobuf:"bytes,5,opt,name=apiVersion"` +} + +func (s VersionKind) GroupVersionKind() schema.GroupVersionKind { + apiVersion := normalizeAPIVersion(s.APIVersion) + + if apiVersion == CoreAPIVersion { + return schema.GroupVersionKind{ + Group: "", + Version: CoreAPIVersion, + Kind: s.Kind, + } + } + + if apiVersion == WildcardVersionKindMatcher { + return schema.GroupVersionKind{ + Group: "", + Version: WildcardVersionKindMatcher, + Kind: s.Kind, + } + } + + if strings.Contains(apiVersion, "/") { + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionKind{ + Kind: s.Kind, + } + } + + return gv.WithKind(s.Kind) + } + + return schema.GroupVersionKind{ + Group: apiVersion, + Kind: s.Kind, + } +} + +// MatchesGroupVersionKind returns true when the receiver matches the provided GVK. +// +// Matching is exact unless the receiver contains '*'. +// Empty APIVersion is treated as "v1". +// Kind must be set. Use "*" to explicitly match all kinds. +func (s VersionKind) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool { + return matchAPIGroupPattern(normalizeAPIVersion(s.APIVersion), gvk) && + matchPattern(s.Kind, gvk.Kind) +} + +// MatchesVersionKind returns true when the receiver matches another VersionKind. +// +// The receiver is interpreted as the pattern. +// The provided VersionKind is interpreted as the concrete value. +func (s VersionKind) MatchesVersionKind(value VersionKind) bool { + return s.MatchesGroupVersionKind(value.GroupVersionKind()) +} + +// HasWildcard returns true when APIVersion or Kind contains a wildcard matcher. +func (s VersionKind) HasWildcard() bool { + return strings.Contains(s.APIVersion, WildcardVersionKindMatcher) || + strings.Contains(s.Kind, WildcardVersionKindMatcher) +} + +// +kubebuilder:object:generate=true +type VersionKinds struct { + // API groups or API group/version selectors of the referents. + // + // Empty or omitted APIGroups means the core Kubernetes API version "v1". + // Use "*" to match all API groups and versions. + // + // Examples: + // - [] or [""] means core "v1". + // - ["v1"] means core "v1". + // - ["apps"] means any version in the "apps" API group. + // - ["apps/v1"] means only "apps/v1". + // - ["apps", "batch/v1"] means any "apps" version and "batch/v1". + // - ["*"] means all API groups and versions. + // + // +optional + APIGroups []string `json:"apiGroups,omitempty"` + + // Kinds of the referents. + // + // Use "*" to match all kinds. + // + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:MinLength=1 + Kinds []string `json:"kinds"` +} + +func (s VersionKinds) VersionKinds() []VersionKind { + apiGroups := s.NormalizedAPIGroups() + kinds := s.normalizedKinds() + + out := make([]VersionKind, 0, len(apiGroups)*len(kinds)) + + for _, apiGroup := range apiGroups { + for _, kind := range kinds { + out = append(out, VersionKind{ + APIVersion: apiGroupPatternToAPIVersionPattern(apiGroup), + Kind: kind, + }) + } + } + + return out +} + +func (s VersionKinds) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool { + for _, kind := range s.normalizedKinds() { + if !matchPattern(kind, gvk.Kind) { + continue + } + + for _, apiGroup := range s.NormalizedAPIGroups() { + if matchAPIGroupPattern(apiGroup, gvk) { + return true + } + } + } + + return false +} + +func (s VersionKinds) HasWildcard() bool { + for _, apiGroup := range s.APIGroups { + if strings.Contains(apiGroup, WildcardVersionKindMatcher) { + return true + } + } + + for _, kind := range s.Kinds { + if strings.Contains(kind, WildcardVersionKindMatcher) { + return true + } + } + + return false +} + +// ValidateKnownKinds validates concrete apiGroup/kind or apiGroupVersion/kind combinations against the RESTMapper. +// Wildcard API groups or wildcard kinds are intentionally skipped because they are selectors, +// not concrete Kubernetes resources. +func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath string) error { + if mapper == nil { + return nil + } + + kinds := s.normalizedKinds() + apiGroups := s.NormalizedAPIGroups() + + for kindIndex, kind := range kinds { + if strings.Contains(kind, WildcardVersionKindMatcher) { + continue + } + + for apiGroupIndex, apiGroup := range apiGroups { + if strings.Contains(apiGroup, WildcardVersionKindMatcher) { + continue + } + + if err := validateKnownKindForAPIGroup(mapper, apiGroup, kind); err != nil { + return fmt.Errorf( + "%s.kinds[%d] %q for apiGroups[%d] %q is invalid: %w", + fieldPath, + kindIndex, + kind, + apiGroupIndex, + apiGroup, + err, + ) + } + } + } + + return nil +} + +func (s VersionKinds) StatusAPIGroups() []string { + apiGroups := s.NormalizedAPIGroups() + if len(apiGroups) == 0 { + return []string{CoreAPIVersion} + } + + out := make([]string, 0, len(apiGroups)) + seen := make(map[string]struct{}, len(apiGroups)) + + for _, apiGroup := range apiGroups { + apiGroup = strings.TrimSpace(apiGroup) + if apiGroup == "" { + apiGroup = CoreAPIVersion + } + + if _, ok := seen[apiGroup]; ok { + continue + } + + seen[apiGroup] = struct{}{} + + out = append(out, apiGroup) + } + + if len(out) == 0 { + return []string{CoreAPIVersion} + } + + return out +} + +func validateKnownKindForAPIGroup( + mapper apimeta.RESTMapper, + apiGroup string, + kind string, +) error { + apiGroup = normalizeAPIVersion(apiGroup) + + if apiGroup == CoreAPIVersion { + _, err := mapper.RESTMapping( + schema.GroupKind{ + Group: "", + Kind: kind, + }, + CoreAPIVersion, + ) + + return err + } + + if strings.Contains(apiGroup, "/") { + gv, err := schema.ParseGroupVersion(apiGroup) + if err != nil { + return err + } + + _, err = mapper.RESTMapping( + schema.GroupKind{ + Group: gv.Group, + Kind: kind, + }, + gv.Version, + ) + + return err + } + + _, err := mapper.RESTMapping( + schema.GroupKind{ + Group: apiGroup, + Kind: kind, + }, + ) + + return err +} + +func (s VersionKinds) NormalizedAPIGroups() []string { + if len(s.APIGroups) == 0 { + return []string{CoreAPIVersion} + } + + out := make([]string, 0, len(s.APIGroups)) + + for _, apiGroup := range s.APIGroups { + apiGroup = strings.TrimSpace(apiGroup) + if apiGroup == "" { + apiGroup = CoreAPIVersion + } + + out = append(out, apiGroup) + } + + if len(out) == 0 { + return []string{CoreAPIVersion} + } + + return out +} + +func (s VersionKinds) normalizedKinds() []string { + if len(s.Kinds) == 0 { + return nil + } + + out := make([]string, 0, len(s.Kinds)) + + for _, kind := range s.Kinds { + kind = strings.TrimSpace(kind) + if kind == "" { + continue + } + + out = append(out, kind) + } + + return out +} + +func apiGroupPatternToAPIVersionPattern(apiGroup string) string { + apiGroup = normalizeAPIVersion(apiGroup) + + if apiGroup == CoreAPIVersion { + return "" + } + + if apiGroup == WildcardVersionKindMatcher { + return WildcardVersionKindMatcher + } + + if strings.Contains(apiGroup, "/") { + return apiGroup + } + + return apiGroup + "/" + WildcardVersionKindMatcher +} + +func normalizeAPIVersion(apiVersion string) string { + if apiVersion == "" { + return CoreAPIVersion + } + + return apiVersion +} + +func matchAPIGroupPattern(pattern string, gvk schema.GroupVersionKind) bool { + pattern = normalizeAPIVersion(strings.TrimSpace(pattern)) + + if pattern == WildcardVersionKindMatcher { + return true + } + + target := gvk.Group + if pattern == CoreAPIVersion || strings.Contains(pattern, "/") { + target = gvk.GroupVersion().String() + } + + return matchPattern(pattern, target) +} + +func matchPattern(pattern, value string) bool { + if pattern == WildcardVersionKindMatcher { + return true + } + + if !strings.Contains(pattern, WildcardVersionKindMatcher) { + return pattern == value + } + + parts := strings.Split(pattern, WildcardVersionKindMatcher) + + if len(parts) == 2 { + if parts[0] == "" { + return strings.HasSuffix(value, parts[1]) + } + + if parts[1] == "" { + return strings.HasPrefix(value, parts[0]) + } + } + + idx := 0 + + if parts[0] != "" { + if !strings.HasPrefix(value, parts[0]) { + return false + } + + idx = len(parts[0]) + } + + lastPartIndex := len(parts) - 1 + suffix := parts[lastPartIndex] + limit := len(value) + + if suffix != "" { + if !strings.HasSuffix(value, suffix) { + return false + } + + limit -= len(suffix) + } + + for _, part := range parts[1:lastPartIndex] { + if part == "" { + continue + } + + if idx > limit { + return false + } + + found := strings.Index(value[idx:limit], part) + if found < 0 { + return false + } + + idx += found + len(part) + } + + return true +} diff --git a/pkg/api/runtime/version_kind_types_test.go b/pkg/api/runtime/version_kind_types_test.go new file mode 100644 index 00000000..6a45475c --- /dev/null +++ b/pkg/api/runtime/version_kind_types_test.go @@ -0,0 +1,2894 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package runtime + +import ( + "reflect" + "strings" + "testing" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestVersionKindGroupVersionKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKind + want schema.GroupVersionKind + }{ + { + name: "empty api version defaults to core v1", + in: VersionKind{ + APIVersion: "", + Kind: "ConfigMap", + }, + want: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + }, + { + name: "explicit core v1", + in: VersionKind{ + APIVersion: "v1", + Kind: "Service", + }, + want: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + }, + { + name: "group only api version", + in: VersionKind{ + APIVersion: "apps", + Kind: "Deployment", + }, + want: schema.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + }, + }, + { + name: "group version api version", + in: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + want: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + }, + { + name: "wildcard api version is represented as wildcard version selector", + in: VersionKind{ + APIVersion: "*", + Kind: "*", + }, + want: schema.GroupVersionKind{ + Group: "", + Version: "*", + Kind: "*", + }, + }, + { + name: "partial wildcard group version is parsed", + in: VersionKind{ + APIVersion: "apps/*", + Kind: "*Set", + }, + want: schema.GroupVersionKind{ + Group: "apps", + Version: "*", + Kind: "*Set", + }, + }, + { + name: "invalid group version keeps kind only", + in: VersionKind{ + APIVersion: "apps/v1/extra", + Kind: "Deployment", + }, + want: schema.GroupVersionKind{ + Kind: "Deployment", + }, + }, + { + name: "empty kind remains empty", + in: VersionKind{ + APIVersion: "v1", + Kind: "", + }, + want: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "", + }, + }, + { + name: "spaces are preserved when parsing group version", + in: VersionKind{ + APIVersion: " apps/v1 ", + Kind: "Deployment", + }, + want: schema.GroupVersionKind{ + Group: " apps", + Version: "v1 ", + Kind: "Deployment", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.GroupVersionKind() + if got != tt.want { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestVersionKindMatchesGroupVersionKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern VersionKind + value schema.GroupVersionKind + want bool + }{ + { + name: "empty api version matches core v1 kind", + pattern: VersionKind{ + APIVersion: "", + Kind: "ConfigMap", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: true, + }, + { + name: "empty api version does not match grouped kind", + pattern: VersionKind{ + APIVersion: "", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "explicit core v1 matches core kind", + pattern: VersionKind{ + APIVersion: "v1", + Kind: "Service", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + want: true, + }, + { + name: "group only matches any version in group", + pattern: VersionKind{ + APIVersion: "apps", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1beta1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "group only does not match different group", + pattern: VersionKind{ + APIVersion: "apps", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "group version matches exact group version", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "group version does not match different version", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1beta1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "api version wildcard matches core", + pattern: VersionKind{ + APIVersion: "*", + Kind: "ConfigMap", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: true, + }, + { + name: "api version wildcard matches grouped", + pattern: VersionKind{ + APIVersion: "*", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "partial group version wildcard matches", + pattern: VersionKind{ + APIVersion: "apps/*", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "partial group version wildcard does not match other group", + pattern: VersionKind{ + APIVersion: "apps/*", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "kind wildcard matches exact api group", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "*", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + want: true, + }, + { + name: "partial kind wildcard matches suffix", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "*Set", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "ReplicaSet", + }, + want: true, + }, + { + name: "partial kind wildcard matches prefix", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deploy*", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "empty kind is not wildcard", + pattern: VersionKind{ + APIVersion: "", + Kind: "", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "explicit wildcard kind matches empty kind", + pattern: VersionKind{ + APIVersion: "", + Kind: "*", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "", + }, + want: true, + }, + { + name: "matching is case-sensitive for kind", + pattern: VersionKind{ + APIVersion: "", + Kind: "configmap", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "matching is case-sensitive for api group", + pattern: VersionKind{ + APIVersion: "Apps", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "wrong kind does not match", + pattern: VersionKind{ + APIVersion: "", + Kind: "Service", + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "partial group version wildcard matches empty version suffix", + pattern: VersionKind{ + APIVersion: "apps/*", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "", + Kind: "Deployment", + }, + want: true, + }, + { + name: "spaces are trimmed when matching VersionKind api version", + pattern: VersionKind{ + APIVersion: " apps ", + Kind: "Deployment", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "spaces are significant in VersionKind kind", + pattern: VersionKind{ + APIVersion: "apps", + Kind: " Deployment ", + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "api version wildcard and kind wildcard match everything", + pattern: VersionKind{ + APIVersion: "*", + Kind: "*", + }, + value: schema.GroupVersionKind{ + Group: "example.corp", + Version: "v1alpha1", + Kind: "Widget", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.pattern.MatchesGroupVersionKind(tt.value) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestVersionKindMatchesVersionKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern VersionKind + value VersionKind + want bool + }{ + { + name: "matches same core kind", + pattern: VersionKind{ + APIVersion: "", + Kind: "ConfigMap", + }, + value: VersionKind{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + want: true, + }, + { + name: "group only pattern matches group version value", + pattern: VersionKind{ + APIVersion: "apps", + Kind: "Deployment", + }, + value: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "group version pattern matches same group version value", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + value: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "group version pattern does not match group only value", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + value: VersionKind{ + APIVersion: "apps", + Kind: "Deployment", + }, + want: false, + }, + { + name: "api version wildcard matches grouped value", + pattern: VersionKind{ + APIVersion: "*", + Kind: "Deployment", + }, + value: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "kind wildcard matches value kind", + pattern: VersionKind{ + APIVersion: "apps/v1", + Kind: "*", + }, + value: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "partial wildcard matches", + pattern: VersionKind{ + APIVersion: "apps/*", + Kind: "*Set", + }, + value: VersionKind{ + APIVersion: "apps/v1", + Kind: "StatefulSet", + }, + want: true, + }, + { + name: "different kind does not match", + pattern: VersionKind{ + APIVersion: "", + Kind: "Service", + }, + value: VersionKind{ + APIVersion: "", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "empty kind does not match value kind", + pattern: VersionKind{ + APIVersion: "", + Kind: "", + }, + value: VersionKind{ + APIVersion: "", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "partial wildcard api version value is treated through GroupVersionKind", + pattern: VersionKind{ + APIVersion: "apps", + Kind: "StatefulSet", + }, + value: VersionKind{ + APIVersion: "apps/*", + Kind: "StatefulSet", + }, + want: true, + }, + { + name: "invalid value group version keeps kind only and does not match grouped pattern", + pattern: VersionKind{ + APIVersion: "apps", + Kind: "Deployment", + }, + value: VersionKind{ + APIVersion: "apps/v1/extra", + Kind: "Deployment", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.pattern.MatchesVersionKind(tt.value) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestVersionKindHasWildcard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKind + want bool + }{ + { + name: "no wildcard", + in: VersionKind{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "api version wildcard", + in: VersionKind{ + APIVersion: "*", + Kind: "Deployment", + }, + want: true, + }, + { + name: "partial api version wildcard", + in: VersionKind{ + APIVersion: "apps/*", + Kind: "Deployment", + }, + want: true, + }, + { + name: "kind wildcard", + in: VersionKind{ + APIVersion: "apps/v1", + Kind: "*", + }, + want: true, + }, + { + name: "partial kind wildcard", + in: VersionKind{ + APIVersion: "apps/v1", + Kind: "*Set", + }, + want: true, + }, + { + name: "empty values are not wildcards", + in: VersionKind{ + APIVersion: "", + Kind: "", + }, + want: false, + }, + { + name: "spaces around wildcard still contain wildcard", + in: VersionKind{ + APIVersion: " * ", + Kind: "Deployment", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.HasWildcard() + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestVersionKindsVersionKinds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKinds + want []VersionKind + }{ + { + name: "empty kinds returns empty", + in: VersionKinds{ + APIGroups: []string{"apps"}, + Kinds: nil, + }, + want: []VersionKind{}, + }, + { + name: "omitted api groups defaults to core v1", + in: VersionKinds{ + Kinds: []string{ + "ConfigMap", + "Service", + }, + }, + want: []VersionKind{ + { + APIVersion: "", + Kind: "ConfigMap", + }, + { + APIVersion: "", + Kind: "Service", + }, + }, + }, + { + name: "blank api groups default to core v1", + in: VersionKinds{ + APIGroups: []string{ + "", + " ", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + want: []VersionKind{ + { + APIVersion: "", + Kind: "ConfigMap", + }, + { + APIVersion: "", + Kind: "ConfigMap", + }, + }, + }, + { + name: "group only api group expands to group wildcard version", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Deployment", + "StatefulSet", + }, + }, + want: []VersionKind{ + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + { + APIVersion: "apps/*", + Kind: "StatefulSet", + }, + }, + }, + { + name: "exact group version is preserved", + in: VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + want: []VersionKind{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + }, + }, + }, + { + name: "multiple api groups and kinds expand as cross product", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "batch/v1", + }, + Kinds: []string{ + "Deployment", + "Job", + }, + }, + want: []VersionKind{ + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + { + APIVersion: "apps/*", + Kind: "Job", + }, + { + APIVersion: "batch/v1", + Kind: "Deployment", + }, + { + APIVersion: "batch/v1", + Kind: "Job", + }, + }, + }, + { + name: "trims api groups and kinds", + in: VersionKinds{ + APIGroups: []string{ + " apps ", + }, + Kinds: []string{ + " Deployment ", + "", + " ", + }, + }, + want: []VersionKind{ + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + }, + }, + { + name: "wildcard api group is preserved", + in: VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "*", + "*Set", + }, + }, + want: []VersionKind{ + { + APIVersion: "*", + Kind: "*", + }, + { + APIVersion: "*", + Kind: "*Set", + }, + }, + }, + { + name: "partial wildcard api group version is preserved", + in: VersionKinds{ + APIGroups: []string{ + "apps/*", + }, + Kinds: []string{ + "*Set", + }, + }, + want: []VersionKind{ + { + APIVersion: "apps/*", + Kind: "*Set", + }, + }, + }, + { + name: "duplicates are preserved", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "apps", + }, + Kinds: []string{ + "Deployment", + "Deployment", + }, + }, + want: []VersionKind{ + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + { + APIVersion: "apps/*", + Kind: "Deployment", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.VersionKinds() + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestVersionKindsMatchesGroupVersionKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern VersionKinds + value schema.GroupVersionKind + want bool + }{ + { + name: "empty kinds do not match", + pattern: VersionKinds{ + APIGroups: []string{"*"}, + Kinds: nil, + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "omitted api groups match core v1", + pattern: VersionKinds{ + Kinds: []string{ + "ConfigMap", + }, + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: true, + }, + { + name: "omitted api groups do not match grouped resource", + pattern: VersionKinds{ + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "blank api group matches core v1", + pattern: VersionKinds{ + APIGroups: []string{ + "", + }, + Kinds: []string{ + "Service", + }, + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + want: true, + }, + { + name: "group only matches any version in group", + pattern: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1beta1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "group only does not match another group", + pattern: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "group version matches exact version", + pattern: VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "group version does not match another version", + pattern: VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1beta1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "multiple api groups match second group", + pattern: VersionKinds{ + APIGroups: []string{ + "apps", + "batch/v1", + }, + Kinds: []string{ + "Deployment", + "Job", + }, + }, + value: schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + want: true, + }, + { + name: "multiple kinds match second kind", + pattern: VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + "StatefulSet", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + want: true, + }, + { + name: "api group wildcard matches any group", + pattern: VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "Widget", + }, + }, + value: schema.GroupVersionKind{ + Group: "example.corp", + Version: "v1alpha1", + Kind: "Widget", + }, + want: true, + }, + { + name: "kind wildcard matches any kind in group", + pattern: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "*", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "ReplicaSet", + }, + want: true, + }, + { + name: "partial kind wildcard matches suffix", + pattern: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "*Set", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "ReplicaSet", + }, + want: true, + }, + { + name: "partial api group wildcard matches group version", + pattern: VersionKinds{ + APIGroups: []string{ + "apps/*", + }, + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "blank kinds are ignored", + pattern: VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "", + " ", + "ConfigMap", + }, + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: true, + }, + { + name: "only blank kinds do not match", + pattern: VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "", + " ", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "matching is case-sensitive for kind", + pattern: VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "configmap", + }, + }, + value: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: false, + }, + { + name: "matching is case-sensitive for api group", + pattern: VersionKinds{ + APIGroups: []string{ + "Apps", + }, + Kinds: []string{ + "Deployment", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "api group and kind wildcard match all", + pattern: VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "*", + }, + }, + value: schema.GroupVersionKind{ + Group: "example.corp", + Version: "v1alpha1", + Kind: "Widget", + }, + want: true, + }, + { + name: "trimmed api groups and kinds match", + pattern: VersionKinds{ + APIGroups: []string{ + " apps ", + }, + Kinds: []string{ + " Deployment ", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: true, + }, + { + name: "no api group match prevents kind match", + pattern: VersionKinds{ + APIGroups: []string{ + "batch", + }, + Kinds: []string{ + "*", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + { + name: "no kind match prevents api group match", + pattern: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Job", + }, + }, + value: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.pattern.MatchesGroupVersionKind(tt.value) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestVersionKindsHasWildcard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKinds + want bool + }{ + { + name: "no wildcard", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "batch/v1", + }, + Kinds: []string{ + "Deployment", + "Job", + }, + }, + want: false, + }, + { + name: "api group wildcard", + in: VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "Deployment", + }, + }, + want: true, + }, + { + name: "partial api group wildcard", + in: VersionKinds{ + APIGroups: []string{ + "apps/*", + }, + Kinds: []string{ + "Deployment", + }, + }, + want: true, + }, + { + name: "wildcard among api groups", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "batch/*", + }, + Kinds: []string{ + "Job", + }, + }, + want: true, + }, + { + name: "kind wildcard", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "*", + }, + }, + want: true, + }, + { + name: "partial kind wildcard", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "*Set", + }, + }, + want: true, + }, + { + name: "wildcard among kinds", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Deployment", + "*Set", + }, + }, + want: true, + }, + { + name: "empty values are not wildcards", + in: VersionKinds{ + APIGroups: []string{ + "", + }, + Kinds: []string{ + "", + }, + }, + want: false, + }, + { + name: "spaces around wildcard still contain wildcard", + in: VersionKinds{ + APIGroups: []string{ + " * ", + }, + Kinds: []string{ + "Deployment", + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.HasWildcard() + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestVersionKindsValidateKnownKinds(t *testing.T) { + t.Parallel() + + mapper := newVersionKindTestRESTMapper() + + tests := []struct { + name string + in VersionKinds + mapper apimeta.RESTMapper + fieldPath string + wantErr []string + }{ + { + name: "nil mapper skips validation", + in: VersionKinds{ + APIGroups: []string{ + "unknown.example.com/v1", + }, + Kinds: []string{ + "NotAThing", + }, + }, + mapper: nil, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "omitted api groups validates core v1", + in: VersionKinds{ + Kinds: []string{ + "ConfigMap", + "Service", + "Pod", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "blank api groups validate core v1", + in: VersionKinds{ + APIGroups: []string{ + "", + " ", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "known group only kind is valid", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Deployment", + "StatefulSet", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "known exact group version kind is valid", + in: VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "multiple concrete api groups validate all combinations", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "apps/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "unknown core kind is invalid", + in: VersionKinds{ + Kinds: []string{ + "NotAThing", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0] "NotAThing"`, + `apiGroups[0] "v1"`, + }, + }, + { + name: "wrong api group kind combination is invalid", + in: VersionKinds{ + APIGroups: []string{ + "batch/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0] "Deployment"`, + `apiGroups[0] "batch/v1"`, + }, + }, + { + name: "exact group version validates exact version", + in: VersionKinds{ + APIGroups: []string{ + "apps/v1beta1", + }, + Kinds: []string{ + "StatefulSet", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0] "StatefulSet"`, + `apiGroups[0] "apps/v1beta1"`, + }, + }, + { + name: "multiple api groups report failing api group index", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "batch/v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0] "Deployment"`, + `apiGroups[1] "batch/v1"`, + }, + }, + { + name: "multiple kinds report failing kind index", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Deployment", + "NotADeployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[1] "NotADeployment"`, + `apiGroups[0] "apps"`, + }, + }, + { + name: "wildcard api group skips discovery validation", + in: VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "NotAThing", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "partial wildcard api group skips discovery validation", + in: VersionKinds{ + APIGroups: []string{ + "apps/*", + }, + Kinds: []string{ + "NotAThing", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "wildcard kind skips discovery validation", + in: VersionKinds{ + APIGroups: []string{ + "unknown.example.com/v1", + }, + Kinds: []string{ + "*", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "partial wildcard kind skips discovery validation", + in: VersionKinds{ + APIGroups: []string{ + "unknown.example.com/v1", + }, + Kinds: []string{ + "*Set", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "blank kinds are ignored", + in: VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "", + " ", + "ConfigMap", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "only blank kinds produce no concrete validation", + in: VersionKinds{ + APIGroups: []string{ + "unknown.example.com/v1", + }, + Kinds: []string{ + "", + " ", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + { + name: "invalid group version is invalid", + in: VersionKinds{ + APIGroups: []string{ + "apps/v1/extra", + }, + Kinds: []string{ + "Deployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0] "Deployment"`, + `apiGroups[0] "apps/v1/extra"`, + }, + }, + { + name: "custom field path is reported", + in: VersionKinds{ + APIGroups: []string{ + "apps", + }, + Kinds: []string{ + "Missing", + }, + }, + mapper: mapper, + fieldPath: "spec.rules[3].enforce.metadata[2]", + wantErr: []string{ + `spec.rules[3].enforce.metadata[2].kinds[0] "Missing"`, + `apiGroups[0] "apps"`, + }, + }, + { + name: "case-sensitive kind fails", + in: VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "configmap", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `configmap`, + `apiGroups[0] "v1"`, + }, + }, + { + name: "case-sensitive api group fails", + in: VersionKinds{ + APIGroups: []string{ + "Apps", + }, + Kinds: []string{ + "Deployment", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + wantErr: []string{ + `Deployment`, + `apiGroups[0] "Apps"`, + }, + }, + { + name: "trimmed api group validates", + in: VersionKinds{ + APIGroups: []string{ + " apps ", + }, + Kinds: []string{ + " Deployment ", + }, + }, + mapper: mapper, + fieldPath: "rules[0].enforce.metadata[0]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.in.ValidateKnownKinds(tt.mapper, tt.fieldPath) + + if len(tt.wantErr) == 0 { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + for _, expected := range tt.wantErr { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error containing %q, got %q", expected, err.Error()) + } + } + }) + } +} + +func TestValidateKnownKindForAPIGroup(t *testing.T) { + t.Parallel() + + mapper := newVersionKindTestRESTMapper() + + tests := []struct { + name string + apiGroup string + kind string + wantErr bool + }{ + { + name: "core v1 kind", + apiGroup: "", + kind: "ConfigMap", + }, + { + name: "explicit core v1 kind", + apiGroup: "v1", + kind: "Service", + }, + { + name: "group only kind", + apiGroup: "apps", + kind: "Deployment", + }, + { + name: "exact group version kind", + apiGroup: "apps/v1", + kind: "Deployment", + }, + { + name: "batch exact group version kind", + apiGroup: "batch/v1", + kind: "Job", + }, + { + name: "unknown core kind", + apiGroup: "", + kind: "NotAThing", + wantErr: true, + }, + { + name: "wrong group kind", + apiGroup: "batch/v1", + kind: "Deployment", + wantErr: true, + }, + { + name: "wrong exact version", + apiGroup: "apps/v1beta1", + kind: "StatefulSet", + wantErr: true, + }, + { + name: "unknown group", + apiGroup: "example.corp", + kind: "Widget", + wantErr: true, + }, + { + name: "invalid group version", + apiGroup: "apps/v1/extra", + kind: "Deployment", + wantErr: true, + }, + { + name: "empty kind fails", + apiGroup: "v1", + kind: "", + wantErr: true, + }, + { + name: "case-sensitive kind fails", + apiGroup: "v1", + kind: "configmap", + wantErr: true, + }, + { + name: "case-sensitive group fails", + apiGroup: "Apps", + kind: "Deployment", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateKnownKindForAPIGroup(mapper, tt.apiGroup, tt.kind) + + if tt.wantErr && err == nil { + t.Fatalf("expected error") + } + + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +func TestVersionKindsNormalizedAPIGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKinds + want []string + }{ + { + name: "nil defaults to core v1", + in: VersionKinds{}, + want: []string{"v1"}, + }, + { + name: "empty slice defaults to core v1", + in: VersionKinds{ + APIGroups: []string{}, + }, + want: []string{"v1"}, + }, + { + name: "blank values become core v1", + in: VersionKinds{ + APIGroups: []string{ + "", + " ", + }, + }, + want: []string{ + "v1", + "v1", + }, + }, + { + name: "trims values", + in: VersionKinds{ + APIGroups: []string{ + " apps ", + " batch/v1 ", + }, + }, + want: []string{ + "apps", + "batch/v1", + }, + }, + { + name: "preserves wildcard values", + in: VersionKinds{ + APIGroups: []string{ + "*", + "apps/*", + }, + }, + want: []string{ + "*", + "apps/*", + }, + }, + { + name: "preserves duplicates", + in: VersionKinds{ + APIGroups: []string{ + "apps", + "apps", + }, + }, + want: []string{ + "apps", + "apps", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.NormalizedAPIGroups() + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestVersionKindsStatusAPIGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKinds + want []string + }{ + { + name: "nil defaults to core v1", + in: VersionKinds{}, + want: []string{"v1"}, + }, + { + name: "blank values become core v1", + in: VersionKinds{ + APIGroups: []string{ + "", + " ", + }, + }, + want: []string{"v1"}, + }, + { + name: "explicit wildcard is preserved", + in: VersionKinds{ + APIGroups: []string{ + "*", + }, + }, + want: []string{"*"}, + }, + { + name: "duplicates are removed after normalization", + in: VersionKinds{ + APIGroups: []string{ + "", + "v1", + "apps/v1", + "apps/v1", + }, + }, + want: []string{ + "v1", + "apps/v1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.StatusAPIGroups() + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestVersionKindsNormalizedKinds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in VersionKinds + want []string + }{ + { + name: "nil returns nil", + in: VersionKinds{}, + want: nil, + }, + { + name: "empty slice returns nil", + in: VersionKinds{ + Kinds: []string{}, + }, + want: nil, + }, + { + name: "trims values and skips blanks", + in: VersionKinds{ + Kinds: []string{ + " ConfigMap ", + "", + " ", + "Service", + }, + }, + want: []string{ + "ConfigMap", + "Service", + }, + }, + { + name: "preserves wildcard values", + in: VersionKinds{ + Kinds: []string{ + "*", + "*Set", + }, + }, + want: []string{ + "*", + "*Set", + }, + }, + { + name: "preserves duplicates", + in: VersionKinds{ + Kinds: []string{ + "ConfigMap", + "ConfigMap", + }, + }, + want: []string{ + "ConfigMap", + "ConfigMap", + }, + }, + { + name: "only blanks returns empty slice", + in: VersionKinds{ + Kinds: []string{ + "", + " ", + }, + }, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.in.normalizedKinds() + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestAPIGroupPatternToAPIVersionPattern(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "empty becomes legacy empty api version", + in: "", + want: "", + }, + { + name: "core v1 becomes legacy empty api version", + in: "v1", + want: "", + }, + { + name: "wildcard is preserved", + in: "*", + want: "*", + }, + { + name: "group only becomes group wildcard version", + in: "apps", + want: "apps/*", + }, + { + name: "exact group version is preserved", + in: "apps/v1", + want: "apps/v1", + }, + { + name: "partial group version is preserved", + in: "apps/*", + want: "apps/*", + }, + { + name: "spaces are not trimmed by helper", + in: " apps ", + want: " apps /*", + }, + { + name: "space padded v1 is treated as group name", + in: " v1 ", + want: " v1 /*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := apiGroupPatternToAPIVersionPattern(tt.in) + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestNormalizeAPIVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "empty defaults to core v1", + in: "", + want: "v1", + }, + { + name: "core v1 stays core v1", + in: "v1", + want: "v1", + }, + { + name: "group only stays group only", + in: "apps", + want: "apps", + }, + { + name: "group version stays group version", + in: "apps/v1", + want: "apps/v1", + }, + { + name: "wildcard stays wildcard", + in: "*", + want: "*", + }, + { + name: "spaces are not trimmed", + in: " v1 ", + want: " v1 ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := normalizeAPIVersion(tt.in) + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestMatchAPIGroupPattern(t *testing.T) { + t.Parallel() + + coreConfigMap := schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + } + appsDeployment := schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + } + appsDeploymentBeta := schema.GroupVersionKind{ + Group: "apps", + Version: "v1beta1", + Kind: "Deployment", + } + + tests := []struct { + name string + pattern string + gvk schema.GroupVersionKind + want bool + }{ + { + name: "empty pattern defaults to core v1", + pattern: "", + gvk: coreConfigMap, + want: true, + }, + { + name: "core v1 pattern matches core", + pattern: "v1", + gvk: coreConfigMap, + want: true, + }, + { + name: "core v1 pattern does not match grouped", + pattern: "v1", + gvk: appsDeployment, + want: false, + }, + { + name: "group only matches grouped resource", + pattern: "apps", + gvk: appsDeployment, + want: true, + }, + { + name: "group only matches another version", + pattern: "apps", + gvk: appsDeploymentBeta, + want: true, + }, + { + name: "group only does not match other group", + pattern: "batch", + gvk: appsDeployment, + want: false, + }, + { + name: "group version matches exact", + pattern: "apps/v1", + gvk: appsDeployment, + want: true, + }, + { + name: "group version does not match other version", + pattern: "apps/v1", + gvk: appsDeploymentBeta, + want: false, + }, + { + name: "wildcard matches core", + pattern: "*", + gvk: coreConfigMap, + want: true, + }, + { + name: "wildcard matches grouped", + pattern: "*", + gvk: appsDeployment, + want: true, + }, + { + name: "partial group wildcard matches group name", + pattern: "app*", + gvk: appsDeployment, + want: true, + }, + { + name: "partial group version wildcard matches version", + pattern: "apps/*", + gvk: appsDeployment, + want: true, + }, + { + name: "partial group version wildcard does not match different group", + pattern: "batch/*", + gvk: appsDeployment, + want: false, + }, + { + name: "pattern is trimmed", + pattern: " apps ", + gvk: appsDeployment, + want: true, + }, + { + name: "case-sensitive mismatch", + pattern: "Apps", + gvk: appsDeployment, + want: false, + }, + { + name: "empty version suffix matches apps slash", + pattern: "apps/*", + gvk: schema.GroupVersionKind{ + Group: "apps", + Version: "", + Kind: "Deployment", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := matchAPIGroupPattern(tt.pattern, tt.gvk) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func TestMatchPattern(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + value string + want bool + }{ + { + name: "wildcard constant matches anything", + pattern: "*", + value: "anything", + want: true, + }, + { + name: "wildcard constant matches empty", + pattern: "*", + value: "", + want: true, + }, + { + name: "exact match", + pattern: "abc", + value: "abc", + want: true, + }, + { + name: "exact mismatch", + pattern: "abc", + value: "abd", + want: false, + }, + { + name: "empty pattern matches empty", + pattern: "", + value: "", + want: true, + }, + { + name: "empty pattern does not match non-empty", + pattern: "", + value: "abc", + want: false, + }, + { + name: "prefix wildcard", + pattern: "*bc", + value: "abc", + want: true, + }, + { + name: "prefix wildcard mismatch", + pattern: "*bd", + value: "abc", + want: false, + }, + { + name: "suffix wildcard", + pattern: "ab*", + value: "abc", + want: true, + }, + { + name: "suffix wildcard mismatch", + pattern: "ac*", + value: "abc", + want: false, + }, + { + name: "middle wildcard", + pattern: "a*c", + value: "abc", + want: true, + }, + { + name: "middle wildcard spans many characters", + pattern: "a*z", + value: "abcdefghijklmnopqrstuvwxyz", + want: true, + }, + { + name: "middle wildcard mismatch", + pattern: "a*d", + value: "abc", + want: false, + }, + { + name: "multiple wildcards match ordered parts", + pattern: "a*c*e", + value: "abcde", + want: true, + }, + { + name: "multiple wildcards mismatch missing suffix", + pattern: "a*c*f", + value: "abcde", + want: false, + }, + { + name: "multiple wildcards preserve prefix anchor", + pattern: "a*c", + value: "xxabc", + want: false, + }, + { + name: "multiple wildcards preserve suffix anchor", + pattern: "a*c", + value: "abcyy", + want: false, + }, + { + name: "wildcards around middle part", + pattern: "*bc*", + value: "abcde", + want: true, + }, + { + name: "wildcards around missing middle part", + pattern: "*bd*", + value: "abcde", + want: false, + }, + { + name: "consecutive wildcards only", + pattern: "**", + value: "anything", + want: true, + }, + { + name: "consecutive wildcards with literals", + pattern: "a**c", + value: "abc", + want: true, + }, + { + name: "consecutive wildcards can consume zero characters", + pattern: "a**c", + value: "ac", + want: true, + }, + { + name: "wildcard can match separators", + pattern: "apps/*", + value: "apps/v1", + want: true, + }, + { + name: "wildcard separator mismatch", + pattern: "apps/*", + value: "batch/v1", + want: false, + }, + { + name: "wildcard exact separator match", + pattern: "apps/*/v1", + value: "apps/foo/v1", + want: true, + }, + { + name: "wildcard over multiple separators", + pattern: "apps/*", + value: "apps/foo/bar", + want: true, + }, + { + name: "asterisk is the only operator", + pattern: "a.b", + value: "acb", + want: false, + }, + { + name: "regex-looking pattern is literal", + pattern: "a[bc]", + value: "ab", + want: false, + }, + { + name: "question mark is literal", + pattern: "a?c", + value: "abc", + want: false, + }, + { + name: "case-sensitive mismatch", + pattern: "ABC", + value: "abc", + want: false, + }, + { + name: "unicode exact match", + pattern: "Ä", + value: "Ä", + want: true, + }, + { + name: "unicode wildcard match", + pattern: "Ä*", + value: "Äbc", + want: true, + }, + { + name: "unicode mismatch", + pattern: "Ä", + value: "A", + want: false, + }, + { + name: "spaces are literal", + pattern: "a c", + value: "a c", + want: true, + }, + { + name: "spaces mismatch", + pattern: "a c", + value: "abc", + want: false, + }, + { + name: "wildcard can consume literal star in value", + pattern: "a*b", + value: "a*b", + want: true, + }, + { + name: "literal star cannot be escaped", + pattern: `a\*b`, + value: "a*b", + want: false, + }, + { + name: "required suffix is enforced", + pattern: "*abc", + value: "ab", + want: false, + }, + { + name: "required prefix is enforced", + pattern: "abc*", + value: "zabc", + want: false, + }, + { + name: "trailing stars accepted after value exhausted", + pattern: "abc***", + value: "abc", + want: true, + }, + { + name: "trailing non-star rejected after value exhausted", + pattern: "abc*d", + value: "abc", + want: false, + }, + { + name: "value longer than exact pattern does not match", + pattern: "abc", + value: "abcdef", + want: false, + }, + { + name: "pattern longer than value without wildcard does not match", + pattern: "abcdef", + value: "abc", + want: false, + }, + { + name: "zero-length wildcard between literals", + pattern: "abc*def", + value: "abcdef", + want: true, + }, + { + name: "non-zero wildcard between literals", + pattern: "abc*def", + value: "abcXYZdef", + want: true, + }, + { + name: "missing required suffix after wildcard", + pattern: "abc*deg", + value: "abcdef", + want: false, + }, + { + name: "wildcard cannot reorder characters", + pattern: "a*b*c", + value: "acb", + want: false, + }, + { + name: "wildcard with repeated chars", + pattern: "a*a", + value: "aaaa", + want: true, + }, + { + name: "wildcard cannot create missing required char", + pattern: "a*b", + value: "aaaa", + want: false, + }, + { + name: "dot literal with wildcard", + pattern: "*.k8s.io/v1", + value: "apiextensions.k8s.io/v1", + want: true, + }, + { + name: "dot literal with wildcard mismatch", + pattern: "*.k8s.io/v1", + value: "apps/v1", + want: false, + }, + { + name: "wildcard can match empty suffix", + pattern: "apps/*", + value: "apps/", + want: true, + }, + { + name: "wildcard can match empty prefix", + pattern: "*/v1", + value: "/v1", + want: true, + }, + { + name: "anchored prefix and suffix with no middle parts", + pattern: "apps/*/v1", + value: "apps//v1", + want: true, + }, + { + name: "empty value does not satisfy literal", + pattern: "ConfigMap", + value: "", + want: false, + }, + { + name: "single star matches literal star", + pattern: "*", + value: "*", + want: true, + }, + { + name: "only literal part and wildcard", + pattern: "***abc***", + value: "abc", + want: true, + }, + { + name: "only literal part and wildcard mismatch", + pattern: "***abc***", + value: "ab", + want: false, + }, + { + name: "ordered middle parts must all exist", + pattern: "*ab*cd*ef*", + value: "xxabyycdefzz", + want: true, + }, + { + name: "ordered middle parts cannot be reversed", + pattern: "*ab*cd*", + value: "xxcdyyab", + want: false, + }, + { + name: "prefix suffix and middle part must fit before suffix", + pattern: "a*b*c", + value: "abxc", + want: true, + }, + { + name: "middle part cannot overlap suffix region", + pattern: "a*b*bc", + value: "abc", + want: false, + }, + { + name: "middle part before suffix region", + pattern: "a*b*bc", + value: "abbc", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := matchPattern(tt.pattern, tt.value) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} + +func newVersionKindTestRESTMapper() apimeta.RESTMapper { + mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{ + { + Group: "", + Version: "v1", + }, + { + Group: "apps", + Version: "v1", + }, + { + Group: "apps", + Version: "v1beta1", + }, + { + Group: "batch", + Version: "v1", + }, + }) + + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "apps", + Version: "v1beta1", + Kind: "Deployment", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + apimeta.RESTScopeNamespace, + ) + + return mapper +} diff --git a/pkg/api/runtime/zz_generated.deepcopy.go b/pkg/api/runtime/zz_generated.deepcopy.go new file mode 100644 index 00000000..9f82937d --- /dev/null +++ b/pkg/api/runtime/zz_generated.deepcopy.go @@ -0,0 +1,71 @@ +//go:build !ignore_autogenerated + +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by controller-gen. DO NOT EDIT. + +package runtime + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExpressionMatch) DeepCopyInto(out *ExpressionMatch) { + *out = *in + out.ExpressionRegex = in.ExpressionRegex + if in.Exact != nil { + in, out := &in.Exact, &out.Exact + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpressionMatch. +func (in *ExpressionMatch) DeepCopy() *ExpressionMatch { + if in == nil { + return nil + } + out := new(ExpressionMatch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersionKind) DeepCopyInto(out *VersionKind) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionKind. +func (in *VersionKind) DeepCopy() *VersionKind { + if in == nil { + return nil + } + out := new(VersionKind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersionKinds) DeepCopyInto(out *VersionKinds) { + *out = *in + if in.APIGroups != nil { + in, out := &in.APIGroups, &out.APIGroups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Kinds != nil { + in, out := &in.Kinds, &out.Kinds + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionKinds. +func (in *VersionKinds) DeepCopy() *VersionKinds { + if in == nil { + return nil + } + out := new(VersionKinds) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index e43baf5d..a7cb6e0f 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -142,27 +142,6 @@ func (in *DefaultAllowedListSpec) DeepCopy() *DefaultAllowedListSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExpressionMatch) DeepCopyInto(out *ExpressionMatch) { - *out = *in - out.ExpressionRegex = in.ExpressionRegex - if in.Exact != nil { - in, out := &in.Exact, &out.Exact - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpressionMatch. -func (in *ExpressionMatch) DeepCopy() *ExpressionMatch { - if in == nil { - return nil - } - out := new(ExpressionMatch) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalServiceIPsSpec) DeepCopyInto(out *ExternalServiceIPsSpec) { *out = *in @@ -247,31 +226,6 @@ func (in *NetworkPolicySpec) DeepCopy() *NetworkPolicySpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OCIRegistry) DeepCopyInto(out *OCIRegistry) { - *out = *in - if in.Policy != nil { - in, out := &in.Policy, &out.Policy - *out = make([]corev1.PullPolicy, len(*in)) - copy(*out, *in) - } - if in.Validation != nil { - in, out := &in.Validation, &out.Validation - *out = make([]RegistryValidationTarget, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRegistry. -func (in *OCIRegistry) DeepCopy() *OCIRegistry { - if in == nil { - return nil - } - out := new(OCIRegistry) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PodOptions) DeepCopyInto(out *PodOptions) { *out = *in diff --git a/pkg/ruleengine/enforce_evaluator_test.go b/pkg/ruleengine/enforce_evaluator_test.go index 17eb6b40..4daa2b8b 100644 --- a/pkg/ruleengine/enforce_evaluator_test.go +++ b/pkg/ruleengine/enforce_evaluator_test.go @@ -903,6 +903,234 @@ func TestEvaluation_Append(t *testing.T) { }) } +func TestEvaluateEnforce_SkipsEmptyExtractedValues(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + action api.ActionType + values []Value + rules []string + wantMatcherCalls int + wantBlocking bool + wantFinal bool + wantAudits int + wantBlockingPath string + } + + tests := []testCase{ + { + name: "empty value is skipped before deny evaluation", + action: api.ActionTypeDeny, + values: []Value{ + { + Value: "", + Path: "spec.value", + }, + }, + rules: []string{ + "", + }, + wantMatcherCalls: 0, + wantBlocking: false, + wantFinal: false, + wantAudits: 0, + }, + { + name: "empty value is skipped before allow miss", + action: api.ActionTypeAllow, + values: []Value{ + { + Value: "", + Path: "spec.value", + }, + }, + rules: []string{ + "allowed", + }, + wantMatcherCalls: 0, + wantBlocking: false, + wantFinal: false, + wantAudits: 0, + }, + { + name: "empty value is skipped before audit evaluation", + action: api.ActionTypeAudit, + values: []Value{ + { + Value: "", + Path: "spec.value", + }, + }, + rules: []string{ + "", + }, + wantMatcherCalls: 0, + wantBlocking: false, + wantFinal: false, + wantAudits: 0, + }, + { + name: "empty value is skipped but later non empty value is evaluated", + action: api.ActionTypeDeny, + values: []Value{ + { + Value: "", + Path: "spec.empty", + }, + { + Value: "deny", + Path: "spec.nonEmpty", + }, + }, + rules: []string{ + "deny", + }, + wantMatcherCalls: 1, + wantBlocking: true, + wantFinal: true, + wantAudits: 0, + wantBlockingPath: "spec.nonEmpty", + }, + { + name: "whitespace value is not skipped", + action: api.ActionTypeDeny, + values: []Value{ + { + Value: " ", + Path: "spec.value", + }, + }, + rules: []string{ + " ", + }, + wantMatcherCalls: 1, + wantBlocking: true, + wantFinal: true, + wantAudits: 0, + wantBlockingPath: "spec.value", + }, + { + name: "empty values are skipped before non matching allow value triggers allow miss", + action: api.ActionTypeAllow, + values: []Value{ + { + Value: "", + Path: "spec.empty", + }, + { + Value: "actual", + Path: "spec.actual", + }, + }, + rules: []string{ + "allowed", + }, + wantMatcherCalls: 1, + wantBlocking: true, + wantFinal: false, + wantAudits: 0, + wantBlockingPath: "spec.actual", + }, + { + name: "all empty values are skipped", + action: api.ActionTypeDeny, + values: []Value{ + { + Value: "", + Path: "spec.first", + }, + { + Value: "", + Path: "spec.second", + }, + }, + rules: []string{ + "", + }, + wantMatcherCalls: 0, + wantBlocking: false, + wantFinal: false, + wantAudits: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + matcherCalls := 0 + + evaluation, err := EvaluateEnforce( + struct{}{}, + []*api.NamespaceRuleEnforceBody{ + { + Action: tt.action, + }, + }, + Set[string, struct{}]{ + Name: "registry", + EventReason: "NamespaceRuleViolation", + + Values: func(struct{}) []Value { + return tt.values + }, + + Rules: func(*api.NamespaceRuleEnforceBody) []string { + return tt.rules + }, + + Matches: func(rule string, value Value) (Match, error) { + matcherCalls++ + + return Match{ + Matched: value.Value == rule, + MatchedValue: rule, + }, nil + }, + + RuleDescription: func(rule string) string { + return rule + }, + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation") + } + + if matcherCalls != tt.wantMatcherCalls { + t.Fatalf("expected %d matcher calls, got %d", tt.wantMatcherCalls, matcherCalls) + } + + if got := evaluation.Blocking != nil; got != tt.wantBlocking { + t.Fatalf("expected blocking=%t, got %t: %#v", tt.wantBlocking, got, evaluation.Blocking) + } + + if got := evaluation.Final != nil; got != tt.wantFinal { + t.Fatalf("expected final=%t, got %t: %#v", tt.wantFinal, got, evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audits, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if tt.wantBlockingPath != "" { + if evaluation.Blocking == nil { + t.Fatalf("expected blocking decision") + } + + if evaluation.Blocking.Value.Path != tt.wantBlockingPath { + t.Fatalf("expected blocking path %q, got %q", tt.wantBlockingPath, evaluation.Blocking.Value.Path) + } + } + }) + } +} + func TestMessageHelpers(t *testing.T) { t.Parallel() diff --git a/pkg/ruleengine/validate.go b/pkg/ruleengine/validate.go index 58909363..2496d4b7 100644 --- a/pkg/ruleengine/validate.go +++ b/pkg/ruleengine/validate.go @@ -3,16 +3,23 @@ package ruleengine import ( + "errors" "fmt" "net" "regexp" "strings" - "github.com/projectcapsule/capsule/pkg/api" + k8smeta "k8s.io/apimachinery/pkg/api/meta" + k8svalidation "k8s.io/apimachinery/pkg/util/validation" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) -func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error { +func ValidateRuleStatusBody( + mapper k8smeta.RESTMapper, + bodies []*rules.NamespaceRuleBodyNamespace, +) error { for i, rule := range bodies { if rule == nil || rule.Enforce == nil { continue @@ -25,6 +32,10 @@ func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error { if err := validateServiceRules(i, rule.Enforce.Services); err != nil { return err } + + if err := validateMetadataRules(i, rule.Enforce.Metadata, mapper); err != nil { + return err + } } return nil @@ -112,7 +123,76 @@ func validateServiceRules( return nil } -func validateExpressionMatch(match api.ExpressionMatch, fieldPath string) error { +func validateMetadataRules( + ruleIndex int, + metadata []rules.MetadataRule, + mapper k8smeta.RESTMapper, +) error { + for j, rule := range metadata { + fieldPath := fmt.Sprintf("rules[%d].enforce.metadata[%d]", ruleIndex, j) + + if err := validateMetadataTargets(fieldPath, rule, mapper); err != nil { + return err + } + + for key, policy := range rule.Labels { + if err := validateMetadataKey(key); err != nil { + return fmt.Errorf( + "%s.labels[%q] is invalid: %w", + fieldPath, + key, + err, + ) + } + + for k, matcher := range policy.Values { + if err := validateExpressionMatch( + matcher, + fmt.Sprintf("%s.labels[%q].values[%d]", fieldPath, key, k), + ); err != nil { + return err + } + } + } + + for key, policy := range rule.Annotations { + if err := validateMetadataKey(key); err != nil { + return fmt.Errorf( + "%s.annotations[%q] is invalid: %w", + fieldPath, + key, + err, + ) + } + + for k, matcher := range policy.Values { + if err := validateExpressionMatch( + matcher, + fmt.Sprintf("%s.annotations[%q].values[%d]", fieldPath, key, k), + ); err != nil { + return err + } + } + } + } + + return nil +} + +func validateMetadataKey(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return errors.New("key is empty") + } + + if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 { + return errors.New(strings.Join(errs, ", ")) + } + + return nil +} + +func validateExpressionMatch(match runtime.ExpressionMatch, fieldPath string) error { if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil { return err } @@ -181,3 +261,30 @@ func validateNodePortRange(portRange rules.ServiceNodePortRange) error { return nil } + +func validateMetadataTargets( + fieldPath string, + rule rules.MetadataRule, + mapper k8smeta.RESTMapper, +) error { + if len(rule.Kinds) == 0 { + return fmt.Errorf("%s.kinds is invalid: at least one kind must be configured", fieldPath) + } + + for i, kind := range rule.Kinds { + kind = strings.TrimSpace(kind) + if kind == "" { + return fmt.Errorf("%s.kinds[%d] is invalid: kind is empty", fieldPath, i) + } + } + + if mapper == nil { + return nil + } + + if err := rule.ValidateKnownKinds(mapper, fieldPath); err != nil { + return err + } + + return nil +} diff --git a/pkg/ruleengine/validate_test.go b/pkg/ruleengine/validate_test.go index e39b9a81..3fcbe757 100644 --- a/pkg/ruleengine/validate_test.go +++ b/pkg/ruleengine/validate_test.go @@ -1,23 +1,33 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 + package ruleengine import ( "strings" "testing" - "github.com/projectcapsule/capsule/pkg/api" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) func TestValidateRuleStatusBody(t *testing.T) { + t.Parallel() + + mapper := newRuleValidationRESTMapper() + tests := []struct { name string bodies []*rules.NamespaceRuleBodyNamespace + mapper apimeta.RESTMapper wantErr string }{ { - name: "nil bodies are valid", + name: "nil bodies are valid", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ nil, {}, @@ -27,31 +37,85 @@ func TestValidateRuleStatusBody(t *testing.T) { }, }, { - name: "valid workload and service rules", + name: "valid workload service and metadata rules", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ Action: rules.ActionTypeAllow, + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "ConfigMap", + "Service", + "Deployment", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "^(prod|test|dev)$", + }, + }, + { + Exact: []string{ + "prod", + "test", + }, + }, + }, + }, + "presence-only": { + Required: true, + }, + }, + Annotations: map[string]rules.MetadataValueRule{ + "example.corp/cost-center": { + Required: false, + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "^INV-[0-9]{4}$", + }, + }, + { + Exact: []string{ + "prod", + "test", + }, + }, + }, + }, + }, + }, + }, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "harbor/.*", }, }, }, { - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ "harbor/platform/debian:latest", }, }, }, }, - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "tenant-[a-z0-9-]+", }, }, @@ -73,19 +137,19 @@ func TestValidateRuleStatusBody(t *testing.T) { }, }, ExternalNames: &rules.ServiceExternalNameRule{ - Hostnames: []api.ExpressionMatch{ + Hostnames: []runtime.ExpressionMatch{ { Exact: []string{ "internal.git.com", }, }, { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: ".*\\.example\\.com", }, }, { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "trusted\\..*", Negate: true, }, @@ -110,15 +174,325 @@ func TestValidateRuleStatusBody(t *testing.T) { }, }, { - name: "invalid workload registry regex", + name: "valid metadata rule with empty apiVersion meaning core v1", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "ConfigMap", + "Service", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + Exact: []string{ + "prod", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "valid metadata rule with wildcard apiVersion and wildcard kind", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAudit, + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "*", + }, + }, + Annotations: map[string]rules.MetadataValueRule{ + "example.corp/audit": { + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "^audit-.*", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "valid metadata rule with partial wildcards", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "apps/*", + }, + Kinds: []string{ + "*Set", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + Exact: []string{ + "prod", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "invalid metadata label regex", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.metadata[0].labels["env"].values[0].exp "[" is invalid`, + }, + { + name: "invalid metadata annotation regex", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Annotations: map[string]rules.MetadataValueRule{ + "example.corp/cost-center": { + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.metadata[0].annotations["example.corp/cost-center"].values[0].exp "[" is invalid`, + }, + { + name: "invalid metadata label key", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "bad/key/again": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + Exact: []string{ + "prod", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.metadata[0].labels["bad/key/again"] is invalid`, + }, + { + name: "invalid metadata annotation key", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Annotations: map[string]rules.MetadataValueRule{ + "bad/key/again": { + Values: []runtime.ExpressionMatch{ + { + Exact: []string{ + "value", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.metadata[0].annotations["bad/key/again"] is invalid`, + }, + { + name: "reports correct metadata indexes across multiple rules and values", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "valid-.*", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "Service", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "team": { + Values: []runtime.ExpressionMatch{ + { + Exact: []string{ + "platform", + }, + }, + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[1].enforce.metadata[0].labels["team"].values[1].exp "[" is invalid`, + }, + { + name: "invalid workload registry regex", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "[", }, }, @@ -131,14 +505,15 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.workloads.registries[0].exp "[" is invalid`, }, { - name: "invalid workload scheduler regex", + name: "invalid workload scheduler regex", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Schedulers: []api.ExpressionMatch{ + Schedulers: []runtime.ExpressionMatch{ { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "[", }, }, @@ -150,7 +525,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.workloads.schedulers[0].exp "[" is invalid`, }, { - name: "invalid service type", + name: "invalid service type", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -166,7 +542,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.types[1] "InvalidType" is invalid`, }, { - name: "invalid loadBalancer CIDR", + name: "invalid loadBalancer CIDR", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -183,7 +560,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "10.0.0.0/33" is invalid`, }, { - name: "empty loadBalancer CIDR", + name: "empty loadBalancer CIDR", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -200,15 +578,16 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "" is invalid: CIDR is empty`, }, { - name: "invalid externalName hostname regex", + name: "invalid externalName hostname regex", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ Services: rules.NamespaceRuleEnforceServicesBody{ ExternalNames: &rules.ServiceExternalNameRule{ - Hostnames: []api.ExpressionMatch{ + Hostnames: []runtime.ExpressionMatch{ { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "[", }, }, @@ -221,7 +600,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.externalNames.hostnames[0].exp "[" is invalid`, }, { - name: "nodePort from greater than to", + name: "nodePort from greater than to", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -241,7 +621,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 32767 must be lower than or equal to 30000`, }, { - name: "nodePort from below valid port range", + name: "nodePort from below valid port range", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -261,7 +642,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 0 must be between 1 and 65535`, }, { - name: "nodePort to above valid port range", + name: "nodePort to above valid port range", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -281,7 +663,8 @@ func TestValidateRuleStatusBody(t *testing.T) { wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: to 70000 must be between 1 and 65535`, }, { - name: "single nodePort range is valid", + name: "single nodePort range is valid", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -300,7 +683,8 @@ func TestValidateRuleStatusBody(t *testing.T) { }, }, { - name: "reports correct indexes across multiple rules", + name: "reports correct indexes across multiple service rules", + mapper: mapper, bodies: []*rules.NamespaceRuleBodyNamespace{ { Enforce: &rules.NamespaceRuleEnforceBody{ @@ -315,14 +699,14 @@ func TestValidateRuleStatusBody(t *testing.T) { Enforce: &rules.NamespaceRuleEnforceBody{ Services: rules.NamespaceRuleEnforceServicesBody{ ExternalNames: &rules.ServiceExternalNameRule{ - Hostnames: []api.ExpressionMatch{ + Hostnames: []runtime.ExpressionMatch{ { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "valid\\..*", }, }, { - ExpressionRegex: api.ExpressionRegex{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "[", }, }, @@ -338,7 +722,9 @@ func TestValidateRuleStatusBody(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateRuleStatusBody(tt.bodies) + t.Parallel() + + err := ValidateRuleStatusBody(tt.mapper, tt.bodies) if tt.wantErr == "" { if err != nil { @@ -358,3 +744,452 @@ func TestValidateRuleStatusBody(t *testing.T) { }) } } + +func TestValidateRuleStatusBodyWithRESTMapper(t *testing.T) { + t.Parallel() + + mapper := newRuleValidationRESTMapper() + + tests := []struct { + name string + bodies []*rules.NamespaceRuleBodyNamespace + mapper apimeta.RESTMapper + wantErr []string + }{ + { + name: "nil mapper skips discovery validation", + mapper: nil, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "NotAThing", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "known core v1 multiple kinds are valid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "ConfigMap", + "Service", + "Pod", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + Exact: []string{ + "prod", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "known grouped apiVersion kind is valid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + "StatefulSet", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "unknown core kind is invalid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "NotAThing", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0]`, + `NotAThing`, + }, + }, + { + name: "wrong apiVersion kind combination is invalid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "Deployment", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0]`, + `Deployment`, + }, + }, + { + name: "unknown grouped kind is invalid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "NotADeployment", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + wantErr: []string{ + `rules[0].enforce.metadata[0].kinds[0]`, + `NotADeployment`, + }, + }, + { + name: "wildcard apiVersion skips discovery validation", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "*", + }, + Kinds: []string{ + "NotAThing", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "wildcard kind skips discovery validation", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "*", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "partial wildcard kind skips discovery validation", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "*Set", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "reports correct indexes across multiple metadata rules and kinds", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + }, + }, + }, + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "apps/v1", + }, + Kinds: []string{ + "Deployment", + "NotADeployment", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "team": { + Required: true, + }, + }, + }, + }, + }, + }, + }, + wantErr: []string{ + `rules[0].enforce.metadata[1].kinds[1]`, + `NotADeployment`, + }, + }, + { + name: "still validates metadata syntax when mapper is enabled", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: runtime.VersionKinds{ + APIGroups: []string{ + "v1", + }, + Kinds: []string{ + "ConfigMap", + }, + }, + Labels: map[string]rules.MetadataValueRule{ + "env": { + Required: true, + Values: []runtime.ExpressionMatch{ + { + ExpressionRegex: runtime.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: []string{ + `rules[0].enforce.metadata[0].labels["env"].values[0].exp "[" is invalid`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateRuleStatusBody(tt.mapper, tt.bodies) + + if len(tt.wantErr) == 0 { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + for _, expected := range tt.wantErr { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error containing %q, got %q", expected, err.Error()) + } + } + }) + } +} + +func newRuleValidationRESTMapper() apimeta.RESTMapper { + mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{ + { + Group: "", + Version: "v1", + }, + { + Group: "apps", + Version: "v1", + }, + }) + + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + apimeta.RESTScopeNamespace, + ) + + mapper.Add( + schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + apimeta.RESTScopeNamespace, + ) + + return mapper +} diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index d6c61d63..cdefef89 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -8,11 +8,14 @@ const ( ReasonTenantResourceWriteOp string = "TenantResourceWriteOp" ReasonOverprovision string = "Overprovisioned" ReasonCordoning string = "Cordoned" + // ForbiddenLabelReason used as reason string to deny forbidden labels. ReasonForbiddenLabel string = "ForbiddenLabel" // ForbiddenAnnotationReason used as reason string to deny forbidden annotations. ReasonForbiddenAnnotation string = "ForbiddenAnnotation" - ReasonAdmissionFailure string = "AdmissionFailed" + ReasonForbiddenMetadata string = "ForbiddenMetadata" + + ReasonAdmissionFailure string = "AdmissionFailed" // RuleStatus. ReasonNamespaceRuleAudit string = "NamespaceRuleAudit" diff --git a/pkg/runtime/gvk/gk_types.go b/pkg/runtime/gvk/gk_types.go deleted file mode 100644 index 0abc03a3..00000000 --- a/pkg/runtime/gvk/gk_types.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package gvk - -import "k8s.io/apimachinery/pkg/runtime/schema" - -type VersionKind struct { - // Kind of the referent. - // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"` - // API version of the referent. - APIVersion string `json:"apiVersion" protobuf:"bytes,5,opt,name=apiVersion"` -} - -func (s VersionKind) GroupVersionKind() schema.GroupVersionKind { - gv, err := schema.ParseGroupVersion(s.APIVersion) - if err != nil { - return schema.GroupVersionKind{ - Kind: s.Kind, - } - } - - return gv.WithKind(s.Kind) -} diff --git a/pkg/runtime/gvk/has_gvk.go b/pkg/runtime/gvk/gvk.go similarity index 100% rename from pkg/runtime/gvk/has_gvk.go rename to pkg/runtime/gvk/gvk.go diff --git a/pkg/runtime/gvk/has_gvk_test.go b/pkg/runtime/gvk/gvk_test.go similarity index 100% rename from pkg/runtime/gvk/has_gvk_test.go rename to pkg/runtime/gvk/gvk_test.go diff --git a/pkg/template/reference.go b/pkg/template/reference.go index 43a87911..899640e8 100644 --- a/pkg/template/reference.go +++ b/pkg/template/reference.go @@ -16,14 +16,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/projectcapsule/capsule/pkg/runtime/gvk" + "github.com/projectcapsule/capsule/pkg/api/runtime" "github.com/projectcapsule/capsule/pkg/runtime/selectors" ) // Reference // +kubebuilder:object:generate=true type ResourceReference struct { - gvk.VersionKind `json:",inline"` + runtime.VersionKind `json:",inline"` // Name of the values referent. This is useful // when you traying to get a specific resource diff --git a/pkg/template/rules_test.go b/pkg/template/rules_test.go index daf1fc26..48497938 100644 --- a/pkg/template/rules_test.go +++ b/pkg/template/rules_test.go @@ -9,8 +9,8 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) func TestRenderNamespaceRuleBodies(t *testing.T) { @@ -57,7 +57,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) { Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ "{{ .tenant.metadata.name }}/{{ .namespace.metadata.name }}/app:1", }, @@ -114,7 +114,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) { Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ `{{ index .namespace.metadata.labels "registry-prefix" }}/app:1`, }, @@ -151,8 +151,8 @@ func TestRenderNamespaceRuleBodies(t *testing.T) { Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "{{ .tenant.metadata.name }}/allow/.*", }, }, @@ -170,8 +170,8 @@ func TestRenderNamespaceRuleBodies(t *testing.T) { }, Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ - ExpressionRegex: api.ExpressionRegex{ + ExpressionMatch: runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ Expression: "{{ .tenant.metadata.name }}/deny/.*", }, }, @@ -229,7 +229,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) { Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ "{{ .namespace.metadata.labels.registry }}/app:1", }, @@ -284,7 +284,7 @@ func TestRenderNamespaceRuleBodies_DoesNotMutateInput(t *testing.T) { Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ { - ExpressionMatch: api.ExpressionMatch{ + ExpressionMatch: runtime.ExpressionMatch{ Exact: []string{ "{{ .tenant.metadata.name }}/app:1", },