diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d28116145..002a9e753 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,7 +45,6 @@ jobs: fail-fast: false matrix: k8s-version: - - 'v1.30.0' - 'v1.31.0' - 'v1.32.0' - 'v1.33.0' diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index 36ec0673c..fd813f05f 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -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: diff --git a/.nwa-config b/.nwa-config index a66d10bf2..664f6ae02 100644 --- a/.nwa-config +++ b/.nwa-config @@ -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 diff --git a/Makefile b/Makefile index 9d7d6af2f..12b9288c6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/api/v1beta1/owner_list_test.go b/api/v1beta1/owner_list_test.go index d2d4fd9f3..d6a33307c 100644 --- a/api/v1beta1/owner_list_test.go +++ b/api/v1beta1/owner_list_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package v1beta1 diff --git a/api/v1beta1/tenant_webhook.go b/api/v1beta1/tenant_webhook.go index af3011826..d4dfe7efc 100644 --- a/api/v1beta1/tenant_webhook.go +++ b/api/v1beta1/tenant_webhook.go @@ -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() } diff --git a/api/v1beta2/capsuleconfiguration_status.go b/api/v1beta2/capsuleconfiguration_status.go index a13734494..15c6e1f42 100644 --- a/api/v1beta2/capsuleconfiguration_status.go +++ b/api/v1beta2/capsuleconfiguration_status.go @@ -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"` } diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 4afa88597..b3c7c0eed 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -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 { diff --git a/api/v1beta2/namespace_rule_type.go b/api/v1beta2/namespace_rule_type.go new file mode 100644 index 000000000..79a8632ca --- /dev/null +++ b/api/v1beta2/namespace_rule_type.go @@ -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"` +} diff --git a/api/v1beta2/resourcepool_func_test.go b/api/v1beta2/resourcepool_func_test.go index a5bcfc6a0..1b7616ae6 100644 --- a/api/v1beta2/resourcepool_func_test.go +++ b/api/v1beta2/resourcepool_func_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package v1beta2 diff --git a/api/v1beta2/resourcepoolclaim_func_test.go b/api/v1beta2/resourcepoolclaim_func_test.go index bfe0af085..5312be757 100644 --- a/api/v1beta2/resourcepoolclaim_func_test.go +++ b/api/v1beta2/resourcepoolclaim_func_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package v1beta2 diff --git a/api/v1beta2/rule_status_type.go b/api/v1beta2/rule_status_type.go new file mode 100644 index 000000000..e121c3fc7 --- /dev/null +++ b/api/v1beta2/rule_status_type.go @@ -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"` +} diff --git a/api/v1beta2/tenant_func.go b/api/v1beta2/tenant_func.go index e0df16fd8..65bdd261f 100644 --- a/api/v1beta2/tenant_func.go +++ b/api/v1beta2/tenant_func.go @@ -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()...) diff --git a/api/v1beta2/tenant_func_test.go b/api/v1beta2/tenant_func_test.go index 30b767164..0857dfa51 100644 --- a/api/v1beta2/tenant_func_test.go +++ b/api/v1beta2/tenant_func_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package v1beta2 diff --git a/api/v1beta2/tenant_status.go b/api/v1beta2/tenant_status.go index 0a1927597..0ef14cafb 100644 --- a/api/v1beta2/tenant_status.go +++ b/api/v1beta2/tenant_status.go @@ -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 { diff --git a/api/v1beta2/tenant_types.go b/api/v1beta2/tenant_types.go index a43b56548..3d1dfff74 100644 --- a/api/v1beta2/tenant_types.go +++ b/api/v1beta2/tenant_types.go @@ -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"` diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 4664d5737..fa7a18e4b 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -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,11 +1493,6 @@ 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)) @@ -1281,8 +1500,6 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { (*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. diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 6f3f763ee..630af8ec2 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -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,12 +290,7 @@ 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) | @@ -289,6 +298,7 @@ The following Values have changed key or Value: | 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 | diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 5323c2770..064649e46 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -64,6 +64,195 @@ 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: |- @@ -71,6 +260,11 @@ spec: 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. diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml new file mode 100644 index 000000000..772d9f4e6 --- /dev/null +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -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: {} diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 0bbe6a28d..637596a0a 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -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 diff --git a/charts/capsule/templates/_helpers.tpl b/charts/capsule/templates/_helpers.tpl index 446ec9ad5..784e9010f 100644 --- a/charts/capsule/templates/_helpers.tpl +++ b/charts/capsule/templates/_helpers.tpl @@ -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 }} diff --git a/charts/capsule/templates/configuration.yaml b/charts/capsule/templates/configuration.yaml index 29d937cf8..a38c6693c 100644 --- a/charts/capsule/templates/configuration.yaml +++ b/charts/capsule/templates/configuration.yaml @@ -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: diff --git a/charts/capsule/templates/crd-lifecycle/rbac.yaml b/charts/capsule/templates/crd-lifecycle/rbac.yaml index b77989c60..a5f8f380a 100644 --- a/charts/capsule/templates/crd-lifecycle/rbac.yaml +++ b/charts/capsule/templates/crd-lifecycle/rbac.yaml @@ -32,6 +32,7 @@ rules: - globaltenantresources.capsule.clastix.io - tenants.capsule.clastix.io - tenantowners.capsule.clastix.io + - rulestatuses.capsule.clastix.io verbs: - create - delete diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 6c45f47a1..53735e57b 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -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 }} diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 0095f6093..87e192b38 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -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 }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index de41c0f71..8902ccefb 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -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" diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index a0076b5d0..5ddd03958 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -211,6 +211,25 @@ 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: [] @@ -218,7 +237,6 @@ manager: # 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: {} diff --git a/cmd/main.go b/cmd/main.go index f8bb83a4a..d2c9f9c00 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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,9 +323,12 @@ 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(), ), @@ -326,7 +336,7 @@ func main() { 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") diff --git a/e2e/namespace_additional_metadata_test.go b/e2e/namespace_additional_metadata_test.go index 835ffa13d..bf304bf0c 100644 --- a/e2e/namespace_additional_metadata_test.go +++ b/e2e/namespace_additional_metadata_test.go @@ -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{} diff --git a/e2e/rules_managed_test.go b/e2e/rules_managed_test.go new file mode 100644 index 000000000..04a3c1eb5 --- /dev/null +++ b/e2e/rules_managed_test.go @@ -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()) + }) +}) diff --git a/e2e/rules_registry_test.go b/e2e/rules_registry_test.go new file mode 100644 index 000000000..1b095cff9 --- /dev/null +++ b/e2e/rules_registry_test.go @@ -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:", + ) + }) + +}) diff --git a/e2e/sa_owner_promotion_test.go b/e2e/sa_owner_promotion_test.go index 09a4d49b1..7018df480 100644 --- a/e2e/sa_owner_promotion_test.go +++ b/e2e/sa_owner_promotion_test.go @@ -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 diff --git a/e2e/suite_test.go b/e2e/suite_test.go index 366c53e0d..21e412934 100644 --- a/e2e/suite_test.go +++ b/e2e/suite_test.go @@ -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 diff --git a/e2e/utils_test.go b/e2e/utils_test.go index b770053a5..e75798e4f 100644 --- a/e2e/utils_test.go +++ b/e2e/utils_test.go @@ -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) diff --git a/go.mod b/go.mod index 2c99a40fd..77bda2b30 100644 --- a/go.mod +++ b/go.mod @@ -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,12 +20,14 @@ 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 @@ -33,61 +35,80 @@ require ( 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 ) diff --git a/go.sum b/go.sum index 1540c7996..954a9da9d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index 79cb89078..80890a28a 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -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 diff --git a/hack/kind-cluster.yaml b/hack/kind-cluster.yaml new file mode 100644 index 000000000..d25358e03 --- /dev/null +++ b/hack/kind-cluster.yaml @@ -0,0 +1,8 @@ +--- +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: capsule +featureGates: + ImageVolume: true +nodes: +- role: control-plane diff --git a/hack/kind-cluster.yml b/hack/kind-cluster.yml deleted file mode 100644 index 88bd511a9..000000000 --- a/hack/kind-cluster.yml +++ /dev/null @@ -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 diff --git a/internal/cache/invalidation.go b/internal/cache/invalidation.go new file mode 100644 index 000000000..8529f2c0c --- /dev/null +++ b/internal/cache/invalidation.go @@ -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 +} diff --git a/internal/cache/registries.go b/internal/cache/registries.go new file mode 100644 index 000000000..0dc021b50 --- /dev/null +++ b/internal/cache/registries.go @@ -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 +} diff --git a/internal/cache/registries_test.go b/internal/cache/registries_test.go new file mode 100644 index 000000000..ab33e98d7 --- /dev/null +++ b/internal/cache/registries_test.go @@ -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:]) +} diff --git a/internal/controllers/admission/manager.go b/internal/controllers/admission/manager.go new file mode 100644 index 000000000..815d431e5 --- /dev/null +++ b/internal/controllers/admission/manager.go @@ -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 +} diff --git a/internal/controllers/admission/mutating.go b/internal/controllers/admission/mutating.go new file mode 100644 index 000000000..c43ff7cf9 --- /dev/null +++ b/internal/controllers/admission/mutating.go @@ -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 +} diff --git a/internal/controllers/admission/validating.go b/internal/controllers/admission/validating.go new file mode 100644 index 000000000..2371f6c01 --- /dev/null +++ b/internal/controllers/admission/validating.go @@ -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 +} diff --git a/internal/controllers/cfg/cache_registries.go b/internal/controllers/cfg/cache_registries.go new file mode 100644 index 000000000..e9302e69f --- /dev/null +++ b/internal/controllers/cfg/cache_registries.go @@ -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 +} diff --git a/internal/controllers/cfg/caches.go b/internal/controllers/cfg/caches.go new file mode 100644 index 000000000..71abe6ff3 --- /dev/null +++ b/internal/controllers/cfg/caches.go @@ -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 +} diff --git a/internal/controllers/cfg/manager.go b/internal/controllers/cfg/manager.go index 924221b9e..dcb5871fd 100644 --- a/internal/controllers/cfg/manager.go +++ b/internal/controllers/cfg/manager.go @@ -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( diff --git a/internal/controllers/pod/metadata.go b/internal/controllers/pod/metadata.go index 139f205d3..7f989ac11 100644 --- a/internal/controllers/pod/metadata.go +++ b/internal/controllers/pod/metadata.go @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + 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,7 +84,7 @@ 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 { @@ -90,7 +92,7 @@ func (m *MetadataReconciler) getTenant(ctx context.Context, namespacedName types } if tenant.Spec.PodOptions == nil || tenant.Spec.PodOptions.AdditionalMetadata == nil { - return nil, NewNoPodMetadata(namespacedName.Name) + return nil, caperrors.NewNoPodMetadata(namespacedName.Name) } return tenant, nil diff --git a/internal/controllers/pv/controller.go b/internal/controllers/pv/controller.go index 03b56412c..d01e4570e 100644 --- a/internal/controllers/pv/controller.go +++ b/internal/controllers/pv/controller.go @@ -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 { diff --git a/internal/controllers/rbac/manager.go b/internal/controllers/rbac/manager.go index 655092176..af49fb504 100644 --- a/internal/controllers/rbac/manager.go +++ b/internal/controllers/rbac/manager.go @@ -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,9 +42,12 @@ 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 { @@ -48,6 +55,7 @@ func (r *Manager) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlCo } 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,22 +90,18 @@ 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) } } @@ -105,13 +109,29 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res } 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 +} diff --git a/internal/controllers/resourcepools/claim_controller.go b/internal/controllers/resourcepools/claim_controller.go index 53de60c29..f7586c658 100644 --- a/internal/controllers/resourcepools/claim_controller.go +++ b/internal/controllers/resourcepools/claim_controller.go @@ -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,6 +27,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" ) type resourceClaimController struct { @@ -34,11 +35,12 @@ type resourceClaimController struct { 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, ) diff --git a/internal/controllers/resourcepools/manager.go b/internal/controllers/resourcepools/manager.go index 03c247daf..75aa66be7 100644 --- a/internal/controllers/resourcepools/manager.go +++ b/internal/controllers/resourcepools/manager.go @@ -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{ diff --git a/internal/controllers/resourcepools/pool_controller.go b/internal/controllers/resourcepools/pool_controller.go index 4b0217de0..912e85f3a 100644 --- a/internal/controllers/resourcepools/pool_controller.go +++ b/internal/controllers/resourcepools/pool_controller.go @@ -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 diff --git a/internal/controllers/resources/processor.go b/internal/controllers/resources/processor.go index 33532f3d3..cc96d3d61 100644 --- a/internal/controllers/resources/processor.go +++ b/internal/controllers/resources/processor.go @@ -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} diff --git a/internal/controllers/servicelabels/abstract.go b/internal/controllers/servicelabels/abstract.go index 5640fca83..fd78a8285 100644 --- a/internal/controllers/servicelabels/abstract.go +++ b/internal/controllers/servicelabels/abstract.go @@ -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,7 +86,7 @@ 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 { @@ -93,7 +94,7 @@ func (r *abstractServiceLabelsReconciler) getTenant(ctx context.Context, namespa } if tenant.Spec.ServiceOptions == nil || tenant.Spec.ServiceOptions.AdditionalMetadata == nil { - return nil, NewNoServicesMetadata(namespacedName.Name) + return nil, caperrors.NewNoServicesMetadata(namespacedName.Name) } return tenant, nil diff --git a/internal/controllers/servicelabels/endpoint_slices.go b/internal/controllers/servicelabels/endpoint_slices.go index eafbc1f7a..a64cf22f5 100644 --- a/internal/controllers/servicelabels/endpoint_slices.go +++ b/internal/controllers/servicelabels/endpoint_slices.go @@ -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) } diff --git a/internal/controllers/servicelabels/errors.go b/internal/controllers/servicelabels/errors.go deleted file mode 100644 index 3e57e1716..000000000 --- a/internal/controllers/servicelabels/errors.go +++ /dev/null @@ -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) -} diff --git a/internal/controllers/servicelabels/service.go b/internal/controllers/servicelabels/service.go index 39d3bbc63..745b31b4e 100644 --- a/internal/controllers/servicelabels/service.go +++ b/internal/controllers/servicelabels/service.go @@ -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) } diff --git a/internal/controllers/tenant/limitranges.go b/internal/controllers/tenant/limitranges.go index 80bdf6d5e..15ee7fa64 100644 --- a/internal/controllers/tenant/limitranges.go +++ b/internal/controllers/tenant/limitranges.go @@ -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 { diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index 9672f4a11..6259f4038 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -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,7 +40,9 @@ 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 { @@ -48,7 +50,7 @@ type Manager struct { 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,7 +240,7 @@ 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) @@ -243,7 +248,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct 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) diff --git a/internal/controllers/tenant/namespaces.go b/internal/controllers/tenant/namespaces.go index 865a2dbfa..b2e36c8e0 100644 --- a/internal/controllers/tenant/namespaces.go +++ b/internal/controllers/tenant/namespaces.go @@ -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, diff --git a/internal/controllers/tenant/networkpolicies.go b/internal/controllers/tenant/networkpolicies.go index b13004dc8..d01e23f12 100644 --- a/internal/controllers/tenant/networkpolicies.go +++ b/internal/controllers/tenant/networkpolicies.go @@ -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 diff --git a/internal/controllers/tenant/resourcequotas.go b/internal/controllers/tenant/resourcequotas.go index 67045fe92..c3ca1732c 100644 --- a/internal/controllers/tenant/resourcequotas.go +++ b/internal/controllers/tenant/resourcequotas.go @@ -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,7 +92,7 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 // For this case, we're going to block the Quota setting the Hard as the // used one. for name, hardQuota := range resourceQuota.Hard { - r.Log.V(4).Info("Desired hard " + name.String() + " quota is " + hardQuota.String()) + 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 @@ -100,7 +100,7 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, tenant *capsulev1beta2 quantity.Add(item.Status.Used[name]) } - r.Log.V(4).Info("Computed " + name.String() + " quota for the whole Tenant is " + quantity.String()) + 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) } diff --git a/internal/controllers/tenant/resourcequotas_quota.go b/internal/controllers/tenant/resourcequotas_quota.go index ccfd9c0ea..2f43c0291 100644 --- a/internal/controllers/tenant/resourcequotas_quota.go +++ b/internal/controllers/tenant/resourcequotas_quota.go @@ -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) { diff --git a/internal/controllers/tenant/rolebindings.go b/internal/controllers/tenant/rolebindings.go index 42d85a1a3..fd4737708 100644 --- a/internal/controllers/tenant/rolebindings.go +++ b/internal/controllers/tenant/rolebindings.go @@ -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 diff --git a/internal/controllers/tenant/status.go b/internal/controllers/tenant/status.go index 3786fe606..7fbe2eecb 100644 --- a/internal/controllers/tenant/status.go +++ b/internal/controllers/tenant/status.go @@ -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 diff --git a/internal/controllers/tenant/utils.go b/internal/controllers/tenant/utils.go index 9b66e25fe..ee0e2195b 100644 --- a/internal/controllers/tenant/utils.go +++ b/internal/controllers/tenant/utils.go @@ -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) -} diff --git a/internal/controllers/tls/manager.go b/internal/controllers/tls/manager.go index 2b05f299e..185c931fd 100644 --- a/internal/controllers/tls/manager.go +++ b/internal/controllers/tls/manager.go @@ -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{} diff --git a/internal/webhook/cfg/warnings.go b/internal/webhook/cfg/warnings.go index e17a9fffe..65ae39fb4 100644 --- a/internal/webhook/cfg/warnings.go +++ b/internal/webhook/cfg/warnings.go @@ -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) } diff --git a/internal/webhook/defaults/errors.go b/internal/webhook/defaults/errors.go deleted file mode 100644 index 6f3536b0f..000000000 --- a/internal/webhook/defaults/errors.go +++ /dev/null @@ -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) -} diff --git a/internal/webhook/defaults/gateway.go b/internal/webhook/defaults/gateway.go index a7055bbea..8032f58d9 100644 --- a/internal/webhook/defaults/gateway.go +++ b/internal/webhook/defaults/gateway.go @@ -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,7 +49,7 @@ 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 } @@ -58,7 +57,7 @@ func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client. 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 diff --git a/internal/webhook/defaults/handler.go b/internal/webhook/defaults/handler.go index b582e88bf..daec3d4a7 100644 --- a/internal/webhook/defaults/handler.go +++ b/internal/webhook/defaults/handler.go @@ -8,12 +8,12 @@ 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 { @@ -21,43 +21,43 @@ type handler struct { 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 { diff --git a/internal/webhook/defaults/ingress.go b/internal/webhook/defaults/ingress.go index de81c4d94..eaef66f93 100644 --- a/internal/webhook/defaults/ingress.go +++ b/internal/webhook/defaults/ingress.go @@ -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 diff --git a/internal/webhook/defaults/pods.go b/internal/webhook/defaults/pods.go index 4ad29307f..e783ac17a 100644 --- a/internal/webhook/defaults/pods.go +++ b/internal/webhook/defaults/pods.go @@ -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 diff --git a/internal/webhook/defaults/storage.go b/internal/webhook/defaults/storage.go index 0d82af4b6..c48367c6b 100644 --- a/internal/webhook/defaults/storage.go +++ b/internal/webhook/defaults/storage.go @@ -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 diff --git a/internal/webhook/dra/validate.go b/internal/webhook/dra/validate.go index ded00b2f9..b071ef1c7 100644 --- a/internal/webhook/dra/validate.go +++ b/internal/webhook/dra/validate.go @@ -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 } diff --git a/internal/webhook/gateway/errors.go b/internal/webhook/gateway/errors.go deleted file mode 100644 index ed48c3fae..000000000 --- a/internal/webhook/gateway/errors.go +++ /dev/null @@ -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: ") -} diff --git a/internal/webhook/gateway/validate_class.go b/internal/webhook/gateway/validate_class.go index 27bf211d4..e344e44d8 100644 --- a/internal/webhook/gateway/validate_class.go +++ b/internal/webhook/gateway/validate_class.go @@ -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 } diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go deleted file mode 100644 index 374080843..000000000 --- a/internal/webhook/handler.go +++ /dev/null @@ -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 -} diff --git a/internal/webhook/ingress/validate_class.go b/internal/webhook/ingress/validate_class.go index 913a22484..1f5c7d3d7 100644 --- a/internal/webhook/ingress/validate_class.go +++ b/internal/webhook/ingress/validate_class.go @@ -10,14 +10,16 @@ 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 { @@ -25,32 +27,39 @@ type class struct { 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 } diff --git a/internal/webhook/ingress/validate_collision.go b/internal/webhook/ingress/validate_collision.go index 8088bcdf4..3dc16591b 100644 --- a/internal/webhook/ingress/validate_collision.go +++ b/internal/webhook/ingress/validate_collision.go @@ -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) } } } diff --git a/internal/webhook/ingress/validate_hostnames.go b/internal/webhook/ingress/validate_hostnames.go index 22fdc3036..ce07ebe71 100644 --- a/internal/webhook/ingress/validate_hostnames.go +++ b/internal/webhook/ingress/validate_hostnames.go @@ -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 diff --git a/internal/webhook/ingress/validate_wildcard.go b/internal/webhook/ingress/validate_wildcard.go index 43436fbe0..28b70a113 100644 --- a/internal/webhook/ingress/validate_wildcard.go +++ b/internal/webhook/ingress/validate_wildcard.go @@ -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())) diff --git a/internal/webhook/misc/managed.go b/internal/webhook/misc/managed.go new file mode 100644 index 000000000..414b5ed74 --- /dev/null +++ b/internal/webhook/misc/managed.go @@ -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 +} diff --git a/internal/webhook/misc/tenant_assignment.go b/internal/webhook/misc/tenant_assignment.go index c068a7c68..55b63d253 100644 --- a/internal/webhook/misc/tenant_assignment.go +++ b/internal/webhook/misc/tenant_assignment.go @@ -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) diff --git a/internal/webhook/namespace/mutation/cordoning.go b/internal/webhook/namespace/mutation/cordoning.go index d038f6f27..d0de05692 100644 --- a/internal/webhook/namespace/mutation/cordoning.go +++ b/internal/webhook/namespace/mutation/cordoning.go @@ -11,14 +11,14 @@ 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" ) @@ -26,19 +26,19 @@ 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) } diff --git a/internal/webhook/namespace/mutation/handler.go b/internal/webhook/namespace/mutation/handler.go index b57e47ac1..35f51720a 100644 --- a/internal/webhook/namespace/mutation/handler.go +++ b/internal/webhook/namespace/mutation/handler.go @@ -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") diff --git a/internal/webhook/namespace/mutation/metadata.go b/internal/webhook/namespace/mutation/metadata.go index ddee24fbe..52ff15a30 100644 --- a/internal/webhook/namespace/mutation/metadata.go +++ b/internal/webhook/namespace/mutation/metadata.go @@ -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 { diff --git a/internal/webhook/namespace/mutation/ownerreference.go b/internal/webhook/namespace/mutation/ownerreference.go index 00fb40522..6bd3e9a05 100644 --- a/internal/webhook/namespace/mutation/ownerreference.go +++ b/internal/webhook/namespace/mutation/ownerreference.go @@ -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 } diff --git a/internal/webhook/namespace/validation/freezed.go b/internal/webhook/namespace/validation/freezed.go index 2e9b80579..2c3829177 100644 --- a/internal/webhook/namespace/validation/freezed.go +++ b/internal/webhook/namespace/validation/freezed.go @@ -7,21 +7,22 @@ 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} } @@ -29,12 +30,12 @@ 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") diff --git a/internal/webhook/namespace/validation/handler.go b/internal/webhook/namespace/validation/handler.go index d914bbc9c..85de0d05e 100644 --- a/internal/webhook/namespace/validation/handler.go +++ b/internal/webhook/namespace/validation/handler.go @@ -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()) diff --git a/internal/webhook/namespace/validation/patch.go b/internal/webhook/namespace/validation/patch.go index 7d93d729b..8e4a4dc2d 100644 --- a/internal/webhook/namespace/validation/patch.go +++ b/internal/webhook/namespace/validation/patch.go @@ -8,21 +8,22 @@ 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} } @@ -30,9 +31,9 @@ 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,9 +56,9 @@ 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) @@ -65,7 +66,7 @@ func (h *patchHandler) OnUpdate( 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 diff --git a/internal/webhook/namespace/validation/prefix.go b/internal/webhook/namespace/validation/prefix.go index f5b23096b..ff9bb7707 100644 --- a/internal/webhook/namespace/validation/prefix.go +++ b/internal/webhook/namespace/validation/prefix.go @@ -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 } diff --git a/internal/webhook/namespace/validation/quota.go b/internal/webhook/namespace/validation/quota.go index 0ad456faf..d0271302e 100644 --- a/internal/webhook/namespace/validation/quota.go +++ b/internal/webhook/namespace/validation/quota.go @@ -8,17 +8,19 @@ 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 "aHandler{} } @@ -26,9 +28,9 @@ 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 } diff --git a/internal/webhook/namespace/validation/user_metadata.go b/internal/webhook/namespace/validation/user_metadata.go index 667664100..b4dcd0e80 100644 --- a/internal/webhook/namespace/validation/user_metadata.go +++ b/internal/webhook/namespace/validation/user_metadata.go @@ -8,18 +8,19 @@ 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{} } @@ -27,15 +28,15 @@ 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,16 +61,16 @@ 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 } @@ -77,7 +78,7 @@ func (h *userMetadataHandler) OnUpdate( 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 } diff --git a/internal/webhook/networkpolicy/validating.go b/internal/webhook/networkpolicy/validating.go index de8f79637..6c14fcc61 100644 --- a/internal/webhook/networkpolicy/validating.go +++ b/internal/webhook/networkpolicy/validating.go @@ -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 { diff --git a/internal/webhook/node/user_metadata.go b/internal/webhook/node/user_metadata.go index c553ed32d..20933dd81 100644 --- a/internal/webhook/node/user_metadata.go +++ b/internal/webhook/node/user_metadata.go @@ -9,13 +9,15 @@ 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 { @@ -23,26 +25,26 @@ type userMetadataHandler struct { 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 } diff --git a/internal/webhook/pod/containerregistry.go b/internal/webhook/pod/containerregistry.go deleted file mode 100644 index 25f1ef09b..000000000 --- a/internal/webhook/pod/containerregistry.go +++ /dev/null @@ -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 -} diff --git a/internal/webhook/pod/containerregistry_errors.go b/internal/webhook/pod/containerregistry_errors.go deleted file mode 100644 index 15182beb2..000000000 --- a/internal/webhook/pod/containerregistry_errors.go +++ /dev/null @@ -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 ®istryClassForbiddenError{ - 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 -} diff --git a/internal/webhook/pod/containerregistry_legacy.go b/internal/webhook/pod/containerregistry_legacy.go new file mode 100644 index 000000000..29b3244fa --- /dev/null +++ b/internal/webhook/pod/containerregistry_legacy.go @@ -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 +} diff --git a/internal/webhook/pod/containerregistry_registry.go b/internal/webhook/pod/containerregistry_legacy_registry.go similarity index 96% rename from internal/webhook/pod/containerregistry_registry.go rename to internal/webhook/pod/containerregistry_legacy_registry.go index d3136ff43..e81176dc6 100644 --- a/internal/webhook/pod/containerregistry_registry.go +++ b/internal/webhook/pod/containerregistry_legacy_registry.go @@ -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 diff --git a/internal/webhook/pod/handler.go b/internal/webhook/pod/handler.go index 97325f849..a64f107e8 100644 --- a/internal/webhook/pod/handler.go +++ b/internal/webhook/pod/handler.go @@ -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, } } diff --git a/internal/webhook/pod/imagepullpolicy.go b/internal/webhook/pod/imagepullpolicy.go index a4dfd31c8..d412c3588 100644 --- a/internal/webhook/pod/imagepullpolicy.go +++ b/internal/webhook/pod/imagepullpolicy.go @@ -7,17 +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" 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{} } @@ -25,9 +27,10 @@ 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,7 +67,7 @@ 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 { @@ -70,19 +75,19 @@ func (h *imagePullPolicy) validate( } 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,7 +96,8 @@ func (h *imagePullPolicy) validate( } func (h *imagePullPolicy) verifyPullPolicy( - recorder record.EventRecorder, + recorder events.EventRecorder, + pod *corev1.Pod, req admission.Request, policy PullPolicy, usedPullPolicy string, @@ -99,9 +105,16 @@ func (h *imagePullPolicy) verifyPullPolicy( 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 } diff --git a/internal/webhook/pod/imagepullpolicy_errors.go b/internal/webhook/pod/imagepullpolicy_errors.go deleted file mode 100644 index 89e851ec0..000000000 --- a/internal/webhook/pod/imagepullpolicy_errors.go +++ /dev/null @@ -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, ", ")) -} diff --git a/internal/webhook/pod/imagepullpolicy_pullpolicy.go b/internal/webhook/pod/imagepullpolicy_pullpolicy.go index e348dd2b1..aa7aa66ef 100644 --- a/internal/webhook/pod/imagepullpolicy_pullpolicy.go +++ b/internal/webhook/pod/imagepullpolicy_pullpolicy.go @@ -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 { diff --git a/internal/webhook/pod/priorityclass.go b/internal/webhook/pod/priorityclass.go index 37c414f05..e7ea9de50 100644 --- a/internal/webhook/pod/priorityclass.go +++ b/internal/webhook/pod/priorityclass.go @@ -8,18 +8,20 @@ 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{} } @@ -27,9 +29,10 @@ 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 } diff --git a/internal/webhook/pod/priorityclass_errors.go b/internal/webhook/pod/priorityclass_errors.go deleted file mode 100644 index 303ba139e..000000000 --- a/internal/webhook/pod/priorityclass_errors.go +++ /dev/null @@ -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) -} diff --git a/internal/webhook/pod/registry.go b/internal/webhook/pod/registry.go new file mode 100644 index 000000000..f66afe97a --- /dev/null +++ b/internal/webhook/pod/registry.go @@ -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 ®istryHandler{ + 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, ", ") +} diff --git a/internal/webhook/pod/runtimeclass.go b/internal/webhook/pod/runtimeclass.go index c53ba3778..141052a23 100644 --- a/internal/webhook/pod/runtimeclass.go +++ b/internal/webhook/pod/runtimeclass.go @@ -10,17 +10,19 @@ 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{} } @@ -28,9 +30,10 @@ 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: diff --git a/internal/webhook/pod/runtimeclass_errors.go b/internal/webhook/pod/runtimeclass_errors.go deleted file mode 100644 index 61d90c7c2..000000000 --- a/internal/webhook/pod/runtimeclass_errors.go +++ /dev/null @@ -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) -} diff --git a/internal/webhook/pvc/errors.go b/internal/webhook/pvc/errors.go deleted file mode 100644 index 98fbc539f..000000000 --- a/internal/webhook/pvc/errors.go +++ /dev/null @@ -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" -} diff --git a/internal/webhook/pvc/handler.go b/internal/webhook/pvc/handler.go index 2e444575b..bfcacaf92 100644 --- a/internal/webhook/pvc/handler.go +++ b/internal/webhook/pvc/handler.go @@ -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, } } diff --git a/internal/webhook/pvc/pv.go b/internal/webhook/pvc/pv.go index 4efe218f3..4b2c2fe43 100644 --- a/internal/webhook/pvc/pv.go +++ b/internal/webhook/pvc/pv.go @@ -5,24 +5,26 @@ 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{} } @@ -30,45 +32,25 @@ 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 +} diff --git a/internal/webhook/pvc/validating.go b/internal/webhook/pvc/validating.go index a3465dcae..108d3459d 100644 --- a/internal/webhook/pvc/validating.go +++ b/internal/webhook/pvc/validating.go @@ -9,18 +9,20 @@ 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{} } @@ -28,9 +30,9 @@ 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 } diff --git a/internal/webhook/resourcepool/claim_mutating.go b/internal/webhook/resourcepool/claim_mutating.go index e3d687748..d7777001a 100644 --- a/internal/webhook/resourcepool/claim_mutating.go +++ b/internal/webhook/resourcepool/claim_mutating.go @@ -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) } diff --git a/internal/webhook/resourcepool/claim_validating.go b/internal/webhook/resourcepool/claim_validating.go index 34816b794..e52a4814a 100644 --- a/internal/webhook/resourcepool/claim_validating.go +++ b/internal/webhook/resourcepool/claim_validating.go @@ -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{} diff --git a/internal/webhook/resourcepool/pool_mutating.go b/internal/webhook/resourcepool/pool_mutating.go index 779aa5448..a36248b18 100644 --- a/internal/webhook/resourcepool/pool_mutating.go +++ b/internal/webhook/resourcepool/pool_mutating.go @@ -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) } diff --git a/internal/webhook/resourcepool/pool_validation.go b/internal/webhook/resourcepool/pool_validation.go index 809f11423..221d5eceb 100644 --- a/internal/webhook/resourcepool/pool_validation.go +++ b/internal/webhook/resourcepool/pool_validation.go @@ -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 { diff --git a/internal/webhook/route/config.go b/internal/webhook/route/config.go index 70c673f16..7641d8a99 100644 --- a/internal/webhook/route/config.go +++ b/internal/webhook/route/config.go @@ -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 } diff --git a/internal/webhook/route/cordoning.go b/internal/webhook/route/cordoning.go index d24cd752e..4f567c19f 100644 --- a/internal/webhook/route/cordoning.go +++ b/internal/webhook/route/cordoning.go @@ -3,15 +3,13 @@ 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} } @@ -19,6 +17,6 @@ func (w cordoning) GetPath() string { return "/cordoning" } -func (w cordoning) GetHandlers() []capsulewebhook.Handler { +func (w cordoning) GetHandlers() []handlers.Handler { return w.handlers } diff --git a/internal/webhook/route/customresources.go b/internal/webhook/route/customresources.go index 3f3f26313..ede649dbd 100644 --- a/internal/webhook/route/customresources.go +++ b/internal/webhook/route/customresources.go @@ -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 } diff --git a/internal/webhook/route/defaults.go b/internal/webhook/route/defaults.go index 00ad3db2e..0d00d61cc 100644 --- a/internal/webhook/route/defaults.go +++ b/internal/webhook/route/defaults.go @@ -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 } diff --git a/internal/webhook/route/deviceclass.go b/internal/webhook/route/deviceclass.go index db533ae07..aff5174c8 100644 --- a/internal/webhook/route/deviceclass.go +++ b/internal/webhook/route/deviceclass.go @@ -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 } diff --git a/internal/webhook/route/gateway.go b/internal/webhook/route/gateway.go index 60c63bf0e..729aef748 100644 --- a/internal/webhook/route/gateway.go +++ b/internal/webhook/route/gateway.go @@ -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 } diff --git a/internal/webhook/route/ingresses.go b/internal/webhook/route/ingresses.go index 53b13860f..931ddba93 100644 --- a/internal/webhook/route/ingresses.go +++ b/internal/webhook/route/ingresses.go @@ -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 } diff --git a/internal/webhook/route/misc.go b/internal/webhook/route/misc.go index 6469dcf71..76f72fcc1 100644 --- a/internal/webhook/route/misc.go +++ b/internal/webhook/route/misc.go @@ -3,15 +3,13 @@ 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} } @@ -19,6 +17,22 @@ 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 +} diff --git a/internal/webhook/route/namespaces.go b/internal/webhook/route/namespaces.go index 7cc480256..20a20a0cc 100644 --- a/internal/webhook/route/namespaces.go +++ b/internal/webhook/route/namespaces.go @@ -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 } diff --git a/internal/webhook/route/networkpolicies.go b/internal/webhook/route/networkpolicies.go index 80cb8e5c3..ae92f5d69 100644 --- a/internal/webhook/route/networkpolicies.go +++ b/internal/webhook/route/networkpolicies.go @@ -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 } diff --git a/internal/webhook/route/node.go b/internal/webhook/route/node.go index 58108752f..8419214f1 100644 --- a/internal/webhook/route/node.go +++ b/internal/webhook/route/node.go @@ -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 } diff --git a/internal/webhook/route/ownerreference.go b/internal/webhook/route/ownerreference.go index 75c814a83..fa8b47e56 100644 --- a/internal/webhook/route/ownerreference.go +++ b/internal/webhook/route/ownerreference.go @@ -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" } diff --git a/internal/webhook/route/pods.go b/internal/webhook/route/pods.go index a966e5739..386d67eac 100644 --- a/internal/webhook/route/pods.go +++ b/internal/webhook/route/pods.go @@ -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 } diff --git a/internal/webhook/route/pvc.go b/internal/webhook/route/pvc.go index 45b86320e..2b03fffa3 100644 --- a/internal/webhook/route/pvc.go +++ b/internal/webhook/route/pvc.go @@ -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 } diff --git a/internal/webhook/route/resourcepool.go b/internal/webhook/route/resourcepool.go index fbb5dd815..4592c0429 100644 --- a/internal/webhook/route/resourcepool.go +++ b/internal/webhook/route/resourcepool.go @@ -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 } diff --git a/internal/webhook/route/serviceaccounts.go b/internal/webhook/route/serviceaccounts.go index bc39d7ba9..8f32d4fdf 100644 --- a/internal/webhook/route/serviceaccounts.go +++ b/internal/webhook/route/serviceaccounts.go @@ -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 } diff --git a/internal/webhook/route/services.go b/internal/webhook/route/services.go index f3cfa59c2..9e981d2ef 100644 --- a/internal/webhook/route/services.go +++ b/internal/webhook/route/services.go @@ -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 } diff --git a/internal/webhook/route/tenantresource_objs.go b/internal/webhook/route/tenantresource_objs.go index 5c55dd6a0..2b7cc4178 100644 --- a/internal/webhook/route/tenantresource_objs.go +++ b/internal/webhook/route/tenantresource_objs.go @@ -3,15 +3,13 @@ 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} } @@ -19,6 +17,6 @@ func (t tntResourceObjs) GetPath() string { return "/tenantresource-objects" } -func (t tntResourceObjs) GetHandlers() []capsulewebhook.Handler { +func (t tntResourceObjs) GetHandlers() []handlers.Handler { return t.handlers } diff --git a/internal/webhook/route/tenants.go b/internal/webhook/route/tenants.go index 7d894f2e9..7ede33083 100644 --- a/internal/webhook/route/tenants.go +++ b/internal/webhook/route/tenants.go @@ -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 } diff --git a/internal/webhook/router.go b/internal/webhook/router.go index 27f107747..c1f61f37f 100644 --- a/internal/webhook/router.go +++ b/internal/webhook/router.go @@ -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 { diff --git a/internal/webhook/service/handler.go b/internal/webhook/service/handler.go index f818e0844..055f90492 100644 --- a/internal/webhook/service/handler.go +++ b/internal/webhook/service/handler.go @@ -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, } } diff --git a/internal/webhook/service/validating.go b/internal/webhook/service/validating.go index 90dbaa4d8..e3031cd03 100644 --- a/internal/webhook/service/validating.go +++ b/internal/webhook/service/validating.go @@ -10,18 +10,20 @@ 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{} } @@ -29,9 +31,9 @@ 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,9 +56,9 @@ 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 } @@ -64,30 +66,51 @@ func (h *validating) OnDelete( 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 } @@ -95,8 +118,17 @@ func (h *validating) handle( 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 @@ -104,8 +136,17 @@ func (h *validating) handle( 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 } diff --git a/internal/webhook/serviceaccounts/handler.go b/internal/webhook/serviceaccounts/handler.go index b3d1c54e5..328b6e470 100644 --- a/internal/webhook/serviceaccounts/handler.go +++ b/internal/webhook/serviceaccounts/handler.go @@ -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, } } diff --git a/internal/webhook/serviceaccounts/validating.go b/internal/webhook/serviceaccounts/validating.go index 181bd723e..4325ff562 100644 --- a/internal/webhook/serviceaccounts/validating.go +++ b/internal/webhook/serviceaccounts/validating.go @@ -5,24 +5,26 @@ 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} } @@ -30,11 +32,11 @@ 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,11 +45,11 @@ 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) } } @@ -55,9 +57,9 @@ 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 } diff --git a/internal/webhook/tenant/mutation/metadata.go b/internal/webhook/tenant/mutation/metadata.go index 7807486d6..922bc6490 100644 --- a/internal/webhook/tenant/mutation/metadata.go +++ b/internal/webhook/tenant/mutation/metadata.go @@ -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 } diff --git a/internal/webhook/tenant/validation/containerregistry_regex.go b/internal/webhook/tenant/validation/containerregistry_regex.go index c325bb570..84fcc4e02 100644 --- a/internal/webhook/tenant/validation/containerregistry_regex.go +++ b/internal/webhook/tenant/validation/containerregistry_regex.go @@ -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 diff --git a/internal/webhook/tenant/validation/cordoning.go b/internal/webhook/tenant/validation/cordoning.go index 741b65057..8b2cce6f9 100644 --- a/internal/webhook/tenant/validation/cordoning.go +++ b/internal/webhook/tenant/validation/cordoning.go @@ -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())) diff --git a/internal/webhook/tenant/validation/custom_resource_quota.go b/internal/webhook/tenant/validation/custom_resource_quota.go index 4cfc2b3b2..5541b4c00 100644 --- a/internal/webhook/tenant/validation/custom_resource_quota.go +++ b/internal/webhook/tenant/validation/custom_resource_quota.go @@ -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 } diff --git a/internal/webhook/tenant/validation/forbidden_annotations_regex.go b/internal/webhook/tenant/validation/forbidden_annotations_regex.go index 9655a250a..92b8a25b7 100644 --- a/internal/webhook/tenant/validation/forbidden_annotations_regex.go +++ b/internal/webhook/tenant/validation/forbidden_annotations_regex.go @@ -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 diff --git a/internal/webhook/tenant/validation/freezed_emitter.go b/internal/webhook/tenant/validation/freezed_emitter.go index 49cc7d898..6041042a1 100644 --- a/internal/webhook/tenant/validation/freezed_emitter.go +++ b/internal/webhook/tenant/validation/freezed_emitter.go @@ -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 diff --git a/internal/webhook/tenant/validation/hostname_regex.go b/internal/webhook/tenant/validation/hostname_regex.go index 0b36b36ce..695e78185 100644 --- a/internal/webhook/tenant/validation/hostname_regex.go +++ b/internal/webhook/tenant/validation/hostname_regex.go @@ -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 diff --git a/internal/webhook/tenant/validation/ingressclass_regex.go b/internal/webhook/tenant/validation/ingressclass_regex.go index 4455c83fc..584a796a8 100644 --- a/internal/webhook/tenant/validation/ingressclass_regex.go +++ b/internal/webhook/tenant/validation/ingressclass_regex.go @@ -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") diff --git a/internal/webhook/tenant/validation/name.go b/internal/webhook/tenant/validation/name.go index f4b4ae58b..0fd76572b 100644 --- a/internal/webhook/tenant/validation/name.go +++ b/internal/webhook/tenant/validation/name.go @@ -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 } diff --git a/internal/webhook/tenant/validation/protected.go b/internal/webhook/tenant/validation/protected.go index 63efff553..edd23f008 100644 --- a/internal/webhook/tenant/validation/protected.go +++ b/internal/webhook/tenant/validation/protected.go @@ -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 } diff --git a/internal/webhook/tenant/validation/rolebindings_regex.go b/internal/webhook/tenant/validation/rolebindings_regex.go index bbf5ed26a..52b8c294b 100644 --- a/internal/webhook/tenant/validation/rolebindings_regex.go +++ b/internal/webhook/tenant/validation/rolebindings_regex.go @@ -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) } diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go new file mode 100644 index 000000000..39ceba075 --- /dev/null +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -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 +} diff --git a/internal/webhook/tenant/validation/serviceaccount_format.go b/internal/webhook/tenant/validation/serviceaccount_format.go index 43cdf02e2..f1ad9a60f 100644 --- a/internal/webhook/tenant/validation/serviceaccount_format.go +++ b/internal/webhook/tenant/validation/serviceaccount_format.go @@ -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) } diff --git a/internal/webhook/tenant/validation/storageclass_regex.go b/internal/webhook/tenant/validation/storageclass_regex.go index fb0d41791..30fb9b1c6 100644 --- a/internal/webhook/tenant/validation/storageclass_regex.go +++ b/internal/webhook/tenant/validation/storageclass_regex.go @@ -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") diff --git a/internal/webhook/tenant/validation/warnings.go b/internal/webhook/tenant/validation/warnings.go index 878fe0917..a0f793545 100644 --- a/internal/webhook/tenant/validation/warnings.go +++ b/internal/webhook/tenant/validation/warnings.go @@ -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, diff --git a/internal/webhook/tenantresource/objects.go b/internal/webhook/tenantresource/objects.go index 6a981927d..55f2d15b9 100644 --- a/internal/webhook/tenantresource/objects.go +++ b/internal/webhook/tenantresource/objects.go @@ -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)) diff --git a/internal/webhook/utils/tenant_get.go b/internal/webhook/utils/tenant_get.go index e68831c3e..e2e875e38 100644 --- a/internal/webhook/utils/tenant_get.go +++ b/internal/webhook/utils/tenant_get.go @@ -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 { diff --git a/pkg/api/allowed_list_test.go b/pkg/api/allowed_list_test.go index 762c74431..6a2dda6e7 100644 --- a/pkg/api/allowed_list_test.go +++ b/pkg/api/allowed_list_test.go @@ -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, } diff --git a/internal/webhook/dra/errors.go b/pkg/api/errors/devices.go similarity index 76% rename from internal/webhook/dra/errors.go rename to pkg/api/errors/devices.go index 9616fd451..56a4b8fcc 100644 --- a/internal/webhook/dra/errors.go +++ b/pkg/api/errors/devices.go @@ -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 ") } diff --git a/pkg/api/errors/evented.go b/pkg/api/errors/evented.go new file mode 100644 index 000000000..88ffcefb7 --- /dev/null +++ b/pkg/api/errors/evented.go @@ -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(), + ) +} diff --git a/pkg/api/errors/gateway.go b/pkg/api/errors/gateway.go new file mode 100644 index 000000000..df2922830 --- /dev/null +++ b/pkg/api/errors/gateway.go @@ -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: ") +} diff --git a/internal/webhook/ingress/errors.go b/pkg/api/errors/ingress.go similarity index 68% rename from internal/webhook/ingress/errors.go rename to pkg/api/errors/ingress.go index 4233fbaef..352fa8ec9 100644 --- a/internal/webhook/ingress/errors.go +++ b/pkg/api/errors/ingress.go @@ -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) diff --git a/internal/controllers/tls/errors.go b/pkg/api/errors/misc.go similarity index 53% rename from internal/controllers/tls/errors.go rename to pkg/api/errors/misc.go index b96cd95d0..8ae5a65b5 100644 --- a/internal/controllers/tls/errors.go +++ b/pkg/api/errors/misc.go @@ -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" +} diff --git a/internal/webhook/namespace/validation/errors.go b/pkg/api/errors/namespaces.go similarity index 60% rename from internal/webhook/namespace/validation/errors.go rename to pkg/api/errors/namespaces.go index 92211c120..8273b19e0 100644 --- a/internal/webhook/namespace/validation/errors.go +++ b/pkg/api/errors/namespaces.go @@ -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" } diff --git a/internal/webhook/node/errors.go b/pkg/api/errors/nodes.go similarity index 81% rename from internal/webhook/node/errors.go rename to pkg/api/errors/nodes.go index a2a95ded0..d6c4b9ebe 100644 --- a/internal/webhook/node/errors.go +++ b/pkg/api/errors/nodes.go @@ -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)) } diff --git a/pkg/api/errors/pods.go b/pkg/api/errors/pods.go new file mode 100644 index 000000000..2f0a7c89f --- /dev/null +++ b/pkg/api/errors/pods.go @@ -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) +} diff --git a/internal/webhook/service/errors.go b/pkg/api/errors/services.go similarity index 55% rename from internal/webhook/service/errors.go rename to pkg/api/errors/services.go index 7c1ae238e..64096ca3d 100644 --- a/internal/webhook/service/errors.go +++ b/pkg/api/errors/services.go @@ -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,12 +33,12 @@ 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" } @@ -34,32 +46,32 @@ func (e externalServiceIPForbiddenError) Error() string { 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" } diff --git a/pkg/api/errors/storage.go b/pkg/api/errors/storage.go new file mode 100644 index 000000000..e7cc7f9c2 --- /dev/null +++ b/pkg/api/errors/storage.go @@ -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) +} diff --git a/internal/controllers/pod/errors.go b/pkg/api/errors/tenants.go similarity index 53% rename from internal/controllers/pod/errors.go rename to pkg/api/errors/tenants.go index 0765259e4..631da77f3 100644 --- a/internal/controllers/pod/errors.go +++ b/pkg/api/errors/tenants.go @@ -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) -} diff --git a/pkg/api/forbidden_list.go b/pkg/api/forbidden_list.go index d961d8491..03aa0cbe9 100644 --- a/pkg/api/forbidden_list.go +++ b/pkg/api/forbidden_list.go @@ -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"` diff --git a/pkg/api/forbidden_list_test.go b/pkg/api/forbidden_list_test.go index e96e89470..3a8ec4f71 100644 --- a/pkg/api/forbidden_list_test.go +++ b/pkg/api/forbidden_list_test.go @@ -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)) } } } diff --git a/pkg/api/image_pull_policy.go b/pkg/api/image_pull_policy.go deleted file mode 100644 index ab301f982..000000000 --- a/pkg/api/image_pull_policy.go +++ /dev/null @@ -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) -} diff --git a/pkg/api/meta/annotations.go b/pkg/api/meta/annotations.go index 407742bd3..258a10fad 100644 --- a/pkg/api/meta/annotations.go +++ b/pkg/api/meta/annotations.go @@ -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" diff --git a/pkg/api/meta/conditions_test.go b/pkg/api/meta/conditions_test.go index 4ec7dccf9..647c15c2e 100644 --- a/pkg/api/meta/conditions_test.go +++ b/pkg/api/meta/conditions_test.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors. // SPDX-License-Identifier: Apache-2.0 -package meta +package meta_test import ( "testing" @@ -9,11 +9,13 @@ import ( "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", diff --git a/pkg/api/meta/labels.go b/pkg/api/meta/labels.go index 50e98d580..9890166e8 100644 --- a/pkg/api/meta/labels.go +++ b/pkg/api/meta/labels.go @@ -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) +} diff --git a/pkg/api/meta/labels_test.go b/pkg/api/meta/labels_test.go index 3016f7bcb..6d31898cd 100644 --- a/pkg/api/meta/labels_test.go +++ b/pkg/api/meta/labels_test.go @@ -1,12 +1,16 @@ // 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) { @@ -14,24 +18,24 @@ func TestFreezeLabel(t *testing.T) { 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 +} diff --git a/pkg/api/meta/managers.go b/pkg/api/meta/managers.go new file mode 100644 index 000000000..f6a2b257f --- /dev/null +++ b/pkg/api/meta/managers.go @@ -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 +} diff --git a/pkg/api/meta/names.go b/pkg/api/meta/names.go index 91d489eac..4da094476 100644 --- a/pkg/api/meta/names.go +++ b/pkg/api/meta/names.go @@ -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) } diff --git a/pkg/api/meta/ownerreference.go b/pkg/api/meta/ownerreference.go index 322399d7b..a6b8e3b4c 100644 --- a/pkg/api/meta/ownerreference.go +++ b/pkg/api/meta/ownerreference.go @@ -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 +} diff --git a/pkg/api/meta/ownerreference_test.go b/pkg/api/meta/ownerreference_test.go index a520409a9..588f31761 100644 --- a/pkg/api/meta/ownerreference_test.go +++ b/pkg/api/meta/ownerreference_test.go @@ -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") + } }) } diff --git a/pkg/api/meta/ownership.go b/pkg/api/meta/ownership.go deleted file mode 100644 index 809387cd1..000000000 --- a/pkg/api/meta/ownership.go +++ /dev/null @@ -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 -} diff --git a/pkg/api/meta/reference.go b/pkg/api/meta/reference.go new file mode 100644 index 000000000..3cb6d91a5 --- /dev/null +++ b/pkg/api/meta/reference.go @@ -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"` +} diff --git a/pkg/api/meta/zz_generated.deepcopy.go b/pkg/api/meta/zz_generated.deepcopy.go index cd974dbce..677e7c984 100644 --- a/pkg/api/meta/zz_generated.deepcopy.go +++ b/pkg/api/meta/zz_generated.deepcopy.go @@ -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 +} diff --git a/pkg/api/owner_list_test.go b/pkg/api/owner_list_test.go index 06cc7f0cf..f4b82247a 100644 --- a/pkg/api/owner_list_test.go +++ b/pkg/api/owner_list_test.go @@ -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{}) } diff --git a/pkg/api/owner_status_list_test.go b/pkg/api/owner_status_list_test.go index 61549f974..bbb418e79 100644 --- a/pkg/api/owner_status_list_test.go +++ b/pkg/api/owner_status_list_test.go @@ -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 diff --git a/pkg/api/rbac.go b/pkg/api/rbac.go deleted file mode 100644 index 8e66f53e5..000000000 --- a/pkg/api/rbac.go +++ /dev/null @@ -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, - }, - } -) diff --git a/pkg/api/registry.go b/pkg/api/registry.go new file mode 100644 index 000000000..2106e4ab0 --- /dev/null +++ b/pkg/api/registry.go @@ -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"` +} diff --git a/pkg/api/users_list_test.go b/pkg/api/users_list_test.go index 70bf0b44d..aaf0ac11d 100644 --- a/pkg/api/users_list_test.go +++ b/pkg/api/users_list_test.go @@ -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 diff --git a/pkg/api/users_test.go b/pkg/api/users_test.go index 0efbca3bb..a93a1d9c0 100644 --- a/pkg/api/users_test.go +++ b/pkg/api/users_test.go @@ -1,3 +1,6 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + package api_test import ( diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 2c2ee137f..7b0fe74d5 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -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) { { diff --git a/pkg/cert/ca.go b/pkg/runtime/cert/ca.go similarity index 100% rename from pkg/cert/ca.go rename to pkg/runtime/cert/ca.go diff --git a/pkg/cert/ca_test.go b/pkg/runtime/cert/ca_test.go similarity index 97% rename from pkg/cert/ca_test.go rename to pkg/runtime/cert/ca_test.go index 42d6e2e21..48476ffd3 100644 --- a/pkg/cert/ca_test.go +++ b/pkg/runtime/cert/ca_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package cert diff --git a/pkg/cert/errors.go b/pkg/runtime/cert/errors.go similarity index 100% rename from pkg/cert/errors.go rename to pkg/runtime/cert/errors.go diff --git a/pkg/cert/options.go b/pkg/runtime/cert/options.go similarity index 100% rename from pkg/cert/options.go rename to pkg/runtime/cert/options.go diff --git a/pkg/runtime/client/apply.go b/pkg/runtime/client/apply.go new file mode 100644 index 000000000..0457634a2 --- /dev/null +++ b/pkg/runtime/client/apply.go @@ -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 +} diff --git a/pkg/runtime/client/ignore.go b/pkg/runtime/client/ignore.go new file mode 100644 index 000000000..6a6b4777d --- /dev/null +++ b/pkg/runtime/client/ignore.go @@ -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 +} diff --git a/pkg/runtime/client/ignore_test.go b/pkg/runtime/client/ignore_test.go new file mode 100644 index 000000000..0bae5f433 --- /dev/null +++ b/pkg/runtime/client/ignore_test.go @@ -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) + } +} diff --git a/pkg/runtime/client/patch.go b/pkg/runtime/client/patch.go new file mode 100644 index 000000000..02a2b022e --- /dev/null +++ b/pkg/runtime/client/patch.go @@ -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/ 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/ 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 +} diff --git a/pkg/runtime/client/patch_test.go b/pkg/runtime/client/patch_test.go new file mode 100644 index 000000000..3f1c05099 --- /dev/null +++ b/pkg/runtime/client/patch_test.go @@ -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) + } + }) + } + }) +} diff --git a/pkg/runtime/client/update.go b/pkg/runtime/client/update.go new file mode 100644 index 000000000..0c2f88477 --- /dev/null +++ b/pkg/runtime/client/update.go @@ -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 +} diff --git a/pkg/runtime/client/zz_generated.deepcopy.go b/pkg/runtime/client/zz_generated.deepcopy.go new file mode 100644 index 000000000..1b0df03fa --- /dev/null +++ b/pkg/runtime/client/zz_generated.deepcopy.go @@ -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 +} diff --git a/pkg/configuration/client.go b/pkg/runtime/configuration/client.go similarity index 93% rename from pkg/configuration/client.go rename to pkg/runtime/configuration/client.go index 58aa463ab..e0c7c6088 100644 --- a/pkg/configuration/client.go +++ b/pkg/runtime/configuration/client.go @@ -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 +} diff --git a/pkg/configuration/configuration.go b/pkg/runtime/configuration/configuration.go similarity index 81% rename from pkg/configuration/configuration.go rename to pkg/runtime/configuration/configuration.go index 76dceec66..e4f84bd0b 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/runtime/configuration/configuration.go @@ -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 } diff --git a/pkg/runtime/events/actions.go b/pkg/runtime/events/actions.go new file mode 100644 index 000000000..e9c4e33d9 --- /dev/null +++ b/pkg/runtime/events/actions.go @@ -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" +) diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go new file mode 100644 index 000000000..d39f02a7c --- /dev/null +++ b/pkg/runtime/events/reasons.go @@ -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" +) diff --git a/pkg/runtime/gvk/has_gvk.go b/pkg/runtime/gvk/has_gvk.go new file mode 100644 index 000000000..7fe9d10dc --- /dev/null +++ b/pkg/runtime/gvk/has_gvk.go @@ -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 +} diff --git a/pkg/runtime/gvk/has_gvk_test.go b/pkg/runtime/gvk/has_gvk_test.go new file mode 100644 index 000000000..33c29c5ba --- /dev/null +++ b/pkg/runtime/gvk/has_gvk_test.go @@ -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) + } + }) +} diff --git a/pkg/runtime/handlers/errors.go b/pkg/runtime/handlers/errors.go new file mode 100644 index 000000000..67f9bd651 --- /dev/null +++ b/pkg/runtime/handlers/errors.go @@ -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 +} diff --git a/pkg/runtime/handlers/handlers.go b/pkg/runtime/handlers/handlers.go new file mode 100644 index 000000000..fae5ddd07 --- /dev/null +++ b/pkg/runtime/handlers/handlers.go @@ -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 +} diff --git a/internal/webhook/utils/in_capsule_groups.go b/pkg/runtime/handlers/in_capsule_groups.go similarity index 79% rename from internal/webhook/utils/in_capsule_groups.go rename to pkg/runtime/handlers/in_capsule_groups.go index 376e974a7..a590c8db9 100644 --- a/internal/webhook/utils/in_capsule_groups.go +++ b/pkg/runtime/handlers/in_capsule_groups.go @@ -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 diff --git a/internal/webhook/utils/typed_tenant_handler.go b/pkg/runtime/handlers/typed_tenant.go similarity index 75% rename from internal/webhook/utils/typed_tenant_handler.go rename to pkg/runtime/handlers/typed_tenant.go index 713e73f0a..1884ba378 100644 --- a/internal/webhook/utils/typed_tenant_handler.go +++ b/pkg/runtime/handlers/typed_tenant.go @@ -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 { diff --git a/pkg/runtime/handlers/typed_tenant_ruleset.go b/pkg/runtime/handlers/typed_tenant_ruleset.go new file mode 100644 index 000000000..f4628536f --- /dev/null +++ b/pkg/runtime/handlers/typed_tenant_ruleset.go @@ -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) +} diff --git a/pkg/runtime/handlers/utils.go b/pkg/runtime/handlers/utils.go new file mode 100644 index 000000000..7fdf3ad19 --- /dev/null +++ b/pkg/runtime/handlers/utils.go @@ -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 diff --git a/internal/webhook/webhook.go b/pkg/runtime/handlers/webhook.go similarity index 90% rename from internal/webhook/webhook.go rename to pkg/runtime/handlers/webhook.go index b571183f0..98b574480 100644 --- a/internal/webhook/webhook.go +++ b/pkg/runtime/handlers/webhook.go @@ -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 diff --git a/pkg/indexer/indexer.go b/pkg/runtime/indexers/indexer.go similarity index 81% rename from pkg/indexer/indexer.go rename to pkg/runtime/indexers/indexer.go index f1f6a38bc..2048fce9f 100644 --- a/pkg/indexer/indexer.go +++ b/pkg/runtime/indexers/indexer.go @@ -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" ) diff --git a/pkg/indexer/ingress/hostname_path.go b/pkg/runtime/indexers/ingress/hostname_path.go similarity index 100% rename from pkg/indexer/ingress/hostname_path.go rename to pkg/runtime/indexers/ingress/hostname_path.go diff --git a/pkg/indexer/ingress/utils.go b/pkg/runtime/indexers/ingress/utils.go similarity index 100% rename from pkg/indexer/ingress/utils.go rename to pkg/runtime/indexers/ingress/utils.go diff --git a/pkg/indexer/namespace/namespaces.go b/pkg/runtime/indexers/namespace/namespaces.go similarity index 93% rename from pkg/indexer/namespace/namespaces.go rename to pkg/runtime/indexers/namespace/namespaces.go index ba55ed70d..37a122d50 100644 --- a/pkg/indexer/namespace/namespaces.go +++ b/pkg/runtime/indexers/namespace/namespaces.go @@ -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{} diff --git a/pkg/indexer/resourcepool/claim.go b/pkg/runtime/indexers/resourcepool/claim.go similarity index 100% rename from pkg/indexer/resourcepool/claim.go rename to pkg/runtime/indexers/resourcepool/claim.go diff --git a/pkg/indexer/resourcepool/namespaces.go b/pkg/runtime/indexers/resourcepool/namespaces.go similarity index 100% rename from pkg/indexer/resourcepool/namespaces.go rename to pkg/runtime/indexers/resourcepool/namespaces.go diff --git a/pkg/indexer/tenant/namespaces.go b/pkg/runtime/indexers/tenant/namespaces.go similarity index 100% rename from pkg/indexer/tenant/namespaces.go rename to pkg/runtime/indexers/tenant/namespaces.go diff --git a/pkg/indexer/tenant/owner.go b/pkg/runtime/indexers/tenant/owner.go similarity index 92% rename from pkg/indexer/tenant/owner.go rename to pkg/runtime/indexers/tenant/owner.go index 6d9928c01..f4bd563ba 100644 --- a/pkg/indexer/tenant/owner.go +++ b/pkg/runtime/indexers/tenant/owner.go @@ -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{} diff --git a/pkg/indexer/tenantresource/constants.go b/pkg/runtime/indexers/tenantresource/constants.go similarity index 100% rename from pkg/indexer/tenantresource/constants.go rename to pkg/runtime/indexers/tenantresource/constants.go diff --git a/pkg/indexer/tenantresource/global.go b/pkg/runtime/indexers/tenantresource/global.go similarity index 100% rename from pkg/indexer/tenantresource/global.go rename to pkg/runtime/indexers/tenantresource/global.go diff --git a/pkg/indexer/tenantresource/local.go b/pkg/runtime/indexers/tenantresource/local.go similarity index 100% rename from pkg/indexer/tenantresource/local.go rename to pkg/runtime/indexers/tenantresource/local.go diff --git a/pkg/runtime/predicates/config_change.go b/pkg/runtime/predicates/config_change.go new file mode 100644 index 000000000..7cdb46a0d --- /dev/null +++ b/pkg/runtime/predicates/config_change.go @@ -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) +} diff --git a/pkg/runtime/predicates/config_change_test.go b/pkg/runtime/predicates/config_change_test.go new file mode 100644 index 000000000..308916038 --- /dev/null +++ b/pkg/runtime/predicates/config_change_test.go @@ -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) + } + }) +} diff --git a/pkg/runtime/predicates/labels_matching.go b/pkg/runtime/predicates/labels_matching.go new file mode 100644 index 000000000..bbcfb9f49 --- /dev/null +++ b/pkg/runtime/predicates/labels_matching.go @@ -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}) +} diff --git a/pkg/runtime/predicates/labels_matching_test.go b/pkg/runtime/predicates/labels_matching_test.go new file mode 100644 index 000000000..275bf8f81 --- /dev/null +++ b/pkg/runtime/predicates/labels_matching_test.go @@ -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") + } + }) +} diff --git a/pkg/runtime/predicates/name_matching.go b/pkg/runtime/predicates/name_matching.go new file mode 100644 index 000000000..28f8e1313 --- /dev/null +++ b/pkg/runtime/predicates/name_matching.go @@ -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}) +} diff --git a/pkg/runtime/predicates/name_matching_test.go b/pkg/runtime/predicates/name_matching_test.go new file mode 100644 index 000000000..ad34e1736 --- /dev/null +++ b/pkg/runtime/predicates/name_matching_test.go @@ -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") + } + }) +} diff --git a/pkg/runtime/predicates/promoted_serviceaccount.go b/pkg/runtime/predicates/promoted_serviceaccount.go new file mode 100644 index 000000000..8a035c50c --- /dev/null +++ b/pkg/runtime/predicates/promoted_serviceaccount.go @@ -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 +} diff --git a/pkg/runtime/predicates/promoted_serviceaccount_test.go b/pkg/runtime/predicates/promoted_serviceaccount_test.go new file mode 100644 index 000000000..141dff8a7 --- /dev/null +++ b/pkg/runtime/predicates/promoted_serviceaccount_test.go @@ -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) + } + }) + } +} diff --git a/pkg/runtime/predicates/reconcile_requested.go b/pkg/runtime/predicates/reconcile_requested.go new file mode 100644 index 000000000..9fc7f5272 --- /dev/null +++ b/pkg/runtime/predicates/reconcile_requested.go @@ -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 +} diff --git a/pkg/runtime/predicates/reconcile_requested_test.go b/pkg/runtime/predicates/reconcile_requested_test.go new file mode 100644 index 000000000..ecf0273b0 --- /dev/null +++ b/pkg/runtime/predicates/reconcile_requested_test.go @@ -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) + } + }) + } +} diff --git a/pkg/runtime/predicates/updated_labels.go b/pkg/runtime/predicates/updated_labels.go new file mode 100644 index 000000000..dfa7ba08c --- /dev/null +++ b/pkg/runtime/predicates/updated_labels.go @@ -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()) +} diff --git a/pkg/runtime/predicates/updated_labels_test.go b/pkg/runtime/predicates/updated_labels_test.go new file mode 100644 index 000000000..3a82e41ee --- /dev/null +++ b/pkg/runtime/predicates/updated_labels_test.go @@ -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) + } + }) + } +} diff --git a/pkg/runtime/predicates/utils.go b/pkg/runtime/predicates/utils.go new file mode 100644 index 000000000..e1cef2f01 --- /dev/null +++ b/pkg/runtime/predicates/utils.go @@ -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 +} diff --git a/pkg/runtime/predicates/utils_test.go b/pkg/runtime/predicates/utils_test.go new file mode 100644 index 000000000..a00e8dbeb --- /dev/null +++ b/pkg/runtime/predicates/utils_test.go @@ -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, + ) + } + }) + } +} diff --git a/pkg/runtime/sanitize/object.go b/pkg/runtime/sanitize/object.go new file mode 100644 index 000000000..3382c46ea --- /dev/null +++ b/pkg/runtime/sanitize/object.go @@ -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 +} diff --git a/pkg/runtime/sanitize/object_test.go b/pkg/runtime/sanitize/object_test.go new file mode 100644 index 000000000..e04f7da33 --- /dev/null +++ b/pkg/runtime/sanitize/object_test.go @@ -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 +} diff --git a/pkg/runtime/sanitize/options.go b/pkg/runtime/sanitize/options.go new file mode 100644 index 000000000..400082d40 --- /dev/null +++ b/pkg/runtime/sanitize/options.go @@ -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, + } +} diff --git a/pkg/runtime/sanitize/options_test.go b/pkg/runtime/sanitize/options_test.go new file mode 100644 index 000000000..ff52a3ff0 --- /dev/null +++ b/pkg/runtime/sanitize/options_test.go @@ -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") + } +} diff --git a/pkg/runtime/sanitize/unstructured.go b/pkg/runtime/sanitize/unstructured.go new file mode 100644 index 000000000..31ad54770 --- /dev/null +++ b/pkg/runtime/sanitize/unstructured.go @@ -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") + } +} diff --git a/pkg/runtime/sanitize/unstructured_test.go b/pkg/runtime/sanitize/unstructured_test.go new file mode 100644 index 000000000..d07395251 --- /dev/null +++ b/pkg/runtime/sanitize/unstructured_test.go @@ -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") + } +} diff --git a/pkg/runtime/selectors/combine.go b/pkg/runtime/selectors/combine.go new file mode 100644 index 000000000..c032db12b --- /dev/null +++ b/pkg/runtime/selectors/combine.go @@ -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 +} diff --git a/pkg/runtime/selectors/combine_test.go b/pkg/runtime/selectors/combine_test.go new file mode 100644 index 000000000..c5dd14ed5 --- /dev/null +++ b/pkg/runtime/selectors/combine_test.go @@ -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") + } + }) +} diff --git a/pkg/template/fast.go b/pkg/template/fast.go index dbd26edf2..4b611aa1c 100644 --- a/pkg/template/fast.go +++ b/pkg/template/fast.go @@ -4,32 +4,36 @@ 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)) } @@ -37,16 +41,72 @@ func TemplateForTenantAndNamespace(template string, tnt *capsulev1beta2.Tenant, }) } -// 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 +} diff --git a/pkg/template/fast_test.go b/pkg/template/fast_test.go index 666af61f9..e4c57a07d 100644 --- a/pkg/template/fast_test.go +++ b/pkg/template/fast_test.go @@ -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,11 +153,13 @@ 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) @@ -95,10 +167,12 @@ func TestTemplateForTenantAndNamespace_NoDelimiters_ReturnsInput(t *testing.T) { } 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,8 +234,10 @@ 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}}", @@ -165,7 +245,7 @@ func TestTemplateForTenantAndNamespaceMap_TransformsValuesWithDelimiters(t *test "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,8 +259,10 @@ 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 }}", @@ -188,7 +270,7 @@ func TestTemplateForTenantAndNamespaceMap_MixedKeys(t *testing.T) { "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,14 +284,16 @@ 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) @@ -217,11 +301,13 @@ func TestTemplateForTenantAndNamespaceMap_UnknownKeyBecomesEmpty(t *testing.T) { } 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. + }) +} diff --git a/pkg/template/funcmap.go b/pkg/template/funcmap.go new file mode 100644 index 000000000..2d4f46276 --- /dev/null +++ b/pkg/template/funcmap.go @@ -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 +} diff --git a/pkg/template/reference.go b/pkg/template/reference.go new file mode 100644 index 000000000..847d13262 --- /dev/null +++ b/pkg/template/reference.go @@ -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 +} diff --git a/pkg/template/reference_context.go b/pkg/template/reference_context.go new file mode 100644 index 000000000..ef2dd266a --- /dev/null +++ b/pkg/template/reference_context.go @@ -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 +} diff --git a/pkg/template/types.go b/pkg/template/types.go new file mode 100644 index 000000000..f041d7362 --- /dev/null +++ b/pkg/template/types.go @@ -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" +) diff --git a/pkg/template/unstructured.go b/pkg/template/unstructured.go new file mode 100644 index 000000000..7b4a0a662 --- /dev/null +++ b/pkg/template/unstructured.go @@ -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 +} diff --git a/pkg/template/unstructured_test.go b/pkg/template/unstructured_test.go new file mode 100644 index 000000000..98e44d116 --- /dev/null +++ b/pkg/template/unstructured_test.go @@ -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()) + } +} diff --git a/pkg/template/zz_generated.deepcopy.go b/pkg/template/zz_generated.deepcopy.go new file mode 100644 index 000000000..aabd24504 --- /dev/null +++ b/pkg/template/zz_generated.deepcopy.go @@ -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 +} diff --git a/pkg/utils/tenant/get_by.go b/pkg/tenant/get_by.go similarity index 97% rename from pkg/utils/tenant/get_by.go rename to pkg/tenant/get_by.go index 2c4e04d35..4ac9a600a 100644 --- a/pkg/utils/tenant/get_by.go +++ b/pkg/tenant/get_by.go @@ -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( diff --git a/pkg/utils/tenant/metadata_test.go b/pkg/tenant/metadata_test.go similarity index 98% rename from pkg/utils/tenant/metadata_test.go rename to pkg/tenant/metadata_test.go index e5e39bb63..7cef972ba 100644 --- a/pkg/utils/tenant/metadata_test.go +++ b/pkg/tenant/metadata_test.go @@ -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 diff --git a/pkg/utils/tenant/metdata.go b/pkg/tenant/metdata.go similarity index 93% rename from pkg/utils/tenant/metdata.go rename to pkg/tenant/metdata.go index 273ecf02b..f260a1d71 100644 --- a/pkg/utils/tenant/metdata.go +++ b/pkg/tenant/metdata.go @@ -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, ",") diff --git a/pkg/utils/tenant/owned.go b/pkg/tenant/owned.go similarity index 86% rename from pkg/utils/tenant/owned.go rename to pkg/tenant/owned.go index 31311aff8..e24ef4be4 100644 --- a/pkg/utils/tenant/owned.go +++ b/pkg/tenant/owned.go @@ -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( diff --git a/pkg/utils/tenant/owner_reference.go b/pkg/tenant/owner_reference.go similarity index 100% rename from pkg/utils/tenant/owner_reference.go rename to pkg/tenant/owner_reference.go diff --git a/pkg/utils/tenant/owner_reference_test.go b/pkg/tenant/owner_reference_test.go similarity index 98% rename from pkg/utils/tenant/owner_reference_test.go rename to pkg/tenant/owner_reference_test.go index b5e63c05e..ad1edb551 100644 --- a/pkg/utils/tenant/owner_reference_test.go +++ b/pkg/tenant/owner_reference_test.go @@ -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) { diff --git a/pkg/tenant/owners.go b/pkg/tenant/owners.go new file mode 100644 index 000000000..6773bc6b8 --- /dev/null +++ b/pkg/tenant/owners.go @@ -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 +} diff --git a/pkg/tenant/owners_test.go b/pkg/tenant/owners_test.go new file mode 100644 index 000000000..705aaa275 --- /dev/null +++ b/pkg/tenant/owners_test.go @@ -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) + } +} diff --git a/pkg/tenant/rules.go b/pkg/tenant/rules.go new file mode 100644 index 000000000..5f975634f --- /dev/null +++ b/pkg/tenant/rules.go @@ -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 +} diff --git a/pkg/tenant/template.go b/pkg/tenant/template.go new file mode 100644 index 000000000..5d779a17a --- /dev/null +++ b/pkg/tenant/template.go @@ -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 +} diff --git a/pkg/tenant/template_test.go b/pkg/tenant/template_test.go new file mode 100644 index 000000000..d940c368b --- /dev/null +++ b/pkg/tenant/template_test.go @@ -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) + } +} diff --git a/pkg/utils/tenant/types.go b/pkg/tenant/types.go similarity index 100% rename from pkg/utils/tenant/types.go rename to pkg/tenant/types.go diff --git a/pkg/utils/users/is_admin_user.go b/pkg/users/is_admin_user.go similarity index 100% rename from pkg/utils/users/is_admin_user.go rename to pkg/users/is_admin_user.go diff --git a/pkg/utils/users/is_capsule_user.go b/pkg/users/is_capsule_user.go similarity index 96% rename from pkg/utils/users/is_capsule_user.go rename to pkg/users/is_capsule_user.go index 6c9541cf7..317dcfefe 100644 --- a/pkg/utils/users/is_capsule_user.go +++ b/pkg/users/is_capsule_user.go @@ -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( diff --git a/pkg/utils/users/is_tenant_owner.go b/pkg/users/is_tenant_owner.go similarity index 96% rename from pkg/utils/users/is_tenant_owner.go rename to pkg/users/is_tenant_owner.go index 18c2a9f76..99fa61af0 100644 --- a/pkg/utils/users/is_tenant_owner.go +++ b/pkg/users/is_tenant_owner.go @@ -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( diff --git a/pkg/utils/users/serviceaccounts.go b/pkg/users/serviceaccounts.go similarity index 95% rename from pkg/utils/users/serviceaccounts.go rename to pkg/users/serviceaccounts.go index da9a54df6..1cbc1cde8 100644 --- a/pkg/utils/users/serviceaccounts.go +++ b/pkg/users/serviceaccounts.go @@ -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 diff --git a/pkg/utils/users/user_group.go b/pkg/users/user_group.go similarity index 100% rename from pkg/utils/users/user_group.go rename to pkg/users/user_group.go diff --git a/pkg/utils/users/user_group_test.go b/pkg/users/user_group_test.go similarity index 91% rename from pkg/utils/users/user_group_test.go rename to pkg/users/user_group_test.go index 1896c941d..ab0d020a1 100644 --- a/pkg/utils/users/user_group_test.go +++ b/pkg/users/user_group_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package users diff --git a/pkg/utils/errors_test.go b/pkg/utils/errors_test.go new file mode 100644 index 000000000..27b7958c4 --- /dev/null +++ b/pkg/utils/errors_test.go @@ -0,0 +1,63 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package utils_test + +import ( + "errors" + "fmt" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + "github.com/projectcapsule/capsule/pkg/utils" +) + +func TestIsUnsupportedAPI_NoKindMatchError(t *testing.T) { + err := &meta.NoKindMatchError{} + + if !utils.IsUnsupportedAPI(err) { + t.Fatalf("expected true for NoKindMatchError") + } +} + +func TestIsUnsupportedAPI_GroupDiscoveryFailed(t *testing.T) { + err := &discovery.ErrGroupDiscoveryFailed{} + + if !utils.IsUnsupportedAPI(err) { + t.Fatalf("expected true for ErrGroupDiscoveryFailed") + } +} + +func TestIsUnsupportedAPI_ResourceDiscoveryFailed(t *testing.T) { + err := &apiutil.ErrResourceDiscoveryFailed{} + + if !utils.IsUnsupportedAPI(err) { + t.Fatalf("expected true for ErrResourceDiscoveryFailed") + } +} + +func TestIsUnsupportedAPI_WrappedError(t *testing.T) { + base := &meta.NoKindMatchError{} + err := fmt.Errorf("wrapped: %w", base) + + if !utils.IsUnsupportedAPI(err) { + t.Fatalf("expected true for wrapped NoKindMatchError") + } +} + +func TestIsUnsupportedAPI_OtherError(t *testing.T) { + err := errors.New("some other error") + + if utils.IsUnsupportedAPI(err) { + t.Fatalf("expected false for unrelated error") + } +} + +func TestIsUnsupportedAPI_NilError(t *testing.T) { + if utils.IsUnsupportedAPI(nil) { + t.Fatalf("expected false for nil error") + } +} diff --git a/pkg/utils/hashes_test.go b/pkg/utils/hashes_test.go index 1a81f435a..38ecbd014 100644 --- a/pkg/utils/hashes_test.go +++ b/pkg/utils/hashes_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package utils_test diff --git a/pkg/utils/maps_test.go b/pkg/utils/maps_test.go index 73ccac127..ff049dbea 100644 --- a/pkg/utils/maps_test.go +++ b/pkg/utils/maps_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 Project Capsule Authors +// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package utils diff --git a/pkg/utils/namespace_selector_test.go b/pkg/utils/namespace_selector_test.go new file mode 100644 index 000000000..69795a813 --- /dev/null +++ b/pkg/utils/namespace_selector_test.go @@ -0,0 +1,175 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package utils_test + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/projectcapsule/capsule/pkg/utils" +) + +func TestIsNamespaceSelectedBySelector_NilSelectorMatchesAll(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{"env": "prod"}, + }, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !ok { + t.Fatalf("expected match=true for nil selector, got false") + } +} + +func TestIsNamespaceSelectedBySelector_MatchLabels(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{"env": "prod", "team": "a"}, + }, + } + + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, selector) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !ok { + t.Fatalf("expected match=true, got false") + } +} + +func TestIsNamespaceSelectedBySelector_NoMatchLabels(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{"env": "dev"}, + }, + } + + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, selector) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ok { + t.Fatalf("expected match=false, got true") + } +} + +func TestIsNamespaceSelectedBySelector_MatchExpressions_In(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{"tier": "backend"}, + }, + } + + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tier", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"backend", "worker"}, + }, + }, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, selector) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !ok { + t.Fatalf("expected match=true, got false") + } +} + +func TestIsNamespaceSelectedBySelector_MatchExpressions_NotIn(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{"tier": "frontend"}, + }, + } + + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tier", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"backend", "worker"}, + }, + }, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, selector) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !ok { + t.Fatalf("expected match=true (frontend not in backend/worker), got false") + } +} + +func TestIsNamespaceSelectedBySelector_EmptyLabels_NoMatch(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: nil, + }, + } + + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, selector) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ok { + t.Fatalf("expected match=false with missing label, got true") + } +} + +func TestIsNamespaceSelectedBySelector_InvalidSelectorReturnsError(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{"env": "prod"}, + }, + } + + // Invalid: In operator requires non-empty Values + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "env", + Operator: metav1.LabelSelectorOpIn, + Values: nil, + }, + }, + } + + ok, err := utils.IsNamespaceSelectedBySelector(ns, selector) + if err == nil { + t.Fatalf("expected error, got nil") + } + if ok { + t.Fatalf("expected match=false on error, got true") + } +} diff --git a/pkg/utils/node_selector_test.go b/pkg/utils/node_selector_test.go new file mode 100644 index 000000000..726959988 --- /dev/null +++ b/pkg/utils/node_selector_test.go @@ -0,0 +1,118 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package utils_test + +import ( + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/utils" +) + +func TestBuildNodeSelector_NilAnnotationsInitializesMap(t *testing.T) { + tnt := &capsulev1beta2.Tenant{} + tnt.Spec.NodeSelector = map[string]string{ + "disktype": "ssd", + } + + out := utils.BuildNodeSelector(tnt, nil) + + if out == nil { + t.Fatalf("expected non-nil map") + } + + got, ok := out[utils.NodeSelectorAnnotation] + if !ok { + t.Fatalf("expected %q annotation to be set", utils.NodeSelectorAnnotation) + } + if got != "disktype=ssd" { + t.Fatalf("unexpected annotation value: got %q want %q", got, "disktype=ssd") + } +} + +func TestBuildNodeSelector_ExistingAnnotationsPreserved(t *testing.T) { + tnt := &capsulev1beta2.Tenant{} + tnt.Spec.NodeSelector = map[string]string{ + "disktype": "ssd", + } + + in := map[string]string{ + "keep": "me", + } + + out := utils.BuildNodeSelector(tnt, in) + + if out["keep"] != "me" { + t.Fatalf("expected existing annotation key to be preserved") + } + if out[utils.NodeSelectorAnnotation] != "disktype=ssd" { + t.Fatalf("unexpected node selector annotation: got %q", out[utils.NodeSelectorAnnotation]) + } +} + +func TestBuildNodeSelector_SortsSelectorsDeterministically(t *testing.T) { + tnt := &capsulev1beta2.Tenant{} + // Intentionally unordered map + tnt.Spec.NodeSelector = map[string]string{ + "b": "2", + "a": "1", + "c": "3", + } + + out := utils.BuildNodeSelector(tnt, map[string]string{}) + + got := out[utils.NodeSelectorAnnotation] + want := "a=1,b=2,c=3" + if got != want { + t.Fatalf("expected deterministic sorted annotation: got %q want %q", got, want) + } +} + +func TestBuildNodeSelector_EmptyNodeSelectorSetsEmptyAnnotation(t *testing.T) { + tnt := &capsulev1beta2.Tenant{} + tnt.Spec.NodeSelector = map[string]string{} + + out := utils.BuildNodeSelector(tnt, map[string]string{}) + + got, ok := out[utils.NodeSelectorAnnotation] + if !ok { + t.Fatalf("expected %q annotation to be present even for empty selector", utils.NodeSelectorAnnotation) + } + if got != "" { + t.Fatalf("expected empty annotation value, got %q", got) + } +} + +func TestBuildNodeSelector_NilNodeSelectorSetsEmptyAnnotation(t *testing.T) { + tnt := &capsulev1beta2.Tenant{} + tnt.Spec.NodeSelector = nil + + out := utils.BuildNodeSelector(tnt, map[string]string{}) + + got, ok := out[utils.NodeSelectorAnnotation] + if !ok { + t.Fatalf("expected %q annotation to be present even for nil selector", utils.NodeSelectorAnnotation) + } + if got != "" { + t.Fatalf("expected empty annotation value, got %q", got) + } +} + +func TestBuildNodeSelector_ReturnsSameMapInstance(t *testing.T) { + tnt := &capsulev1beta2.Tenant{} + tnt.Spec.NodeSelector = map[string]string{"a": "1"} + + in := map[string]string{"x": "y"} + out := utils.BuildNodeSelector(tnt, in) + + // BuildNodeSelector mutates and returns the same map reference + if &in == &out { + // Note: maps are reference types; direct pointer comparison isn't meaningful. + } + if len(out) != 2 { + t.Fatalf("expected 2 keys in resulting map, got %d", len(out)) + } + if out["x"] != "y" { + t.Fatalf("expected original key to remain") + } +} diff --git a/pkg/utils/tenant/owners.go b/pkg/utils/tenant/owners.go deleted file mode 100644 index 9ef8dd3dd..000000000 --- a/pkg/utils/tenant/owners.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tenant - -import ( - "fmt" - - capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" -) - -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 -}