From ab1f6fa60ee17c67d71714d7888f389e44b437b9 Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:14:50 -0300 Subject: [PATCH 1/7] feat: adding service webhook special case --- .github/copilot-instructions.md | 6 +- api/v1alpha1/clusterresourcequota_types.go | 13 +- charts/pac-quota-controller/Chart.yaml | 4 +- charts/pac-quota-controller/README.md.gotmpl | 28 + ....powerapp.cloud_clusterresourcequotas.yaml | 9 +- .../validatingwebhookconfiguration.yaml | 26 + charts/pac-quota-controller/values.yaml | 23 +- docs/object-count-feature-plan.md | 123 +++ .../clusterresourcequota_controller.go | 49 +- pkg/kubernetes/services/service.go | 93 ++ pkg/kubernetes/services/service_suite_test.go | 12 + pkg/kubernetes/services/service_test.go | 139 +++ pkg/kubernetes/services/types.go | 17 + pkg/kubernetes/storage/types.go | 16 - pkg/kubernetes/usage/usage.go | 8 + ...ServiceResourceCalculatorInterface_mock.go | 67 ++ pkg/webhook/server/server.go | 7 + .../v1alpha1/clusterresourcequota_webhook.go | 98 +- pkg/webhook/v1alpha1/pod_webhook.go | 11 +- pkg/webhook/v1alpha1/pod_webhook_test.go | 69 +- pkg/webhook/v1alpha1/service_webhook.go | 222 +++++ pkg/webhook/v1alpha1/service_webhook_test.go | 903 ++++++++++++++++++ pkg/webhook/v1alpha1/webhook_utils.go | 45 +- test/e2e/e2e_suite_test.go | 10 + test/e2e/service_webhook_test.go | 274 ++++++ 25 files changed, 2162 insertions(+), 110 deletions(-) create mode 100644 docs/object-count-feature-plan.md create mode 100644 pkg/kubernetes/services/service.go create mode 100644 pkg/kubernetes/services/service_suite_test.go create mode 100644 pkg/kubernetes/services/service_test.go create mode 100644 pkg/kubernetes/services/types.go create mode 100644 pkg/mocks/ServiceResourceCalculatorInterface_mock.go create mode 100644 pkg/webhook/v1alpha1/service_webhook.go create mode 100644 pkg/webhook/v1alpha1/service_webhook_test.go create mode 100644 test/e2e/service_webhook_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c15dbc7..da11830 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -54,7 +54,11 @@ The `pac-quota-controller` is a Kubernetes controller that extends Kubernetes wi 10. **Cert-Manager:** - Cert-manager is used for webhook certificate management. The Helm chart includes options to install cert-manager or use an existing installation. -11. **Instruction Maintenance:** After interactions where new project conventions, critical file paths, or development preferences are established or significantly clarified, I (GitHub Copilot) should be mindful of these changes. If these changes are persistent and generally applicable, I should suggest or, if requested, directly update this `copilot-instructions.md` file to ensure it remains current and accurately reflects the project's context. The user may also explicitly request updates to this file. +12. **Feature Planning & Documentation:** + - For major features (such as the object count support for core/extended Kubernetes resources), maintain a detailed, step-by-step implementation plan in a Markdown file. + - Always follow and update this plan as the implementation progresses. + - Whenever the plan or project conventions evolve, update both the plan and this `copilot-instructions.md` to ensure alignment and accurate documentation. + - After finishing the implementation of the feature, cleanup the document from the docs folder. ## Workflow for Changes diff --git a/api/v1alpha1/clusterresourcequota_types.go b/api/v1alpha1/clusterresourcequota_types.go index 52ee746..2b0310a 100644 --- a/api/v1alpha1/clusterresourcequota_types.go +++ b/api/v1alpha1/clusterresourcequota_types.go @@ -26,11 +26,12 @@ type ResourceList corev1.ResourceList // ResourceQuotaStatus defines the enforced hard limits and observed use. type ResourceQuotaStatus struct { - // Hard is the set of enforced hard limits for each named resource. + // Hard is the set of enforced hard limits for each named resource (see ClusterResourceQuotaSpec for examples). // +optional Hard ResourceList `json:"hard,omitempty"` // Used is the current observed total usage of the resource in the namespace. + // For object count quotas, this is the current count of each resource type (e.g., pods, services.loadbalancers, ingresses.nginx, etc.). // +optional Used ResourceList `json:"used,omitempty"` } @@ -48,9 +49,13 @@ type ResourceQuotaStatusByNamespace struct { type ClusterResourceQuotaSpec struct { // Hard is the set of desired hard limits for each named resource. // For example: - // 'pods': '10' - // 'requests.cpu': '1' - // 'requests.memory': 1Gi + // 'pods': '10' (Pod count) + // 'services': '5' (Service count) + // 'services.loadbalancers': '2' (Service type=LoadBalancer count) + // 'ingresses': '3' (Ingress count) + // 'configmaps': '20' (ConfigMap count) + // ...and so on for all supported native and extended resource types. + // See documentation for the full list of supported resource keys. // +optional Hard ResourceList `json:"hard,omitempty"` diff --git a/charts/pac-quota-controller/Chart.yaml b/charts/pac-quota-controller/Chart.yaml index 8b45be4..f59d73e 100644 --- a/charts/pac-quota-controller/Chart.yaml +++ b/charts/pac-quota-controller/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: pac-quota-controller description: A Helm chart for PAC Quota Controller - Managing cluster resource quotas across namespaces type: application -version: 0.1.2 -appVersion: "0.1.2" +version: 0.2.0 +appVersion: "0.2.0" maintainers: - name: PowerHome url: https://github.com/powerhome diff --git a/charts/pac-quota-controller/README.md.gotmpl b/charts/pac-quota-controller/README.md.gotmpl index b7293ea..19f1874 100644 --- a/charts/pac-quota-controller/README.md.gotmpl +++ b/charts/pac-quota-controller/README.md.gotmpl @@ -14,12 +14,40 @@ helm install pac-quota-controller oci://ghcr.io/powerhome/pac-quota-controller --version -n pac-quota-controller-system --create-namespace ``` + ## Introduction This chart bootstraps a [PAC Quota Controller](https://github.com/powerhome/pac-quota-controller) deployment on a [Kubernetes](https://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. The PAC Quota Controller extends Kubernetes with a ClusterResourceQuota custom resource that allows defining resource quotas that span multiple namespaces. +### Object Count Quotas (Native & Extended Resources) + +You can specify object count quotas for native and extended Kubernetes resources using the `hard` field in the ClusterResourceQuota spec. Examples: + +```yaml +spec: + hard: + pods: "10" # Pod count + services: "5" # Service count + services.loadbalancers: "2" # Service type=LoadBalancer count + ingresses: "3" # Ingress count + configmaps: "20" # ConfigMap count + # ...and so on for all supported resource types +``` + +Supported extended resource keys include: + +- `services.loadbalancers` (Service objects with type=LoadBalancer) +- `services.nodeports` (Service objects with type=NodePort) +- `ingresses` (Ingress objects) +- `services.externalname` (Service objects with type=ExternalName) +- `services.clusterip` (Service objects with type=ClusterIP) + +Subtype quotas (e.g., `services.loadbalancers`) cannot exceed the total for the parent resource (e.g., `services`). + +Custom CRDs are not supported for object count quotas. + ### Container Images This chart can use container images from GitHub Container Registry: diff --git a/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml b/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml index 6c0338d..ab2e911 100755 --- a/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml +++ b/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml @@ -59,9 +59,12 @@ spec: description: |- Hard is the set of desired hard limits for each named resource. For example: - 'pods': '10' - 'requests.cpu': '1' - 'requests.memory': 1Gi + 'pods': '10' (Pod count) + 'services': '5' (Service count) + 'services.loadbalancers': '2' (Service type=LoadBalancer count) + 'ingresses': '3' (Ingress count) + 'configmaps': '20' (ConfigMap count) + ...and so on for all supported native and extended resource types. type: object namespaceSelector: description: |- diff --git a/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml b/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml index 8841d43..f0a9a5c 100644 --- a/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml +++ b/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml @@ -114,4 +114,30 @@ webhooks: {{- range (include "pacQuota.excludedNamespacesList" . | splitList " ") }} - {{ . | quote }} {{- end }} + - name: vservice-v1alpha1.powerapp.cloud + admissionReviewVersions: ["v1"] + sideEffects: None + failurePolicy: Fail + timeoutSeconds: 30 + clientConfig: + {{- if not .Values.certmanager.enable }} + caBundle: {{ .Values.webhook.customTLS.caBundle }} + {{- end }} + service: + name: pac-quota-controller-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate--v1-service + rules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["services"] + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: NotIn + values: + {{- range (include "pacQuota.excludedNamespacesList" . | splitList " ") }} + - {{ . | quote }} + {{- end }} {{- end }} diff --git a/charts/pac-quota-controller/values.yaml b/charts/pac-quota-controller/values.yaml index e89c162..3030e7f 100644 --- a/charts/pac-quota-controller/values.yaml +++ b/charts/pac-quota-controller/values.yaml @@ -1,5 +1,26 @@ controllerManager: - # Optionally specify imagePullSecrets for pulling private images + # + # Object Count Quotas (Native & Extended Resources) + # + # You can specify object count quotas for native and extended Kubernetes resources using the 'hard' field in the ClusterResourceQuota spec. + # Examples: + # + # hard: + # pods: "10" # Pod count + # services: "5" # Service count + # services.loadbalancers: "2" # Service type=LoadBalancer count + # ingresses: "3" # Ingress count + # configmaps: "20" # ConfigMap count + # # ...and so on for all supported resource types + # + # Supported extended resource keys include: + # - services.loadbalancers (Service objects with type=LoadBalancer) + # - services.nodeports (Service objects with type=NodePort) + # - ingresses (Ingress objects) + # + # Subtype quotas (e.g., services.loadbalancers) cannot exceed the total for the parent resource (e.g., services). + # + # Custom CRDs are not supported for object count quotas. # imagePullSecrets: # - name: ghcr-creds replicas: 1 diff --git a/docs/object-count-feature-plan.md b/docs/object-count-feature-plan.md new file mode 100644 index 0000000..d75c802 --- /dev/null +++ b/docs/object-count-feature-plan.md @@ -0,0 +1,123 @@ +# ClusterResourceQuota Object Count Feature Implementation Plan + +## Overview + +This document tracks the step-by-step implementation plan for adding object count support for core and extended native Kubernetes resources to the ClusterResourceQuota (CRQ) controller. The plan is broken down into the smallest actionable steps, with clarifications and requirements noted. + +## Rules & Principles + +- **No assumptions:** Ask for clarification when needed. +- **No core structure changes:** Follow existing project patterns. +- **No code repetition:** Reuse logic and abstractions. +- **Interfaces & structures:** Use interfaces for testability and maintainability. +- **No custom CRDs:** Only native/extended Kubernetes resources. +- **Testing:** Strict unit and e2e coverage. + +## Step-by-Step Plan + +### 0. Planning & Tracking + +- [ ] Create this implementation plan as a Markdown file in the repo (`docs/object-count-feature-plan.md`). +- [ ] Update the plan as work progresses. + +### 1. Resource Inventory + +### Native Kubernetes Resource Types (for object counting) + +- Pod +- PersistentVolumeClaim (PVC) +- Service +- ConfigMap +- Secret +- ReplicationController +- ReplicaSet +- Deployment +- StatefulSet +- DaemonSet +- Job +- CronJob +- EndpointSlice +- Endpoints +- Ingress +- ServiceAccount +- Lease +- Event + +#### Extended/Subtype Resources + +##### Explicit Extended Resource Types (for object counting) + +- services.loadbalancers (Service objects with type=LoadBalancer) +- services.nodeports (Service objects with type=NodePort) +- ingresses (Ingress objects) +- services.externalname (Service objects with type=ExternalName) +- services.clusterip (Service objects with type=ClusterIP) + +**Note:** These keys are for quota specification and status reporting. The implementation should ensure that subtype counts do not exceed the total for the parent resource (e.g., services.loadbalancers ≤ services). + +**Note:** Custom CRDs are explicitly excluded. + +User will trim or adjust this list as needed before implementation. + +### 2. API & CRD Changes + +- [ ] Update the CRQ API spec to allow specifying object count quotas for the selected resources. +- [ ] Update CRD YAML in Helm chart. +- [ ] Update Helm chart documentation (`README.md.gotmpl`, `values.yaml`). +- [ ] Run `make generate` and `make helm-docs`. + +### 3. Controller Logic + +- [ ] Implement logic to count objects for each supported resource type across namespaces. +- [ ] Update reconciliation loop to aggregate and update usage in CRQ status. +- [ ] Ensure code is modular and testable (interfaces, etc.). +- [ ] Add/extend unit tests for new logic. + +### 4. Admission Webhook + +- [ ] Update webhook to validate create/update requests for supported resources against CRQ limits. +- [ ] Implement live calculation to block/prevent over-quota deployments. +- [ ] Add/extend unit tests for webhook logic. + +### 5. Controller Watches + +- [ ] Update controller to watch for changes to the new resource types (controller-gen). +- [ ] Ensure watches are efficient and follow project patterns. + +### 6. Helm Chart & Docs + +- [ ] Update Helm chart templates and documentation for new CRQ fields and behaviors. +- [ ] Ensure CRD, RBAC, and values are up-to-date. +- [ ] Run `make helm-docs` and `make helm-lint`. + +### 7. Testing + +- [ ] Add/extend unit tests for all new logic (controller, webhook, utils). +- [ ] List use-cases for e2e tests in this plan (before implementation). +- [ ] Implement e2e tests for all critical scenarios. +- [ ] Ensure all tests pass (`make test`, `make test-e2e`). + +### 8. Review & Finalization + +- [ ] Review code for adherence to project principles. +- [ ] Update this plan and project documentation as needed. +- [ ] Prepare for PR review (conventional commits, detailed PR description). + +## Clarifications Needed + +### Clarifications (2025-09-18) + +- **Resource List:** Support all native Kubernetes resources (core and extended). User will trim the list as needed after initial implementation. +- **Scope:** Object counting is both namespace-scoped and cluster-scoped. Blocking logic is based on cluster-scope, as in current CRQ usage. +- **Extended Resources:** For resources like Service type=LoadBalancer, support both total and subtype (e.g., max services, max loadbalancers). Validation should ensure subtypes do not exceed total. +- **Active Objects:** Only active objects are counted. For pods, this is already implemented (terminal pods are excluded). For other resources, count all existing objects unless otherwise specified by Kubernetes conventions. +- **Performance:** No special performance or scalability requirements for large clusters. + +--- + +## Use-Case List for E2E Tests (to be completed before implementation) + +- [ ] To be filled after resource list and API finalized. + +--- +*This plan will be updated as the implementation progresses and as clarifications are provided.* diff --git a/internal/controller/clusterresourcequota_controller.go b/internal/controller/clusterresourcequota_controller.go index 00942bf..7af4692 100644 --- a/internal/controller/clusterresourcequota_controller.go +++ b/internal/controller/clusterresourcequota_controller.go @@ -26,7 +26,9 @@ import ( quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/pod" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/services" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/storage" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" @@ -107,6 +109,7 @@ type ClusterResourceQuotaReconciler struct { crqClient quota.CRQClientInterface ComputeCalculator *pod.PodResourceCalculator StorageCalculator *storage.StorageResourceCalculator + ServiceCalculator services.ServiceResourceCalculatorInterface ExcludeNamespaceLabelKey string ExcludedNamespaces []string } @@ -178,12 +181,17 @@ func (r *ClusterResourceQuotaReconciler) Reconcile(ctx context.Context, req ctrl } log.Info("Found namespaces matching selection criteria", "count", len(selectedNamespaces), "namespaces", selectedNamespaces) + fmt.Printf("[DEBUG] selectedNamespaces after selector: %#v\n", selectedNamespaces) // Calculate aggregated resource usage across all selected namespaces totalUsage, usageByNamespace := r.calculateAndAggregateUsage(ctx, crq, selectedNamespaces) // Update the status of the ClusterResourceQuota if err := r.updateStatus(ctx, crq, totalUsage, usageByNamespace); err != nil { + if errors.IsNotFound(err) { + log.Info("CRQ not found during status update, likely deleted. Skipping status update.", "name", crq.Name) + return ctrl.Result{}, nil + } log.Error(err, "Failed to update ClusterResourceQuota status") return ctrl.Result{}, err } @@ -280,6 +288,11 @@ func (r *ClusterResourceQuotaReconciler) calculateAndAggregateUsage( } for _, nsName := range namespaces { + // If nsName is empty, skip usage calculation for this entry + if nsName == "" { + log.Info("Skipping usage calculation for empty namespace name") + continue + } var currentUsage resource.Quantity // Dispatch to the correct calculation function based on the resource type @@ -323,12 +336,23 @@ func (r *ClusterResourceQuotaReconciler) calculateAndAggregateUsage( } // calculateObjectCount calculates the usage for object count quotas. -func (r *ClusterResourceQuotaReconciler) calculateObjectCount(_ context.Context, ns string, resourceName corev1.ResourceName) resource.Quantity { - // TODO: Implement listing and counting for the specific object type (e.g., Pods, Services). - // This will involve creating a client.ObjectList for the correct type and listing - // it with a namespace filter. - log.Info("Placeholder: Calculating object count", "resource", resourceName, "namespace", ns) - return resource.MustParse("0") +func (r *ClusterResourceQuotaReconciler) calculateObjectCount(ctx context.Context, ns string, resourceName corev1.ResourceName) resource.Quantity { + switch resourceName { + case usage.ResourceServices, usage.ResourceServicesLoadBalancers, usage.ResourceServicesNodePorts: + if r.ServiceCalculator == nil { + log.Error(nil, "ServiceCalculator is nil", "namespace", ns, "resource", resourceName) + return resource.MustParse("0") + } + usage, err := r.ServiceCalculator.CalculateUsage(ctx, ns, resourceName) + if err != nil { + log.Error(err, "Failed to calculate service usage", "resource", resourceName, "namespace", ns) + return resource.MustParse("0") + } + return usage + default: + log.Info("Unsupported object count resource for calculateObjectCount", "resource", resourceName, "namespace", ns) + return resource.MustParse("0") + } } // calculateComputeResources calculates the usage for compute resource quotas (CPU/Memory). @@ -423,6 +447,11 @@ func (r *ClusterResourceQuotaReconciler) findQuotasForObject(ctx context.Context log.Error(err, "Failed to get ClusterResourceQuota for namespace") return nil } + if crq != nil { + log.Info("Found ClusterResourceQuota for namespace", "crq", crq.Name, "namespace", ns.Name) + } else { + log.Info("No ClusterResourceQuota found for namespace", "namespace", ns.Name) + } if crq != nil { return []reconcile.Request{ @@ -487,19 +516,20 @@ func (r *ClusterResourceQuotaReconciler) SetupWithManager(mgr ctrl.Manager) erro if r.ComputeCalculator == nil { r.ComputeCalculator = pod.NewPodResourceCalculator(clientset) } + if r.ServiceCalculator == nil { + r.ServiceCalculator = services.NewServiceResourceCalculator(clientset) + } log.Info("Setting up ClusterResourceQuota controller") // Predicate to filter out updates to status subresource // This prevents reconcile loops caused by status updates - // Not sure about this one, but seems to reduce noise - // Couldn't find much examples of this in the wild resourcePredicate := resourceUpdatePredicate{} b := ctrl.NewControllerManagedBy(mgr). For("av1alpha1.ClusterResourceQuota{}) - // Watch for changes to tracked resources and trigger reconciliation for associated CRQs + // Watch for changes to tracked resources and trigger reconciliation for associated CRQs watchedObjectTypes := []struct { obj client.Object preds []predicate.Predicate @@ -507,6 +537,7 @@ func (r *ClusterResourceQuotaReconciler) SetupWithManager(mgr ctrl.Manager) erro {&corev1.Namespace{}, nil}, {&corev1.Pod{}, []predicate.Predicate{resourcePredicate}}, {&corev1.PersistentVolumeClaim{}, nil}, + {&corev1.Service{}, nil}, } for _, w := range watchedObjectTypes { b = b.Watches( diff --git a/pkg/kubernetes/services/service.go b/pkg/kubernetes/services/service.go new file mode 100644 index 0000000..74f8580 --- /dev/null +++ b/pkg/kubernetes/services/service.go @@ -0,0 +1,93 @@ +package services + +import ( + "context" + "fmt" + + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// CountServices returns the total number of services and a breakdown by type in the namespace (public interface). +func (c *ServiceResourceCalculator) CountServices(ctx context.Context, namespace string) (int64, map[corev1.ServiceType]int64, error) { + return c.countServicesByType(ctx, namespace) +} + +// Ensure ServiceResourceCalculator implements usage.ResourceCalculatorInterface +var _ usage.ResourceCalculatorInterface = &ServiceResourceCalculator{} + +// ServiceResourceCalculator provides methods for counting services and subtypes in a namespace. +type ServiceResourceCalculator struct { + Client kubernetes.Interface +} + +// NewServiceResourceCalculator creates a new ServiceResourceCalculator. +func NewServiceResourceCalculator(c kubernetes.Interface) *ServiceResourceCalculator { + return &ServiceResourceCalculator{Client: c} +} + +// resourceNameToServiceType maps usage resource names to corev1.ServiceType values. +var resourceNameToServiceType = map[corev1.ResourceName]corev1.ServiceType{ + usage.ResourceServicesLoadBalancers: corev1.ServiceTypeLoadBalancer, + usage.ResourceServicesNodePorts: corev1.ServiceTypeNodePort, +} + +// CalculateUsage returns the usage count for a specific service type resource in the namespace. +func (c *ServiceResourceCalculator) CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) { + total, byType, err := c.countServicesByType(ctx, namespace) + if err != nil { + return resource.Quantity{}, err + } + + switch resourceName { + case usage.ResourceServices: + return *resource.NewQuantity(total, resource.DecimalSI), nil + case usage.ResourceServicesLoadBalancers, usage.ResourceServicesNodePorts: + serviceType, ok := resourceNameToServiceType[resourceName] + if !ok { + return resource.Quantity{}, nil + } + return *resource.NewQuantity(byType[serviceType], resource.DecimalSI), nil + default: + return resource.Quantity{}, nil + } +} + +// CalculateTotalUsage calculates the total usage for all supported service count resources in a namespace. +func (c *ServiceResourceCalculator) CalculateTotalUsage(ctx context.Context, namespace string) (map[corev1.ResourceName]resource.Quantity, error) { + total, byType, err := c.countServicesByType(ctx, namespace) + if err != nil { + return nil, err + } + result := map[corev1.ResourceName]resource.Quantity{ + usage.ResourceServices: *resource.NewQuantity(total, resource.DecimalSI), + usage.ResourceServicesLoadBalancers: *resource.NewQuantity(byType[corev1.ServiceTypeLoadBalancer], resource.DecimalSI), + usage.ResourceServicesNodePorts: *resource.NewQuantity(byType[corev1.ServiceTypeNodePort], resource.DecimalSI), + } + return result, nil +} + +// CountServices returns the total number of services and a breakdown by type in the namespace. +func (c *ServiceResourceCalculator) countServicesByType(ctx context.Context, namespace string) (total int64, byType map[corev1.ServiceType]int64, err error) { + serviceList, err := c.Client.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return 0, nil, err + } + byType = map[corev1.ServiceType]int64{ + corev1.ServiceTypeNodePort: 0, + corev1.ServiceTypeLoadBalancer: 0, + } + // DEBUG: Print all services being counted + fmt.Printf("[DEBUG] Counting services in namespace %s:\n", namespace) + for _, svc := range serviceList.Items { + fmt.Printf("[DEBUG] SERVICE: %s/%s type=%s\n", svc.Namespace, svc.Name, svc.Spec.Type) + byType[svc.Spec.Type]++ + } + total = int64(len(serviceList.Items)) + fmt.Printf("[DEBUG] Total services counted: %d\n", total) + return total, byType, nil +} diff --git a/pkg/kubernetes/services/service_suite_test.go b/pkg/kubernetes/services/service_suite_test.go new file mode 100644 index 0000000..c27f20f --- /dev/null +++ b/pkg/kubernetes/services/service_suite_test.go @@ -0,0 +1,12 @@ +package services + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "testing" +) + +func TestServices(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ServiceResourceCalculator Suite") +} diff --git a/pkg/kubernetes/services/service_test.go b/pkg/kubernetes/services/service_test.go new file mode 100644 index 0000000..2b32534 --- /dev/null +++ b/pkg/kubernetes/services/service_test.go @@ -0,0 +1,139 @@ +package services + +import ( + "context" + + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/testing" +) + +var _ = Describe("ServiceResourceCalculator", func() { + var ( + ctx context.Context + client *fake.Clientset + calc *ServiceResourceCalculator + ) + + BeforeEach(func() { + ctx = context.Background() + client = fake.NewSimpleClientset( + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc1", Namespace: "ns1"}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc2", Namespace: "ns1"}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeNodePort}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc3", Namespace: "ns1"}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc4", Namespace: "ns1"}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc5", Namespace: "ns2"}, + Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP}, + }, + ) + calc = NewServiceResourceCalculator(client) + }) + + Describe("CalculateTotalUsage", func() { + It("returns correct map for all supported resources in ns1", func() { + m, err := calc.CalculateTotalUsage(ctx, "ns1") + Expect(err).ToNot(HaveOccurred()) + q := m[usage.ResourceServices] + Expect((&q).Value()).To(Equal(int64(4))) + q = m[usage.ResourceServicesLoadBalancers] + Expect((&q).Value()).To(Equal(int64(1))) + q = m[usage.ResourceServicesNodePorts] + Expect((&q).Value()).To(Equal(int64(1))) + }) + + It("returns correct map for all supported resources in ns2", func() { + m, err := calc.CalculateTotalUsage(ctx, "ns2") + Expect(err).ToNot(HaveOccurred()) + q := m[usage.ResourceServices] + Expect((&q).Value()).To(Equal(int64(1))) + q = m[usage.ResourceServicesLoadBalancers] + Expect((&q).Value()).To(Equal(int64(0))) + q = m[usage.ResourceServicesNodePorts] + Expect((&q).Value()).To(Equal(int64(0))) + }) + + It("returns error if client returns error", func() { + badClient := fake.NewSimpleClientset() + badClient.PrependReactor("list", "services", func(action testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("fake list error") + }) + badCalc := NewServiceResourceCalculator(badClient) + m, err := badCalc.CalculateTotalUsage(ctx, "ns1") + Expect(err).To(HaveOccurred()) + Expect(m).To(BeNil()) + }) + }) + + Describe("CalculateUsage", func() { + It("returns correct count for ResourceServices in ns1", func() { + q, err := calc.CalculateUsage(ctx, "ns1", usage.ResourceServices) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(4))) + }) + + It("returns correct count for ResourceServicesLoadBalancers in ns1", func() { + q, err := calc.CalculateUsage(ctx, "ns1", usage.ResourceServicesLoadBalancers) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(1))) + }) + + It("returns correct count for ResourceServicesNodePorts in ns1", func() { + q, err := calc.CalculateUsage(ctx, "ns1", usage.ResourceServicesNodePorts) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(1))) + }) + + It("returns zero for unsupported resource name", func() { + q, err := calc.CalculateUsage(ctx, "ns1", corev1.ResourceName("unsupported")) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(0))) + }) + + It("returns correct count for ResourceServices in ns2 (single service)", func() { + q, err := calc.CalculateUsage(ctx, "ns2", usage.ResourceServices) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(1))) + }) + + It("returns zero for other service types in ns2", func() { + q, err := calc.CalculateUsage(ctx, "ns2", usage.ResourceServicesLoadBalancers) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(0))) + q, err = calc.CalculateUsage(ctx, "ns2", usage.ResourceServicesNodePorts) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(0))) + }) + + It("returns error if client returns error", func() { + // Use a fake client with a reactor that always returns an error + badClient := fake.NewSimpleClientset() + badClient.PrependReactor("list", "services", func(action testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("fake list error") + }) + badCalc := NewServiceResourceCalculator(badClient) + q, err := badCalc.CalculateUsage(ctx, "ns1", usage.ResourceServices) + Expect(err).To(HaveOccurred()) + Expect(q.Value()).To(Equal(int64(0))) + }) + }) +}) diff --git a/pkg/kubernetes/services/types.go b/pkg/kubernetes/services/types.go new file mode 100644 index 0000000..f7e756a --- /dev/null +++ b/pkg/kubernetes/services/types.go @@ -0,0 +1,17 @@ +//go:generate mockery --name=ServiceResourceCalculatorInterface +package services + +import ( + "context" + + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" + corev1 "k8s.io/api/core/v1" +) + +//go:generate mockery + +// ServiceResourceCalculatorInterface defines the interface for service resource calculations +type ServiceResourceCalculatorInterface interface { + usage.ResourceCalculatorInterface + CountServices(ctx context.Context, namespace string) (total int64, byType map[corev1.ServiceType]int64, err error) +} diff --git a/pkg/kubernetes/storage/types.go b/pkg/kubernetes/storage/types.go index 5cc3a07..ba9c113 100644 --- a/pkg/kubernetes/storage/types.go +++ b/pkg/kubernetes/storage/types.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package storage import ( diff --git a/pkg/kubernetes/usage/usage.go b/pkg/kubernetes/usage/usage.go index 556f6d6..c7a03fc 100644 --- a/pkg/kubernetes/usage/usage.go +++ b/pkg/kubernetes/usage/usage.go @@ -117,6 +117,14 @@ var ( // Count resources ResourcePods = corev1.ResourcePods ResourcePersistentVolumeClaims = corev1.ResourcePersistentVolumeClaims + ResourceConfigMaps = corev1.ResourceConfigMaps + ResourceReplicationControllers = corev1.ResourceReplicationControllers + ResourceSecrets = corev1.ResourceSecrets + + // Count services + ResourceServices = corev1.ResourceServices + ResourceServicesLoadBalancers = corev1.ResourceServicesLoadBalancers + ResourceServicesNodePorts = corev1.ResourceServicesNodePorts ) // Common resource calculation utilities diff --git a/pkg/mocks/ServiceResourceCalculatorInterface_mock.go b/pkg/mocks/ServiceResourceCalculatorInterface_mock.go new file mode 100644 index 0000000..951996a --- /dev/null +++ b/pkg/mocks/ServiceResourceCalculatorInterface_mock.go @@ -0,0 +1,67 @@ +// Code generated by mockery. DO NOT EDIT. +package mocks + +import ( + context "context" + + testify_mock "github.com/stretchr/testify/mock" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// ServiceResourceCalculatorInterface is an autogenerated mock type for the ServiceResourceCalculatorInterface type +// +//go:generate mockery --name=ServiceResourceCalculatorInterface +type ServiceResourceCalculatorInterface struct { + testify_mock.Mock +} + +// CalculateUsage provides a mock function with given fields: ctx, namespace, resourceName +func (_m *ServiceResourceCalculatorInterface) CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) { + ret := _m.Called(ctx, namespace, resourceName) + + var r0 resource.Quantity + if rf, ok := ret.Get(0).(func(context.Context, string, corev1.ResourceName) resource.Quantity); ok { + r0 = rf(ctx, namespace, resourceName) + } else { + r0 = ret.Get(0).(resource.Quantity) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, corev1.ResourceName) error); ok { + r1 = rf(ctx, namespace, resourceName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountServices provides a mock function with given fields: ctx, namespace +func (_m *ServiceResourceCalculatorInterface) CountServices(ctx context.Context, namespace string) (int64, map[corev1.ServiceType]int64, error) { + ret := _m.Called(ctx, namespace) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, string) int64); ok { + r0 = rf(ctx, namespace) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 map[corev1.ServiceType]int64 + if rf, ok := ret.Get(1).(func(context.Context, string) map[corev1.ServiceType]int64); ok { + r1 = rf(ctx, namespace) + } else { + r1 = ret.Get(1).(map[corev1.ServiceType]int64) + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = rf(ctx, namespace) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/pkg/webhook/server/server.go b/pkg/webhook/server/server.go index 94965c7..e54524e 100644 --- a/pkg/webhook/server/server.go +++ b/pkg/webhook/server/server.go @@ -54,6 +54,7 @@ type GinWebhookServer struct { pvcHandler *v1alpha1.PersistentVolumeClaimWebhook crqHandler *v1alpha1.ClusterResourceQuotaWebhook namespaceHandler *v1alpha1.NamespaceWebhook + serviceHandler *v1alpha1.ServiceWebhook k8sClient kubernetes.Interface runtimeClient client.Client @@ -192,6 +193,12 @@ func (s *GinWebhookServer) setupRoutes() { s.podHandler = v1alpha1.NewPodWebhook(s.k8sClient, crqClient, s.log) s.engine.POST("/validate--v1-pod", s.podHandler.Handle) + if s.log != nil { + s.log.Info("Setting up service webhook") + } + s.serviceHandler = v1alpha1.NewServiceWebhook(s.k8sClient, crqClient, s.log) + s.engine.POST("/validate--v1-service", s.serviceHandler.Handle) + if s.log != nil { s.log.Info("Setting up PVC webhook") } diff --git a/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go b/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go index 1c42f79..002078e 100644 --- a/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go +++ b/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( @@ -24,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -32,13 +17,15 @@ import ( quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/namespace" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/services" ) // ClusterResourceQuotaWebhook handles webhook requests for ClusterResourceQuota resources type ClusterResourceQuotaWebhook struct { - client kubernetes.Interface - crqClient *quota.CRQClient - log *zap.Logger + client kubernetes.Interface + crqClient *quota.CRQClient + serviceCalculator services.ServiceResourceCalculatorInterface + log *zap.Logger } // NewClusterResourceQuotaWebhook creates a new ClusterResourceQuotaWebhook @@ -48,9 +35,10 @@ func NewClusterResourceQuotaWebhook( log *zap.Logger, ) *ClusterResourceQuotaWebhook { return &ClusterResourceQuotaWebhook{ - client: k8sClient, - crqClient: crqClient, - log: log, + client: k8sClient, + crqClient: crqClient, + serviceCalculator: services.NewServiceResourceCalculator(k8sClient), + log: log, } } @@ -176,6 +164,39 @@ func (h *ClusterResourceQuotaWebhook) validateCreate( if err := validator.ValidateCRQNamespaceConflicts(ctx, crq); err != nil { return err } + + // Validate service object count quotas for all supported service resource types + if crq.Spec.Hard != nil && crq.Spec.NamespaceSelector != nil { + selector, err := namespace.NewLabelBasedNamespaceSelector(h.client, crq.Spec.NamespaceSelector) + if err != nil { + return fmt.Errorf("failed to create namespace selector: %w", err) + } + selectedNamespaces, err := selector.GetSelectedNamespaces(ctx) + if err != nil { + return fmt.Errorf("failed to get selected namespaces: %w", err) + } + for resourceName := range crq.Spec.Hard { + switch resourceName { + case "services", "services.loadbalancers", "services.nodeports", "services.clusterips", "services.externalnames": + var totalUsage resource.Quantity + for _, ns := range selectedNamespaces { + usageQty, err := h.serviceCalculator.CalculateUsage(ctx, ns, resourceName) + if err != nil { + return fmt.Errorf("failed to calculate usage for %s in namespace %s: %w", resourceName, ns, err) + } + if totalUsage.IsZero() { + totalUsage = usageQty.DeepCopy() + } else { + totalUsage.Add(usageQty) + } + } + hardQty := crq.Spec.Hard[resourceName] + if totalUsage.Cmp(hardQty) > 0 { + return fmt.Errorf("quota exceeded for %s: used %s, hard limit %s", resourceName, totalUsage.String(), hardQty.String()) + } + } + } + } return nil } @@ -191,6 +212,39 @@ func (h *ClusterResourceQuotaWebhook) validateUpdate( if err := validator.ValidateCRQNamespaceConflicts(ctx, crq); err != nil { return err } + + // Validate service object count quotas for all supported service resource types + if crq.Spec.Hard != nil && crq.Spec.NamespaceSelector != nil { + selector, err := namespace.NewLabelBasedNamespaceSelector(h.client, crq.Spec.NamespaceSelector) + if err != nil { + return fmt.Errorf("failed to create namespace selector: %w", err) + } + selectedNamespaces, err := selector.GetSelectedNamespaces(ctx) + if err != nil { + return fmt.Errorf("failed to get selected namespaces: %w", err) + } + for resourceName := range crq.Spec.Hard { + switch resourceName { + case "services", "services.loadbalancers", "services.nodeports", "services.clusterips", "services.externalnames": + var totalUsage resource.Quantity + for _, ns := range selectedNamespaces { + usageQty, err := h.serviceCalculator.CalculateUsage(ctx, ns, resourceName) + if err != nil { + return fmt.Errorf("failed to calculate usage for %s in namespace %s: %w", resourceName, ns, err) + } + if totalUsage.IsZero() { + totalUsage = usageQty.DeepCopy() + } else { + totalUsage.Add(usageQty) + } + } + hardQty := crq.Spec.Hard[resourceName] + if totalUsage.Cmp(hardQty) > 0 { + return fmt.Errorf("quota exceeded for %s: used %s, hard limit %s", resourceName, totalUsage.String(), hardQty.String()) + } + } + } + } return nil } diff --git a/pkg/webhook/v1alpha1/pod_webhook.go b/pkg/webhook/v1alpha1/pod_webhook.go index 5593d1c..a7083e1 100644 --- a/pkg/webhook/v1alpha1/pod_webhook.go +++ b/pkg/webhook/v1alpha1/pod_webhook.go @@ -170,18 +170,18 @@ func (h *PodWebhook) Handle(c *gin.Context) { } func (h *PodWebhook) validateCreate(ctx context.Context, podObj *corev1.Pod) ([]string, error) { - return h.validatePodOperation(ctx, podObj, "creation") + return h.validatePodOperation(ctx, podObj, OperationCreate) } func (h *PodWebhook) validateUpdate(ctx context.Context, podObj *corev1.Pod) ([]string, error) { - return h.validatePodOperation(ctx, podObj, "update") + return h.validatePodOperation(ctx, podObj, OperationUpdate) } // validatePodOperation is a shared function for both create and update validation -func (h *PodWebhook) validatePodOperation(ctx context.Context, podObj *corev1.Pod, operation string) ([]string, error) { +func (h *PodWebhook) validatePodOperation(ctx context.Context, podObj *corev1.Pod, operation operation) ([]string, error) { // Handle nil pod case if podObj == nil { - h.log.Info("Skipping CRQ validation for nil pod on " + operation) + h.log.Info("Skipping CRQ validation for nil pod on " + string(operation)) return nil, nil } @@ -227,7 +227,8 @@ func (h *PodWebhook) validatePodOperation(ctx context.Context, podObj *corev1.Po h.log.Info("Pod CRQ validation passed", zap.String("pod", podObj.Name), zap.String("namespace", podObj.Namespace), - zap.String("operation", operation)) + zap.String("operation", string(operation)), + ) return nil, nil } diff --git a/pkg/webhook/v1alpha1/pod_webhook_test.go b/pkg/webhook/v1alpha1/pod_webhook_test.go index 5fbfc43..8ecc811 100644 --- a/pkg/webhook/v1alpha1/pod_webhook_test.go +++ b/pkg/webhook/v1alpha1/pod_webhook_test.go @@ -115,7 +115,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -142,7 +142,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Update) + admissionReview := createPodAdmissionReview(pod, admissionv1.Update) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -184,7 +184,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) admissionReview.Request.Kind = metav1.GroupVersionKind{ Group: "apps", Version: "v1", @@ -215,7 +215,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Delete) + admissionReview := createPodAdmissionReview(pod, admissionv1.Delete) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeFalse()) @@ -233,7 +233,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -269,7 +269,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -300,7 +300,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -327,7 +327,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -348,7 +348,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -362,7 +362,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) admissionReview.Request.Kind = metav1.GroupVersionKind{ Group: "apps", Version: "v1", @@ -389,7 +389,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Delete) + admissionReview := createPodAdmissionReview(pod, admissionv1.Delete) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeFalse()) @@ -516,7 +516,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -537,7 +537,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -571,7 +571,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(pod, admissionv1.Create) + admissionReview := createPodAdmissionReview(pod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -707,7 +707,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(newPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(newPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeFalse()) @@ -738,7 +738,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(newPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(newPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -765,7 +765,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(newPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(newPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -842,7 +842,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(newPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(newPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -877,7 +877,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(multiContainerPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(multiContainerPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) // 200m (existing) + 80m + 80m = 360m > 300m limit @@ -917,7 +917,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(initContainerPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(initContainerPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) // Should use max(init: 150m, main: 100m) = 150m @@ -947,7 +947,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(memoryPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(memoryPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) // 200Mi (existing) + 150Mi = 350Mi > 300Mi limit @@ -1017,7 +1017,7 @@ var _ = Describe("PodWebhook", func() { }, } - admissionReview := createAdmissionReview(newPod, admissionv1.Create) + admissionReview := createPodAdmissionReview(newPod, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -1515,7 +1515,7 @@ var _ = Describe("PodWebhook", func() { // Helper functions for testing -func createAdmissionReview(pod *corev1.Pod, operation admissionv1.Operation) *admissionv1.AdmissionReview { +func createPodAdmissionReview(pod *corev1.Pod, operation admissionv1.Operation) *admissionv1.AdmissionReview { raw, _ := json.Marshal(pod) return &admissionv1.AdmissionReview{ Request: &admissionv1.AdmissionRequest{ @@ -1538,29 +1538,6 @@ func createAdmissionReview(pod *corev1.Pod, operation admissionv1.Operation) *ad } } -func sendWebhookRequest(engine *gin.Engine, admissionReview *admissionv1.AdmissionReview) *admissionv1.AdmissionReview { - body, _ := json.Marshal(admissionReview) - req, _ := http.NewRequest("POST", "/webhook", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - engine.ServeHTTP(w, req) - - var response admissionv1.AdmissionReview - if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { - // If unmarshaling fails, create a default response - response = admissionv1.AdmissionReview{ - Response: &admissionv1.AdmissionResponse{ - Allowed: false, - Result: &metav1.Status{ - Message: "Failed to parse response", - }, - }, - } - } - return &response -} - // testTerminalPodState is a helper function to test pods in terminal states (Succeeded/Failed) func testTerminalPodState(ctx context.Context, phase corev1.PodPhase, podName, namespaceName string, fakeClient *kubernetes.Interface, crqClient *quota.CRQClient, logger *zap.Logger) { diff --git a/pkg/webhook/v1alpha1/service_webhook.go b/pkg/webhook/v1alpha1/service_webhook.go new file mode 100644 index 0000000..9be3001 --- /dev/null +++ b/pkg/webhook/v1alpha1/service_webhook.go @@ -0,0 +1,222 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes" + + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/services" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" + "k8s.io/apimachinery/pkg/api/resource" +) + +// ServiceWebhook handles webhook requests for Service resources +// It enforces object count quotas for services and subtypes. +type ServiceWebhook struct { + client kubernetes.Interface + serviceCalculator services.ServiceResourceCalculatorInterface + crqClient *quota.CRQClient + log *zap.Logger +} + +// NewServiceWebhook creates a new ServiceWebhook +func NewServiceWebhook(k8sClient kubernetes.Interface, crqClient *quota.CRQClient, log *zap.Logger) *ServiceWebhook { + return &ServiceWebhook{ + client: k8sClient, + serviceCalculator: services.NewServiceResourceCalculator(k8sClient), + crqClient: crqClient, + log: log, + } +} + +// Handle handles the webhook request for Service +func (h *ServiceWebhook) Handle(c *gin.Context) { + var admissionReview admissionv1.AdmissionReview + if err := c.ShouldBindJSON(&admissionReview); err != nil { + h.log.Error("Failed to bind admission review", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check for malformed requests (like {}) that don't have proper AdmissionReview structure + if admissionReview.Kind == "" && admissionReview.APIVersion == "" && admissionReview.Request == nil { + h.log.Error("Malformed admission review request") + c.JSON(http.StatusBadRequest, gin.H{"error": "Malformed admission review request"}) + return + } + + if admissionReview.Request == nil { + h.log.Info("Admission review request is nil") + admissionReview.Response = &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: http.StatusBadRequest, + Message: "Missing admission request", + }, + } + c.JSON(http.StatusOK, admissionReview) + return + } + + admissionReview.Response = &admissionv1.AdmissionResponse{ + UID: admissionReview.Request.UID, + } + + // Only handle Service resources + expectedGVK := metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + } + if admissionReview.Request.Kind != expectedGVK { + h.log.Info("Unexpected resource type", zap.String("got", admissionReview.Request.Kind.Kind)) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("Expected %s resource, got %s", expectedGVK.Kind, admissionReview.Request.Kind.Kind), + } + c.JSON(http.StatusOK, admissionReview) + return + } + + // Decode the Service object + var svc corev1.Service + if err := runtime.DecodeInto( + serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer(), + admissionReview.Request.Object.Raw, + &svc, + ); err != nil { + h.log.Error("Failed to decode Service", zap.Error(err)) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: http.StatusBadRequest, + Message: "Unable to decode Service object", + } + c.JSON(http.StatusOK, admissionReview) + return + } + + var warnings []string + var err error + ctx := c.Request.Context() + switch admissionReview.Request.Operation { + case admissionv1.Create: + h.log.Info("Validating Service on create", + zap.String("name", svc.GetName()), + zap.String("namespace", svc.GetNamespace())) + warnings, err = h.validateCreate(ctx, &svc) + case admissionv1.Update: + h.log.Info("Validating Service on update", + zap.String("name", svc.GetName()), + zap.String("namespace", svc.GetNamespace())) + warnings, err = h.validateUpdate(ctx, &svc) + default: + h.log.Info("Unsupported operation", zap.String("operation", string(admissionReview.Request.Operation))) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("Operation %s is not supported for Service", admissionReview.Request.Operation), + } + c.JSON(http.StatusOK, admissionReview) + return + } + + if err != nil { + h.log.Error("Validation failed", zap.Error(err)) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: http.StatusForbidden, + Message: err.Error(), + } + } else { + admissionReview.Response.Allowed = true + if len(warnings) > 0 { + admissionReview.Response.Warnings = warnings + } + } + + c.JSON(http.StatusOK, admissionReview) +} + +func (h *ServiceWebhook) validateCreate(ctx context.Context, svc *corev1.Service) ([]string, error) { + return h.validateServiceOperation(ctx, svc, "creation") +} + +func (h *ServiceWebhook) validateUpdate(ctx context.Context, svc *corev1.Service) ([]string, error) { + return h.validateServiceOperation(ctx, svc, "update") +} + +// validateServiceOperation is a shared function for both create and update validation +func (h *ServiceWebhook) validateServiceOperation(ctx context.Context, svc *corev1.Service, operation string) ([]string, error) { + if svc == nil { + h.log.Info("Skipping CRQ validation for nil service on " + operation) + return nil, nil + } + + // Determine the resource names to check (generic + subtype) + var resourceName corev1.ResourceName + switch svc.Spec.Type { + case corev1.ServiceTypeLoadBalancer: + resourceName = usage.ResourceServicesLoadBalancers + case corev1.ServiceTypeNodePort: + resourceName = usage.ResourceServicesNodePorts + default: + resourceName = usage.ResourceServices + } + resourceNames := []corev1.ResourceName{usage.ResourceServices, resourceName} + + for _, rn := range resourceNames { + if rn == "" { + continue + } + // Always +1 for the service being created/updated + if err := h.validateResourceQuota(ctx, svc.Namespace, rn, *resource.NewQuantity(1, resource.DecimalSI)); err != nil { + return nil, fmt.Errorf("ClusterResourceQuota service count validation failed for %s: %w", rn, err) + } + } + + h.log.Info("Service CRQ validation passed", + zap.String("service", svc.Name), + zap.String("namespace", svc.Namespace), + zap.String("operation", operation)) + return nil, nil +} + +// validateResourceQuota validates if a resource operation would exceed any applicable ClusterResourceQuota +func (h *ServiceWebhook) validateResourceQuota( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName, + requestedQuantity resource.Quantity, +) error { + ns, err := h.client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get namespace %s: %w", namespace, err) + } + + return validateCRQResourceQuotaWithNamespace(ctx, h.crqClient, h.client, ns, resourceName, requestedQuantity, + func(ns string, rn corev1.ResourceName) (resource.Quantity, error) { + return h.calculateCurrentUsage(ctx, ns, rn) + }, h.log) +} + +// calculateCurrentUsage calculates the current usage of a resource in a namespace +func (h *ServiceWebhook) calculateCurrentUsage(ctx context.Context, namespace string, + resourceName corev1.ResourceName) (resource.Quantity, error) { + switch resourceName { + case usage.ResourceServices, usage.ResourceServicesLoadBalancers, usage.ResourceServicesNodePorts: + return h.serviceCalculator.CalculateUsage(ctx, namespace, resourceName) + default: + return resource.Quantity{}, fmt.Errorf("unsupported resource type: %s", resourceName) + } +} diff --git a/pkg/webhook/v1alpha1/service_webhook_test.go b/pkg/webhook/v1alpha1/service_webhook_test.go new file mode 100644 index 0000000..cdc019e --- /dev/null +++ b/pkg/webhook/v1alpha1/service_webhook_test.go @@ -0,0 +1,903 @@ +package v1alpha1 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlclientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + + quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/services" +) + +var _ = Describe("ServiceWebhook", func() { + var ( + ctx context.Context + webhook *ServiceWebhook + fakeClient kubernetes.Interface + fakeRuntimeClient client.Client + crqClient *quota.CRQClient + logger *zap.Logger + ginEngine *gin.Engine + testNamespace *corev1.Namespace + ) + + BeforeEach(func() { + testNamespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + }, + } + fakeClient = fake.NewSimpleClientset(testNamespace) + scheme := runtime.NewScheme() + _ = quotav1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + fakeRuntimeClient = ctrlclientfake.NewClientBuilder().WithScheme(scheme).Build() + crqClient = quota.NewCRQClient(fakeRuntimeClient) + logger = zap.NewNop() + webhook = &ServiceWebhook{ + client: fakeClient, + serviceCalculator: services.NewServiceResourceCalculator(fakeClient), + crqClient: crqClient, + log: logger, + } + gin.SetMode(gin.TestMode) + ginEngine = gin.New() + ginEngine.POST("/webhook", webhook.Handle) + }) + Describe("Service type and error handling (integration style)", func() { + It("should allow ClusterIP service within quota", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc-clusterip", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + }, + } + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + It("should allow NodePort service within quota", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc-nodeport", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + }, + } + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + It("should allow LoadBalancer service within quota", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc-lb", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + }, + } + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + It("should allow ExternalName service within quota", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc-external", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: "example.com", + }, + } + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + It("should reject if namespace does not exist", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc-missing-ns", + Namespace: "does-not-exist", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + }, + } + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("failed to get namespace")) + }) + }) + + Describe("NewServiceWebhook", func() { + It("should create a new service webhook", func() { + Expect(webhook).NotTo(BeNil()) + Expect(webhook.client).To(Equal(fakeClient)) + Expect(webhook.log).To(Equal(logger)) + Expect(webhook.serviceCalculator).NotTo(BeNil()) + }) + + It("should create webhook with nil client", func() { + webhook := NewServiceWebhook(nil, crqClient, logger) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.client).To(BeNil()) + }) + + It("should create webhook with nil logger", func() { + webhook := NewServiceWebhook(fakeClient, crqClient, nil) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.log).To(BeNil()) + }) + + It("should create webhook with nil CRQ client", func() { + webhook := NewServiceWebhook(fakeClient, nil, logger) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.crqClient).To(BeNil()) + }) + + It("should create webhook with all nil parameters", func() { + webhook := NewServiceWebhook(nil, nil, nil) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.client).To(BeNil()) + Expect(webhook.crqClient).To(BeNil()) + Expect(webhook.log).To(BeNil()) + }) + }) + + Describe("Handle", func() { + It("should handle valid service creation request", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeTrue()) + Expect(response.Response.UID).To(Equal(admissionReview.Request.UID)) + }) + + It("should handle valid service update request", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8081), + }, + }, + }, + } + + admissionReview := createServiceAdmissionReview(svc, admissionv1.Update) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeTrue()) + Expect(response.Response.UID).To(Equal(admissionReview.Request.UID)) + }) + + It("should reject request with nil admission review", func() { + req, _ := http.NewRequest("POST", "/webhook", bytes.NewBuffer([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + ginEngine.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("should reject request with nil admission review request", func() { + admissionReview := &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "AdmissionReview", + APIVersion: "admission.k8s.io/v1", + }, + Request: nil, + } + + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response).NotTo(BeNil()) + Expect(response.Response).NotTo(BeNil()) + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("Missing admission request")) + }) + + It("should reject request with wrong resource kind", func() { + // Use a ConfigMap as a wrong kind + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "test-namespace", + }, + } + raw, _ := json.Marshal(cm) + admissionReview := &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + Resource: metav1.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: raw, + }, + }, + } + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("Expected Service resource")) + }) + + It("should reject request with invalid JSON", func() { + req, _ := http.NewRequest("POST", "/webhook", bytes.NewBuffer([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + ginEngine.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("should reject unsupported operation", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-namespace", + }, + } + + admissionReview := createServiceAdmissionReview(svc, admissionv1.Delete) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("Operation DELETE is not supported")) + }) + + Describe("validateCreate", func() { + It("should validate service creation", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + warnings, err := webhook.validateCreate(ctx, svc) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).To(BeNil()) + }) + }) + + Describe("validateUpdate", func() { + It("should validate service update", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + warnings, err := webhook.validateUpdate(ctx, svc) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).To(BeNil()) + }) + }) + + Describe("Edge Cases", func() { + It("should handle svc with very long name", func() { + longName := "very-long-svc-name-that-exceeds-normal-length-limits-for-testing-purposes" + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: longName, + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeTrue()) + }) + + It("should handle svc with special characters in name", func() { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc-123_456-789", + Namespace: "test-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + admissionReview := createServiceAdmissionReview(svc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeTrue()) + }) + }) + + Describe("Cross-Namespace Quota Validation", func() { + var ( + crq *quotav1alpha1.ClusterResourceQuota + namespace1 *corev1.Namespace + namespace2 *corev1.Namespace + namespace3 *corev1.Namespace // For non-matching namespace tests + existingSvc1 *corev1.Service + existingSvc2 *corev1.Service + existingSvc3 *corev1.Service + ) + + BeforeEach(func() { + // Create test namespaces with matching labels + namespace1 = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-1", + Labels: map[string]string{ + "environment": "test", + "team": "platform", + }, + }, + } + namespace2 = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-2", + Labels: map[string]string{ + "environment": "test", + "team": "platform", + }, + }, + } + + // Create a namespace that doesn't match the CRQ selector + namespace3 = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-3", + Labels: map[string]string{ + "environment": "production", + "team": "backend", + }, + }, + } + + // Create a ClusterResourceQuota that selects both test namespaces + crq = "av1alpha1.ClusterResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-crq", + }, + Spec: quotav1alpha1.ClusterResourceQuotaSpec{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "environment": "test", + }, + }, + Hard: quotav1alpha1.ResourceList{ + corev1.ResourceServices: resource.MustParse("3"), + corev1.ResourceServicesLoadBalancers: resource.MustParse("1"), + corev1.ResourceServicesNodePorts: resource.MustParse("1"), + }, + }, + } + + // Create existing services in namespace1 with unique names + existingSvc1 = &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-svc-lb", + Namespace: "test-ns-1", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + existingSvc2 = &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-svc-nodeport", + Namespace: "test-ns-1", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + existingSvc3 = &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-svc-clusterip", + Namespace: "test-ns-1", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + // Update the fake clients with the new resources + fakeClient = fake.NewSimpleClientset( + testNamespace, + namespace1, + namespace2, + namespace3, + existingSvc1, + existingSvc2, + existingSvc3, + ) + scheme := runtime.NewScheme() + _ = quotav1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + fakeRuntimeClient = ctrlclientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(crq, namespace1, namespace2, namespace3, existingSvc1, existingSvc2, existingSvc3). + Build() + crqClient = quota.NewCRQClient(fakeRuntimeClient) + + // Recreate webhook with updated clients + webhook = NewServiceWebhook(fakeClient, crqClient, logger) + + // Re-setup gin engine + ginEngine = gin.New() + ginEngine.POST("/webhook", webhook.Handle) + }) + + AfterEach(func() { + // Clean up cross-namespace test resources + crq = nil + namespace1 = nil + namespace2 = nil + namespace3 = nil + existingSvc1 = nil + existingSvc2 = nil + existingSvc3 = nil + }) + + It("should reject svc that would exceed cross-namespace quota limits", func() { + // Try to create a new service in namespace2 that would exceed the total quota + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-svc", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("ClusterResourceQuota service count validation failed for")) + Expect(response.Response.Result.Message).To(ContainSubstring("test-crq")) + Expect(response.Response.Result.Message).To(ContainSubstring("limit exceeded")) + }) + + It("should allow svc that fits within cross-namespace quota limits", func() { + // Increase the quota to allow one more LoadBalancer service (from 1 to 2) + // Also increase the quota total number of services to 4 (from 3) + crq.Spec.Hard[corev1.ResourceServices] = resource.MustParse("4") + crq.Spec.Hard[corev1.ResourceServicesLoadBalancers] = resource.MustParse("2") + + // Rebuild the fake runtime client with updated CRQ + scheme := runtime.NewScheme() + _ = quotav1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + fakeRuntimeClient = ctrlclientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(crq, namespace1, namespace2, namespace3, existingSvc1, existingSvc2, existingSvc3). + Build() + crqClient = quota.NewCRQClient(fakeRuntimeClient) + webhook = NewServiceWebhook(fakeClient, crqClient, logger) + ginEngine = gin.New() + ginEngine.POST("/webhook", webhook.Handle) + + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-svc", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + + Expect(response.Response.Allowed).To(BeTrue()) + }) + + }) + + // Service admission tests for all service resource types and quota scenarios + Describe("Service admission", func() { + var ( + fakeClient *fake.Clientset + fakeRuntimeClient client.Client + ginEngine *gin.Engine + crq *quotav1alpha1.ClusterResourceQuota + ) + + BeforeEach(func() { + // Namespaces + ns1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-1", + Labels: map[string]string{"environment": "test"}, + }, + } + ns2 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-2", + Labels: map[string]string{"environment": "test"}, + }, + } + ns3 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-3", + Labels: map[string]string{"environment": "production"}, + }, + } + // CRQ for all service types + crq = "av1alpha1.ClusterResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-crq", + }, + Spec: quotav1alpha1.ClusterResourceQuotaSpec{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "environment": "test", + }, + }, + Hard: quotav1alpha1.ResourceList{ + corev1.ResourceServices: resource.MustParse("5"), + corev1.ResourceServicesNodePorts: resource.MustParse("2"), + corev1.ResourceServicesLoadBalancers: resource.MustParse("1"), + }, + }, + } + fakeClient = fake.NewSimpleClientset(ns1, ns2, ns3) + scheme := runtime.NewScheme() + _ = quotav1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + fakeRuntimeClient = ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(crq, ns1, ns2, ns3).Build() + crqClient = quota.NewCRQClient(fakeRuntimeClient) + logger = zap.NewNop() + ginEngine = gin.New() + webhook = &ServiceWebhook{ + client: fakeClient, + serviceCalculator: services.NewServiceResourceCalculator(fakeClient), + crqClient: crqClient, + log: logger, + } + ginEngine.POST("/webhook", webhook.Handle) + }) + + AfterEach(func() { + fakeClient = nil + fakeRuntimeClient = nil + crqClient = nil + logger = nil + ginEngine = nil + }) + + It("should allow creation of a ClusterIP service within quota", func() { + // No existing services, quota is 5 + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-clusterip", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + }, + } + fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-svc-1", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 81, TargetPort: intstr.FromInt(8081)}}, + }, + }, metav1.CreateOptions{}) + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + + It("should reject creation of a NodePort service if NodePort quota exceeded", func() { + // Add two NodePort services to reach the quota (quota is 2) + fakeClient.CoreV1().Services("test-ns-1").Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-nodeport-1", + Namespace: "test-ns-1", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Name: "http", Port: 82, TargetPort: intstr.FromInt(8082)}}, + }, + }, metav1.CreateOptions{}) + fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-nodeport-2", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Name: "http", Port: 83, TargetPort: intstr.FromInt(8083)}}, + }, + }, metav1.CreateOptions{}) + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-nodeport", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Name: "http", Port: 84, TargetPort: intstr.FromInt(8084)}}, + }, + } + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("service count validation failed")) + }) + + It("should allow creation of an ExternalName service within quota", func() { + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-externalname", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: "example.com", + }, + } + // Add a ClusterIP service to test-ns-2 to ensure quota logic is exercised + fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-svc-ext", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 87, TargetPort: intstr.FromInt(8087)}}, + }, + }, metav1.CreateOptions{}) + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + + It("should reject creation of a LoadBalancer service if quota exceeded", func() { + // Add a LoadBalancer service to reach the quota (quota is 1) + fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-lb", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Name: "http", Port: 88, TargetPort: intstr.FromInt(8088)}}, + }, + }, metav1.CreateOptions{}) + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-loadbalancer", + Namespace: "test-ns-2", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Name: "http", Port: 89, TargetPort: intstr.FromInt(8089)}}, + }, + } + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeFalse()) + Expect(response.Response.Result.Message).To(ContainSubstring("service count validation failed")) + }) + + It("should allow creation of a service in a namespace not matching CRQ selector", func() { + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unmatched-svc", + Namespace: "test-ns-3", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 85, TargetPort: intstr.FromInt(8085)}}, + }, + } + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(ginEngine, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + + It("should allow creation when no quota set", func() { + // Remove quota for services + crq.Spec.Hard = nil + // Use a fresh Gin engine to avoid handler registration panic + scheme := runtime.NewScheme() + _ = quotav1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + ns1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns-1", + Labels: map[string]string{"environment": "test"}, + }, + } + fakeRuntimeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(crq, ns1).Build() + crqClient := quota.NewCRQClient(fakeRuntimeClient) + webhook := &ServiceWebhook{ + client: fakeClient, + serviceCalculator: services.NewServiceResourceCalculator(fakeClient), + crqClient: crqClient, + log: logger, + } + freshGin := gin.New() + freshGin.POST("/webhook", webhook.Handle) + newSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-quota-svc", + Namespace: "test-ns-1", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Name: "http", Port: 86, TargetPort: intstr.FromInt(8086)}}, + }, + } + admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) + response := sendWebhookRequest(freshGin, admissionReview) + Expect(response.Response.Allowed).To(BeTrue()) + }) + + // Add more tests for error paths, CRQ lookup errors, and edge cases as needed + }) + }) +}) + +// Helper functions for testing +func createServiceAdmissionReview(service *corev1.Service, operation admissionv1.Operation) *admissionv1.AdmissionReview { + raw, _ := json.Marshal(service) + return &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Resource: metav1.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "services", + }, + Operation: operation, + Object: runtime.RawExtension{ + Raw: raw, + }, + }, + } +} diff --git a/pkg/webhook/v1alpha1/webhook_utils.go b/pkg/webhook/v1alpha1/webhook_utils.go index 1c6b49d..3be51e6 100644 --- a/pkg/webhook/v1alpha1/webhook_utils.go +++ b/pkg/webhook/v1alpha1/webhook_utils.go @@ -1,19 +1,34 @@ package v1alpha1 import ( + "bytes" "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "go.uber.org/zap" + admissionv1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + "github.com/gin-gonic/gin" quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/namespace" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" ) +type operation string + +const ( + OperationCreate operation = "creation" + OperationUpdate operation = "update" + OperationDelete operation = "deletion" +) + // validateCRQResourceQuotaWithNamespace is a shared function for validating resource quotas // across webhooks with actual namespace object func validateCRQResourceQuotaWithNamespace( @@ -26,7 +41,6 @@ func validateCRQResourceQuotaWithNamespace( calculateCurrentUsage func(string, corev1.ResourceName) (resource.Quantity, error), log *zap.Logger, ) error { - // If no CRQ client is available, skip validation if crqClient == nil { log.Info("Skipping CRQ validation - no CRQ client available", zap.String("namespace", ns.Name), @@ -136,6 +150,12 @@ func calculateCRQCurrentUsage( return resource.Quantity{}, fmt.Errorf("failed to get namespaces matching CRQ selector: %w", err) } + // DEBUG: Print the namespaces selected by the CRQ's selector + log.Info("DEBUG: Namespaces selected by CRQ selector", + zap.String("crq", crq.Name), + zap.Strings("selected_namespaces", namespaceNames), + zap.String("resource", string(resourceName))) + log.Info("Calculating usage across CRQ namespaces", zap.String("crq", crq.Name), zap.String("resource", string(resourceName)), @@ -168,3 +188,26 @@ func calculateCRQCurrentUsage( return *totalUsage, nil } + +func sendWebhookRequest(engine *gin.Engine, admissionReview *admissionv1.AdmissionReview) *admissionv1.AdmissionReview { + body, _ := json.Marshal(admissionReview) + req, _ := http.NewRequest("POST", "/webhook", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + var response admissionv1.AdmissionReview + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + // If unmarshaling fails, create a default response + response = admissionv1.AdmissionReview{ + Response: &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: "Failed to parse response", + }, + }, + } + } + return &response +} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index b55f0ae..2dac44e 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -19,6 +19,7 @@ package e2e import ( "context" "fmt" + "os/exec" "testing" . "github.com/onsi/ginkgo/v2" @@ -71,4 +72,13 @@ var _ = BeforeSuite(func() { }) var _ = AfterSuite(func() { + By("Dumping pac-quota-controller logs before cluster teardown") + // Try to get logs from all pods in the pac-quota-controller-system namespace + cmd := exec.Command("kubectl", "-n", "pac-quota-controller-system", "logs", "-l", "app.kubernetes.io/name=pac-quota-controller", "--tail=1000", "--ignore-errors=true") + logs, err := cmd.CombinedOutput() + if err == nil { + GinkgoWriter.Printf("\n--- pac-quota-controller logs ---\n%s\n", string(logs)) + } else { + GinkgoWriter.Printf("\n[WARN] Failed to get pac-quota-controller logs: %v\n", err) + } }) diff --git a/test/e2e/service_webhook_test.go b/test/e2e/service_webhook_test.go new file mode 100644 index 0000000..75c1f44 --- /dev/null +++ b/test/e2e/service_webhook_test.go @@ -0,0 +1,274 @@ +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" + testutils "github.com/powerhome/pac-quota-controller/test/utils" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("Service Quota Webhook", func() { + var ( + testNamespace string + testCRQName string + testSuffix string + ns *corev1.Namespace + crq *quotav1alpha1.ClusterResourceQuota + ) + + BeforeEach(func() { + testSuffix = testutils.GenerateTestSuffix() + testNamespace = testutils.GenerateResourceName("service-webhook-ns-" + testSuffix) + testCRQName = testutils.GenerateResourceName("service-webhook-crq-" + testSuffix) + + var err error + // Use a unique label key and value for each test to avoid selector collisions + uniqueLabelKey := "service-webhook-test-" + testSuffix + uniqueLabelValue := "test-label-" + testSuffix + ns, err = testutils.CreateNamespace(ctx, k8sClient, testNamespace, map[string]string{ + uniqueLabelKey: uniqueLabelValue, + }) + Expect(err).NotTo(HaveOccurred()) + + + + crq, err = testutils.CreateClusterResourceQuota(ctx, k8sClient, testCRQName, &metav1.LabelSelector{ + MatchLabels: map[string]string{ + uniqueLabelKey: uniqueLabelValue, + }, + }, quotav1alpha1.ResourceList{ + corev1.ResourceName("services"): resource.MustParse("2"), + corev1.ResourceName("services.loadbalancers"): resource.MustParse("1"), + corev1.ResourceName("services.nodeports"): resource.MustParse("1"), + }) + Expect(err).NotTo(HaveOccurred()) + + + + // Wait for CRQ status to include the test namespace before proceeding + By("Waiting for CRQ status to include the test namespace") + err = testutils.WaitForCRQStatus(ctx, k8sClient, testCRQName, []string{testNamespace} /*timeout*/, 60*1e9 /*interval*/, 1*1e9) + Expect(err).NotTo(HaveOccurred(), "CRQ status did not include the test namespace in time; check CRQ selector and namespace labels") + }) + + AfterEach(func() { + // Clean up all services in the test namespace except the default 'kubernetes' service + svcList := &corev1.ServiceList{} + err := k8sClient.List(ctx, svcList, ctrlclient.InNamespace(testNamespace)) + Expect(err).NotTo(HaveOccurred()) + for _, svc := range svcList.Items { + if svc.Name == "kubernetes" { + continue + } + _ = k8sClient.Delete(ctx, &svc) + } + DeferCleanup(func() { + _ = k8sClient.Delete(ctx, ns) + _ = k8sClient.Delete(ctx, crq) + }) + }) + + Context("Service Creation Webhook", func() { + // ClusterIP services are not counted for quota, so only test creation is allowed (no denial test) + It("should allow ClusterIP service creation (not counted for quota)", func() { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-1-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + err := k8sClient.Create(ctx, service) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow NodePort service creation within quota limits", func() { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nodeport-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Port: 30000, NodePort: 30001}}, + }, + } + err := k8sClient.Create(ctx, service) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should deny NodePort service creation when exceeding nodeport quota", func() { + // Create one NodePort service to reach the nodeport quota + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nodeport-1-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Port: 30000, NodePort: 30001}}, + }, + } + Expect(k8sClient.Create(ctx, service)).To(Succeed()) + // Second NodePort service should be denied + service2 := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nodeport-2-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Port: 30002, NodePort: 30003}}, + }, + } + err := k8sClient.Create(ctx, service2) + Expect(err).To(HaveOccurred()) + }) + + It("should allow LoadBalancer service creation within quota limits", func() { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lb-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + err := k8sClient.Create(ctx, service) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should deny LoadBalancer service creation when exceeding loadbalancer quota", func() { + // Create one LoadBalancer service to reach the quota + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lb-1-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + Expect(k8sClient.Create(ctx, service)).To(Succeed()) + // Second LoadBalancer service should be denied + service2 := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lb-2-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + err := k8sClient.Create(ctx, service2) + Expect(err).To(HaveOccurred()) + }) + + // ExternalName services are not counted for quota, so only test creation is allowed (no denial test) + It("should allow ExternalName service creation (not counted for quota)", func() { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-externalname-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: "example.com", + }, + } + err := k8sClient.Create(ctx, service) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow service creation in namespace not matching CRQ selector", func() { + // Create a namespace with a label that does not match the CRQ selector + otherNsName := testutils.GenerateResourceName("service-webhook-other-ns-" + testSuffix) + // Use a unique label key for the non-matching namespace as well + // Use a unique label key/value for the non-matching namespace to ensure it does not match the CRQ selector + otherNs, err := testutils.CreateNamespace(ctx, k8sClient, otherNsName, map[string]string{ + "service-webhook-other-test-" + testSuffix: "other-label-value-" + testSuffix, + }) + Expect(err).NotTo(HaveOccurred()) + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-other-" + testSuffix, + Namespace: otherNsName, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + err = k8sClient.Create(ctx, service) + Expect(err).NotTo(HaveOccurred()) + _ = k8sClient.Delete(ctx, otherNs) + }) + + It("should allow service creation when no CRQ exists", func() { + // Create a namespace with a unique label + noCrqNsName := testutils.GenerateResourceName("service-webhook-nocrq-ns-" + testSuffix) + // Use a unique label key/value for the no-CRQ namespace to ensure it does not match any CRQ selector + noCrqNs, err := testutils.CreateNamespace(ctx, k8sClient, noCrqNsName, map[string]string{ + "service-webhook-nocrq-test-" + testSuffix: "nocrq-label-value-" + testSuffix, + }) + Expect(err).NotTo(HaveOccurred()) + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-nocrq-" + testSuffix, + Namespace: noCrqNsName, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + err = k8sClient.Create(ctx, service) + Expect(err).NotTo(HaveOccurred()) + _ = k8sClient.Delete(ctx, noCrqNs) + }) + + It("should deny updating a service to a type that exceeds subtype quota", func() { + // Create a ClusterIP service (within quota) + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-update-service-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + Expect(k8sClient.Create(ctx, service)).To(Succeed()) + // Fill the loadbalancer quota + lbService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-update-lb-" + testSuffix, + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 80}}, + }, + } + Expect(k8sClient.Create(ctx, lbService)).To(Succeed()) + // Try to update the ClusterIP service to LoadBalancer (should fail) + var fetched corev1.Service + Expect(k8sClient.Get(ctx, ctrlclient.ObjectKey{Name: service.Name, Namespace: testNamespace}, &fetched)).To(Succeed()) + fetched.Spec.Type = corev1.ServiceTypeLoadBalancer + err := k8sClient.Update(ctx, &fetched) + Expect(err).To(HaveOccurred()) + }) + }) +}) From b20115de9546ec695146a41c7a64abe3449715ef Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:17:44 -0300 Subject: [PATCH 2/7] chore: remove debug options --- internal/controller/clusterresourcequota_controller.go | 1 - pkg/kubernetes/services/service.go | 5 ----- pkg/webhook/v1alpha1/webhook_utils.go | 6 ------ test/e2e/e2e_suite_test.go | 10 ---------- test/e2e/service_webhook_test.go | 4 ---- 5 files changed, 26 deletions(-) diff --git a/internal/controller/clusterresourcequota_controller.go b/internal/controller/clusterresourcequota_controller.go index 7af4692..1be22ad 100644 --- a/internal/controller/clusterresourcequota_controller.go +++ b/internal/controller/clusterresourcequota_controller.go @@ -181,7 +181,6 @@ func (r *ClusterResourceQuotaReconciler) Reconcile(ctx context.Context, req ctrl } log.Info("Found namespaces matching selection criteria", "count", len(selectedNamespaces), "namespaces", selectedNamespaces) - fmt.Printf("[DEBUG] selectedNamespaces after selector: %#v\n", selectedNamespaces) // Calculate aggregated resource usage across all selected namespaces totalUsage, usageByNamespace := r.calculateAndAggregateUsage(ctx, crq, selectedNamespaces) diff --git a/pkg/kubernetes/services/service.go b/pkg/kubernetes/services/service.go index 74f8580..c92dc9c 100644 --- a/pkg/kubernetes/services/service.go +++ b/pkg/kubernetes/services/service.go @@ -2,7 +2,6 @@ package services import ( "context" - "fmt" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" corev1 "k8s.io/api/core/v1" @@ -81,13 +80,9 @@ func (c *ServiceResourceCalculator) countServicesByType(ctx context.Context, nam corev1.ServiceTypeNodePort: 0, corev1.ServiceTypeLoadBalancer: 0, } - // DEBUG: Print all services being counted - fmt.Printf("[DEBUG] Counting services in namespace %s:\n", namespace) for _, svc := range serviceList.Items { - fmt.Printf("[DEBUG] SERVICE: %s/%s type=%s\n", svc.Namespace, svc.Name, svc.Spec.Type) byType[svc.Spec.Type]++ } total = int64(len(serviceList.Items)) - fmt.Printf("[DEBUG] Total services counted: %d\n", total) return total, byType, nil } diff --git a/pkg/webhook/v1alpha1/webhook_utils.go b/pkg/webhook/v1alpha1/webhook_utils.go index 3be51e6..ebe2810 100644 --- a/pkg/webhook/v1alpha1/webhook_utils.go +++ b/pkg/webhook/v1alpha1/webhook_utils.go @@ -150,12 +150,6 @@ func calculateCRQCurrentUsage( return resource.Quantity{}, fmt.Errorf("failed to get namespaces matching CRQ selector: %w", err) } - // DEBUG: Print the namespaces selected by the CRQ's selector - log.Info("DEBUG: Namespaces selected by CRQ selector", - zap.String("crq", crq.Name), - zap.Strings("selected_namespaces", namespaceNames), - zap.String("resource", string(resourceName))) - log.Info("Calculating usage across CRQ namespaces", zap.String("crq", crq.Name), zap.String("resource", string(resourceName)), diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 2dac44e..b55f0ae 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -19,7 +19,6 @@ package e2e import ( "context" "fmt" - "os/exec" "testing" . "github.com/onsi/ginkgo/v2" @@ -72,13 +71,4 @@ var _ = BeforeSuite(func() { }) var _ = AfterSuite(func() { - By("Dumping pac-quota-controller logs before cluster teardown") - // Try to get logs from all pods in the pac-quota-controller-system namespace - cmd := exec.Command("kubectl", "-n", "pac-quota-controller-system", "logs", "-l", "app.kubernetes.io/name=pac-quota-controller", "--tail=1000", "--ignore-errors=true") - logs, err := cmd.CombinedOutput() - if err == nil { - GinkgoWriter.Printf("\n--- pac-quota-controller logs ---\n%s\n", string(logs)) - } else { - GinkgoWriter.Printf("\n[WARN] Failed to get pac-quota-controller logs: %v\n", err) - } }) diff --git a/test/e2e/service_webhook_test.go b/test/e2e/service_webhook_test.go index 75c1f44..c668160 100644 --- a/test/e2e/service_webhook_test.go +++ b/test/e2e/service_webhook_test.go @@ -34,8 +34,6 @@ var _ = Describe("Service Quota Webhook", func() { }) Expect(err).NotTo(HaveOccurred()) - - crq, err = testutils.CreateClusterResourceQuota(ctx, k8sClient, testCRQName, &metav1.LabelSelector{ MatchLabels: map[string]string{ uniqueLabelKey: uniqueLabelValue, @@ -47,8 +45,6 @@ var _ = Describe("Service Quota Webhook", func() { }) Expect(err).NotTo(HaveOccurred()) - - // Wait for CRQ status to include the test namespace before proceeding By("Waiting for CRQ status to include the test namespace") err = testutils.WaitForCRQStatus(ctx, k8sClient, testCRQName, []string{testNamespace} /*timeout*/, 60*1e9 /*interval*/, 1*1e9) From 8387a21b6aeb913d56e2200d427c1a3306ab7ccb Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:21:03 -0300 Subject: [PATCH 3/7] chore: remove implementation guideline --- docs/object-count-feature-plan.md | 123 ------------------------------ 1 file changed, 123 deletions(-) delete mode 100644 docs/object-count-feature-plan.md diff --git a/docs/object-count-feature-plan.md b/docs/object-count-feature-plan.md deleted file mode 100644 index d75c802..0000000 --- a/docs/object-count-feature-plan.md +++ /dev/null @@ -1,123 +0,0 @@ -# ClusterResourceQuota Object Count Feature Implementation Plan - -## Overview - -This document tracks the step-by-step implementation plan for adding object count support for core and extended native Kubernetes resources to the ClusterResourceQuota (CRQ) controller. The plan is broken down into the smallest actionable steps, with clarifications and requirements noted. - -## Rules & Principles - -- **No assumptions:** Ask for clarification when needed. -- **No core structure changes:** Follow existing project patterns. -- **No code repetition:** Reuse logic and abstractions. -- **Interfaces & structures:** Use interfaces for testability and maintainability. -- **No custom CRDs:** Only native/extended Kubernetes resources. -- **Testing:** Strict unit and e2e coverage. - -## Step-by-Step Plan - -### 0. Planning & Tracking - -- [ ] Create this implementation plan as a Markdown file in the repo (`docs/object-count-feature-plan.md`). -- [ ] Update the plan as work progresses. - -### 1. Resource Inventory - -### Native Kubernetes Resource Types (for object counting) - -- Pod -- PersistentVolumeClaim (PVC) -- Service -- ConfigMap -- Secret -- ReplicationController -- ReplicaSet -- Deployment -- StatefulSet -- DaemonSet -- Job -- CronJob -- EndpointSlice -- Endpoints -- Ingress -- ServiceAccount -- Lease -- Event - -#### Extended/Subtype Resources - -##### Explicit Extended Resource Types (for object counting) - -- services.loadbalancers (Service objects with type=LoadBalancer) -- services.nodeports (Service objects with type=NodePort) -- ingresses (Ingress objects) -- services.externalname (Service objects with type=ExternalName) -- services.clusterip (Service objects with type=ClusterIP) - -**Note:** These keys are for quota specification and status reporting. The implementation should ensure that subtype counts do not exceed the total for the parent resource (e.g., services.loadbalancers ≤ services). - -**Note:** Custom CRDs are explicitly excluded. - -User will trim or adjust this list as needed before implementation. - -### 2. API & CRD Changes - -- [ ] Update the CRQ API spec to allow specifying object count quotas for the selected resources. -- [ ] Update CRD YAML in Helm chart. -- [ ] Update Helm chart documentation (`README.md.gotmpl`, `values.yaml`). -- [ ] Run `make generate` and `make helm-docs`. - -### 3. Controller Logic - -- [ ] Implement logic to count objects for each supported resource type across namespaces. -- [ ] Update reconciliation loop to aggregate and update usage in CRQ status. -- [ ] Ensure code is modular and testable (interfaces, etc.). -- [ ] Add/extend unit tests for new logic. - -### 4. Admission Webhook - -- [ ] Update webhook to validate create/update requests for supported resources against CRQ limits. -- [ ] Implement live calculation to block/prevent over-quota deployments. -- [ ] Add/extend unit tests for webhook logic. - -### 5. Controller Watches - -- [ ] Update controller to watch for changes to the new resource types (controller-gen). -- [ ] Ensure watches are efficient and follow project patterns. - -### 6. Helm Chart & Docs - -- [ ] Update Helm chart templates and documentation for new CRQ fields and behaviors. -- [ ] Ensure CRD, RBAC, and values are up-to-date. -- [ ] Run `make helm-docs` and `make helm-lint`. - -### 7. Testing - -- [ ] Add/extend unit tests for all new logic (controller, webhook, utils). -- [ ] List use-cases for e2e tests in this plan (before implementation). -- [ ] Implement e2e tests for all critical scenarios. -- [ ] Ensure all tests pass (`make test`, `make test-e2e`). - -### 8. Review & Finalization - -- [ ] Review code for adherence to project principles. -- [ ] Update this plan and project documentation as needed. -- [ ] Prepare for PR review (conventional commits, detailed PR description). - -## Clarifications Needed - -### Clarifications (2025-09-18) - -- **Resource List:** Support all native Kubernetes resources (core and extended). User will trim the list as needed after initial implementation. -- **Scope:** Object counting is both namespace-scoped and cluster-scoped. Blocking logic is based on cluster-scope, as in current CRQ usage. -- **Extended Resources:** For resources like Service type=LoadBalancer, support both total and subtype (e.g., max services, max loadbalancers). Validation should ensure subtypes do not exceed total. -- **Active Objects:** Only active objects are counted. For pods, this is already implemented (terminal pods are excluded). For other resources, count all existing objects unless otherwise specified by Kubernetes conventions. -- **Performance:** No special performance or scalability requirements for large clusters. - ---- - -## Use-Case List for E2E Tests (to be completed before implementation) - -- [ ] To be filled after resource list and API finalized. - ---- -*This plan will be updated as the implementation progresses and as clarifications are provided.* From 30fe246b900594c54d55c4576534d542a7c9ffad Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Tue, 23 Sep 2025 18:07:34 -0300 Subject: [PATCH 4/7] feat(objectcount): implement object count logic --- charts/pac-quota-controller/README.md | 52 ++- charts/pac-quota-controller/README.md.gotmpl | 59 +++- ....powerapp.cloud_clusterresourcequotas.yaml | 32 +- .../templates/rbac/role.yaml | 36 ++ .../validatingwebhookconfiguration.yaml | 42 +++ charts/pac-quota-controller/values.yaml | 29 +- .../clusterresourcequota_controller.go | 49 ++- pkg/kubernetes/objectcount/objectcount.go | 81 +++++ .../objectcount/objectcount_suite_test.go | 13 + .../objectcount/objectcount_test.go | 111 ++++++ pkg/kubernetes/pod/pod.go | 10 +- pkg/kubernetes/pod/pod_suite_test.go | 16 - pkg/kubernetes/quota/quota.go | 3 + pkg/kubernetes/usage/usage.go | 21 +- pkg/webhook/server/server.go | 10 + pkg/webhook/v1alpha1/objectcount_webhook.go | 172 +++++++++ .../v1alpha1/objectcount_webhook_test.go | 329 ++++++++++++++++++ pkg/webhook/v1alpha1/webhook_utils.go | 2 +- test/e2e/clusterresourcequota_webhook_test.go | 1 - test/e2e/controller_reconcile_test.go | 22 +- test/e2e/objectcount_webhook_test.go | 322 +++++++++++++++++ test/utils/helpers.go | 160 ++++++++- 22 files changed, 1486 insertions(+), 86 deletions(-) create mode 100644 pkg/kubernetes/objectcount/objectcount.go create mode 100644 pkg/kubernetes/objectcount/objectcount_suite_test.go create mode 100644 pkg/kubernetes/objectcount/objectcount_test.go create mode 100644 pkg/webhook/v1alpha1/objectcount_webhook.go create mode 100644 pkg/webhook/v1alpha1/objectcount_webhook_test.go create mode 100644 test/e2e/objectcount_webhook_test.go diff --git a/charts/pac-quota-controller/README.md b/charts/pac-quota-controller/README.md index 880d246..bcf4095 100644 --- a/charts/pac-quota-controller/README.md +++ b/charts/pac-quota-controller/README.md @@ -1,6 +1,6 @@ # pac-quota-controller -![Version: 0.1.2](https://img.shields.io/badge/Version-0.1.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.1.2](https://img.shields.io/badge/AppVersion-0.1.2-informational?style=flat-square) +![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.2.0](https://img.shields.io/badge/AppVersion-0.2.0-informational?style=flat-square) A Helm chart for PAC Quota Controller - Managing cluster resource quotas across namespaces @@ -18,12 +18,60 @@ This chart bootstraps a [PAC Quota Controller](https://github.com/powerhome/pac- The PAC Quota Controller extends Kubernetes with a ClusterResourceQuota custom resource that allows defining resource quotas that span multiple namespaces. +### Object Count Quotas (Native & Extended Resources) + +You can specify object count quotas for native and extended Kubernetes resources using the `hard` field in the ClusterResourceQuota spec. + +#### Supported object count resources + +- `pods` (Pod count) +- `services` (Service count) +- `services.loadbalancers` (Service type=LoadBalancer count) +- `services.nodeports` (Service type=NodePort count) +- `configmaps` (ConfigMap count) +- `secrets` (Secret count) +- `persistentvolumeclaims` (PVC count) +- `replicationcontrollers` (ReplicationController count) +- `deployments.apps` (Deployment count) +- `statefulsets.apps` (StatefulSet count) +- `daemonsets.apps` (DaemonSet count) +- `jobs.batch` (Job count) +- `cronjobs.batch` (CronJob count) +- `horizontalpodautoscalers.autoscaling` (HPA count) +- `ingresses.networking.k8s.io` (Ingress count) + +Subtype quotas (e.g., `services.loadbalancers`) cannot exceed the total for the parent resource (e.g., `services`). + +Custom CRDs are not supported for object count quotas. + +#### Example + +```yaml +spec: + hard: + pods: "10" # Pod count + services: "5" # Service count + services.loadbalancers: "2" # Service type=LoadBalancer count + services.nodeports: "3" # Service type=NodePort count + configmaps: "20" # ConfigMap count + secrets: "15" # Secret count + persistentvolumeclaims: "8" # PVC count + replicationcontrollers: "4" # ReplicationController count + deployments.apps: "6" # Deployment count + statefulsets.apps: "2" # StatefulSet count + daemonsets.apps: "2" # DaemonSet count + jobs.batch: "5" # Job count + cronjobs.batch: "3" # CronJob count + horizontalpodautoscalers.autoscaling: "2" # HPA count + ingresses.networking.k8s.io: "3" # Ingress count +``` + ### Container Images This chart can use container images from GitHub Container Registry: ```console -ghcr.io/powerhome/pac-quota-controller:0.1.2 +ghcr.io/powerhome/pac-quota-controller:0.2.0 ``` You can configure which registry to use by modifying the `controllerManager.container.image.repository` value. diff --git a/charts/pac-quota-controller/README.md.gotmpl b/charts/pac-quota-controller/README.md.gotmpl index 19f1874..4d6258f 100644 --- a/charts/pac-quota-controller/README.md.gotmpl +++ b/charts/pac-quota-controller/README.md.gotmpl @@ -23,31 +23,52 @@ The PAC Quota Controller extends Kubernetes with a ClusterResourceQuota custom r ### Object Count Quotas (Native & Extended Resources) -You can specify object count quotas for native and extended Kubernetes resources using the `hard` field in the ClusterResourceQuota spec. Examples: +You can specify object count quotas for native and extended Kubernetes resources using the `hard` field in the ClusterResourceQuota spec. + +#### Supported object count resources + +- `pods` (Pod count) +- `services` (Service count) +- `services.loadbalancers` (Service type=LoadBalancer count) +- `services.nodeports` (Service type=NodePort count) +- `configmaps` (ConfigMap count) +- `secrets` (Secret count) +- `persistentvolumeclaims` (PVC count) +- `replicationcontrollers` (ReplicationController count) +- `deployments.apps` (Deployment count) +- `statefulsets.apps` (StatefulSet count) +- `daemonsets.apps` (DaemonSet count) +- `jobs.batch` (Job count) +- `cronjobs.batch` (CronJob count) +- `horizontalpodautoscalers.autoscaling` (HPA count) +- `ingresses.networking.k8s.io` (Ingress count) + +Subtype quotas (e.g., `services.loadbalancers`) cannot exceed the total for the parent resource (e.g., `services`). + +Custom CRDs are not supported for object count quotas. + +#### Example ```yaml spec: hard: - pods: "10" # Pod count - services: "5" # Service count - services.loadbalancers: "2" # Service type=LoadBalancer count - ingresses: "3" # Ingress count - configmaps: "20" # ConfigMap count - # ...and so on for all supported resource types + pods: "10" # Pod count + services: "5" # Service count + services.loadbalancers: "2" # Service type=LoadBalancer count + services.nodeports: "3" # Service type=NodePort count + configmaps: "20" # ConfigMap count + secrets: "15" # Secret count + persistentvolumeclaims: "8" # PVC count + replicationcontrollers: "4" # ReplicationController count + deployments.apps: "6" # Deployment count + statefulsets.apps: "2" # StatefulSet count + daemonsets.apps: "2" # DaemonSet count + jobs.batch: "5" # Job count + cronjobs.batch: "3" # CronJob count + horizontalpodautoscalers.autoscaling: "2" # HPA count + ingresses.networking.k8s.io: "3" # Ingress count ``` -Supported extended resource keys include: - -- `services.loadbalancers` (Service objects with type=LoadBalancer) -- `services.nodeports` (Service objects with type=NodePort) -- `ingresses` (Ingress objects) -- `services.externalname` (Service objects with type=ExternalName) -- `services.clusterip` (Service objects with type=ClusterIP) - -Subtype quotas (e.g., `services.loadbalancers`) cannot exceed the total for the parent resource (e.g., `services`). - -Custom CRDs are not supported for object count quotas. - ### Container Images This chart can use container images from GitHub Container Registry: diff --git a/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml b/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml index ab2e911..4ac2588 100755 --- a/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml +++ b/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml @@ -28,6 +28,25 @@ spec: ClusterResourceQuota is the Schema for the clusterresourcequotas API. It extends the standard Kubernetes ResourceQuota by allowing it to be applied across multiple namespaces that match a label selector. + + Supported object count resources (for use in the 'hard' and 'used' fields): + - pods + - services + - services.loadbalancers + - services.nodeports + - configmaps + - secrets + - persistentvolumeclaims + - replicationcontrollers + - deployments.apps + - statefulsets.apps + - daemonsets.apps + - jobs.batch + - cronjobs.batch + - horizontalpodautoscalers.autoscaling + - ingresses.networking.k8s.io + + You may specify quotas for any of these resources. See the Helm chart documentation for details and examples. properties: apiVersion: description: |- @@ -62,8 +81,19 @@ spec: 'pods': '10' (Pod count) 'services': '5' (Service count) 'services.loadbalancers': '2' (Service type=LoadBalancer count) - 'ingresses': '3' (Ingress count) + 'services.nodeports': '3' (Service type=NodePort count) 'configmaps': '20' (ConfigMap count) + 'secrets': '15' (Secret count) + 'persistentvolumeclaims': '8' (PVC count) + 'replicationcontrollers': '4' (ReplicationController count) + 'deployments.apps': '6' (Deployment count) + 'statefulsets.apps': '2' (StatefulSet count) + 'daemonsets.apps': '2' (DaemonSet count) + 'jobs.batch': '5' (Job count) + 'cronjobs.batch': '3' (CronJob count) + 'horizontalpodautoscalers.autoscaling': '2' (HPA count) + 'ingresses.networking.k8s.io': '3' (Ingress count) + ...and so on for all supported native and extended resource types. type: object namespaceSelector: diff --git a/charts/pac-quota-controller/templates/rbac/role.yaml b/charts/pac-quota-controller/templates/rbac/role.yaml index 3263136..e6c6935 100755 --- a/charts/pac-quota-controller/templates/rbac/role.yaml +++ b/charts/pac-quota-controller/templates/rbac/role.yaml @@ -16,6 +16,42 @@ rules: - pods - secrets - services + - replicationcontrollers + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + - statefulsets + - daemonsets + verbs: + - get + - list + - watch +- apiGroups: + - batch + resources: + - jobs + - cronjobs + verbs: + - get + - list + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - get + - list + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses verbs: - get - list diff --git a/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml b/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml index f0a9a5c..d1ca1f0 100644 --- a/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml +++ b/charts/pac-quota-controller/templates/webhook/validatingwebhookconfiguration.yaml @@ -140,4 +140,46 @@ webhooks: {{- range (include "pacQuota.excludedNamespacesList" . | splitList " ") }} - {{ . | quote }} {{- end }} + - name: vobjectcount-v1alpha1.powerapp.cloud + admissionReviewVersions: ["v1"] + sideEffects: None + failurePolicy: Fail + timeoutSeconds: 30 + clientConfig: + {{- if not .Values.certmanager.enable }} + caBundle: {{ .Values.webhook.customTLS.caBundle }} + {{- end }} + service: + name: pac-quota-controller-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-objectcount-v1 + rules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["configmaps", "secrets", "replicationcontrollers"] + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments", "statefulsets", "daemonsets"] + - apiGroups: ["batch"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["jobs", "cronjobs"] + - apiGroups: ["autoscaling"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["horizontalpodautoscalers"] + - apiGroups: ["networking.k8s.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["ingresses"] + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: NotIn + values: + {{- range (include "pacQuota.excludedNamespacesList" . | splitList " ") }} + - {{ . | quote }} + {{- end }} {{- end }} diff --git a/charts/pac-quota-controller/values.yaml b/charts/pac-quota-controller/values.yaml index 3030e7f..8257b59 100644 --- a/charts/pac-quota-controller/values.yaml +++ b/charts/pac-quota-controller/values.yaml @@ -3,20 +3,25 @@ controllerManager: # Object Count Quotas (Native & Extended Resources) # # You can specify object count quotas for native and extended Kubernetes resources using the 'hard' field in the ClusterResourceQuota spec. - # Examples: # - # hard: - # pods: "10" # Pod count - # services: "5" # Service count - # services.loadbalancers: "2" # Service type=LoadBalancer count - # ingresses: "3" # Ingress count - # configmaps: "20" # ConfigMap count - # # ...and so on for all supported resource types + # Example configuration for all supported object count resources: # - # Supported extended resource keys include: - # - services.loadbalancers (Service objects with type=LoadBalancer) - # - services.nodeports (Service objects with type=NodePort) - # - ingresses (Ingress objects) + # hard: + # pods: "10" # Pod count + # services: "5" # Service count + # services.loadbalancers: "2" # Service type=LoadBalancer count + # services.nodeports: "3" # Service type=NodePort count + # configmaps: "20" # ConfigMap count + # secrets: "15" # Secret count + # persistentvolumeclaims: "8" # PVC count + # replicationcontrollers: "4" # ReplicationController count + # deployments.apps: "6" # Deployment count + # statefulsets.apps: "2" # StatefulSet count + # daemonsets.apps: "2" # DaemonSet count + # jobs.batch: "5" # Job count + # cronjobs.batch: "3" # CronJob count + # horizontalpodautoscalers.autoscaling: "2" # HPA count + # ingresses.networking.k8s.io: "3" # Ingress count # # Subtype quotas (e.g., services.loadbalancers) cannot exceed the total for the parent resource (e.g., services). # diff --git a/internal/controller/clusterresourcequota_controller.go b/internal/controller/clusterresourcequota_controller.go index 1be22ad..996fd8e 100644 --- a/internal/controller/clusterresourcequota_controller.go +++ b/internal/controller/clusterresourcequota_controller.go @@ -24,12 +24,17 @@ import ( "strings" quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/objectcount" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/pod" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/services" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/storage" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" + appsv1 "k8s.io/api/apps/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -106,6 +111,7 @@ func (resourceUpdatePredicate) Delete(e event.DeleteEvent) bool { type ClusterResourceQuotaReconciler struct { client.Client Scheme *runtime.Scheme + KubeClient kubernetes.Interface crqClient quota.CRQClientInterface ComputeCalculator *pod.PodResourceCalculator StorageCalculator *storage.StorageResourceCalculator @@ -296,8 +302,6 @@ func (r *ClusterResourceQuotaReconciler) calculateAndAggregateUsage( // Dispatch to the correct calculation function based on the resource type switch resourceName { - case corev1.ResourcePods, corev1.ResourceServices, corev1.ResourceConfigMaps, corev1.ResourceSecrets, corev1.ResourcePersistentVolumeClaims: - currentUsage = r.calculateObjectCount(ctx, nsName, resourceName) case corev1.ResourceRequestsCPU, corev1.ResourceRequestsMemory, corev1.ResourceLimitsCPU, corev1.ResourceLimitsMemory: currentUsage = r.calculateComputeResources(ctx, nsName, resourceName) case corev1.ResourceRequestsStorage: @@ -310,8 +314,7 @@ func (r *ClusterResourceQuotaReconciler) calculateAndAggregateUsage( if r.isComputeResource(resourceName) { currentUsage = r.calculateComputeResources(ctx, nsName, resourceName) } else { - log.Info("Unsupported resource type for quota calculation", "resource", resourceName) - continue + currentUsage = r.calculateObjectCount(ctx, nsName, resourceName) } } // Update usage for the specific namespace @@ -336,6 +339,7 @@ func (r *ClusterResourceQuotaReconciler) calculateAndAggregateUsage( // calculateObjectCount calculates the usage for object count quotas. func (r *ClusterResourceQuotaReconciler) calculateObjectCount(ctx context.Context, ns string, resourceName corev1.ResourceName) resource.Quantity { + // Use the correct calculator for each resource type switch resourceName { case usage.ResourceServices, usage.ResourceServicesLoadBalancers, usage.ResourceServicesNodePorts: if r.ServiceCalculator == nil { @@ -348,6 +352,16 @@ func (r *ClusterResourceQuotaReconciler) calculateObjectCount(ctx context.Contex return resource.MustParse("0") } return usage + case usage.ResourceConfigMaps, usage.ResourceSecrets, usage.ResourceReplicationControllers, + usage.ResourceDeployments, usage.ResourceStatefulSets, usage.ResourceDaemonSets, + usage.ResourceJobs, usage.ResourceCronJobs, usage.ResourceHorizontalPodAutoscalers, usage.ResourceIngresses: + calc := objectcount.NewObjectCountCalculator(r.KubeClient) + usage, err := calc.CalculateUsage(ctx, ns, resourceName) + if err != nil { + log.Error(err, "Failed to calculate object count usage", "resource", resourceName, "namespace", ns) + return resource.MustParse("0") + } + return usage default: log.Info("Unsupported object count resource for calculateObjectCount", "resource", resourceName, "namespace", ns) return resource.MustParse("0") @@ -465,7 +479,6 @@ func (r *ClusterResourceQuotaReconciler) findQuotasForObject(ctx context.Context return nil } -// TODO: Improve this, temporary workaround to handle compute resources. // isComputeResource determines if a resource type should be calculated using the compute calculator. // This includes standard compute resources and extended resources (hugepages, GPUs, etc.) func (r *ClusterResourceQuotaReconciler) isComputeResource(resourceName corev1.ResourceName) bool { @@ -483,14 +496,8 @@ func (r *ClusterResourceQuotaReconciler) isComputeResource(resourceName corev1.R return true } - // GPU resources typically contain "gpu" in the name - if strings.Contains(strings.ToLower(resourceStr), "gpu") { - return true - } - - // Extended resources typically have domain-style names (contain dots) - // Examples: nvidia.com/gpu, example.com/foo, intel.com/qat - if strings.Contains(resourceStr, ".") { + // Extended resources start with request. + if strings.HasPrefix(resourceStr, "requests.") { return true } @@ -500,6 +507,11 @@ func (r *ClusterResourceQuotaReconciler) isComputeResource(resourceName corev1.R // SetupWithManager sets up the controller with the Manager. func (r *ClusterResourceQuotaReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Initialize the KubeClient using the manager's config if not already set + if r.KubeClient == nil { + cfg := mgr.GetConfig() + r.KubeClient = kubernetes.NewForConfigOrDie(cfg) + } k8sConfig := mgr.GetConfig() clientset, err := kubernetes.NewForConfig(k8sConfig) if err != nil { @@ -537,6 +549,17 @@ func (r *ClusterResourceQuotaReconciler) SetupWithManager(mgr ctrl.Manager) erro {&corev1.Pod{}, []predicate.Predicate{resourcePredicate}}, {&corev1.PersistentVolumeClaim{}, nil}, {&corev1.Service{}, nil}, + // Generic object count resources + {&corev1.ConfigMap{}, nil}, + {&corev1.Secret{}, nil}, + {&corev1.ReplicationController{}, nil}, + {&appsv1.Deployment{}, nil}, + {&appsv1.StatefulSet{}, nil}, + {&appsv1.DaemonSet{}, nil}, + {&batchv1.Job{}, nil}, + {&batchv1.CronJob{}, nil}, + {&autoscalingv1.HorizontalPodAutoscaler{}, nil}, + {&networkingv1.Ingress{}, nil}, } for _, w := range watchedObjectTypes { b = b.Watches( diff --git a/pkg/kubernetes/objectcount/objectcount.go b/pkg/kubernetes/objectcount/objectcount.go new file mode 100644 index 0000000..61ea3c6 --- /dev/null +++ b/pkg/kubernetes/objectcount/objectcount.go @@ -0,0 +1,81 @@ +// Package objectcount provides resource calculators for generic object count resources. +// Implements usage.ResourceCalculatorInterface for deployments, statefulsets, daemonsets, jobs, cronjobs, hpas, ingresses, configmaps, secrets, replicationcontrollers. +package objectcount + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/client-go/kubernetes" +) + +// ObjectCountCalculator implements usage.ResourceCalculatorInterface for generic object count resources. +type ObjectCountCalculator struct { + Client kubernetes.Interface +} + +func NewObjectCountCalculator(client kubernetes.Interface) *ObjectCountCalculator { + return &ObjectCountCalculator{ + Client: client, + } +} + +// CalculateUsage returns the count of the specified resource in the namespace. +func (c *ObjectCountCalculator) CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) { + var count int64 + var err error + + switch resourceName { + case "configmaps": + list, e := c.Client.CoreV1().ConfigMaps(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "secrets": + list, e := c.Client.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "replicationcontrollers": + list, e := c.Client.CoreV1().ReplicationControllers(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "deployments.apps": + list, e := c.Client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "statefulsets.apps": + list, e := c.Client.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "daemonsets.apps": + list, e := c.Client.AppsV1().DaemonSets(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "jobs.batch": + list, e := c.Client.BatchV1().Jobs(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "cronjobs.batch": + list, e := c.Client.BatchV1().CronJobs(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "horizontalpodautoscalers.autoscaling": + list, e := c.Client.AutoscalingV1().HorizontalPodAutoscalers(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + case "ingresses.networking.k8s.io": + list, e := c.Client.NetworkingV1().Ingresses(namespace).List(ctx, metav1.ListOptions{}) + count, err = int64(len(list.Items)), e + default: + return resource.Quantity{}, nil + } + + if err != nil { + return resource.Quantity{}, err + } + return *resource.NewQuantity(count, resource.DecimalSI), nil +} + +// CalculateTotalUsage returns a map with the count for the configured resource in the namespace. +func (c *ObjectCountCalculator) CalculateTotalUsage(ctx context.Context, resourceName corev1.ResourceName, namespace string) (map[corev1.ResourceName]resource.Quantity, error) { + usage := make(map[corev1.ResourceName]resource.Quantity) + q, err := c.CalculateUsage(ctx, namespace, resourceName) + if err != nil { + return usage, err + } + usage[resourceName] = q + return usage, nil +} diff --git a/pkg/kubernetes/objectcount/objectcount_suite_test.go b/pkg/kubernetes/objectcount/objectcount_suite_test.go new file mode 100644 index 0000000..f29a5d1 --- /dev/null +++ b/pkg/kubernetes/objectcount/objectcount_suite_test.go @@ -0,0 +1,13 @@ +package objectcount + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestObjectCountCalculator(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ObjectCountCalculator Suite") +} diff --git a/pkg/kubernetes/objectcount/objectcount_test.go b/pkg/kubernetes/objectcount/objectcount_test.go new file mode 100644 index 0000000..4df69e7 --- /dev/null +++ b/pkg/kubernetes/objectcount/objectcount_test.go @@ -0,0 +1,111 @@ +package objectcount + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" +) + +var _ = Describe("ObjectCountCalculator", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = batchv1.AddToScheme(scheme) + _ = autoscalingv1.AddToScheme(scheme) + _ = networkingv1.AddToScheme(scheme) + }) + + DescribeTable("CalculateTotalUsage for all supported resources", + func(resourceName string, object runtime.Object, expected int64) { + ns := "ns1" + rn := corev1.ResourceName(resourceName) + client := fake.NewSimpleClientset(object) + calc := NewObjectCountCalculator(client) + usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + Expect(err).ToNot(HaveOccurred()) + q := usage[rn] + Expect(q.Value()).To(Equal(expected)) + }, + Entry("configmaps", "configmaps", &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "ns1"}}, int64(1)), + Entry("secrets", "secrets", &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s1", Namespace: "ns1"}}, int64(1)), + Entry("replicationcontrollers", "replicationcontrollers", &corev1.ReplicationController{ObjectMeta: metav1.ObjectMeta{Name: "rc1", Namespace: "ns1"}}, int64(1)), + Entry("deployments.apps", "deployments.apps", &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: "ns1"}}, int64(1)), + Entry("statefulsets.apps", "statefulsets.apps", &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "st1", Namespace: "ns1"}}, int64(1)), + Entry("daemonsets.apps", "daemonsets.apps", &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "ds1", Namespace: "ns1"}}, int64(1)), + Entry("jobs.batch", "jobs.batch", &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job1", Namespace: "ns1"}}, int64(1)), + Entry("cronjobs.batch", "cronjobs.batch", &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: "cj1", Namespace: "ns1"}}, int64(1)), + Entry("horizontalpodautoscalers.autoscaling", "horizontalpodautoscalers.autoscaling", &autoscalingv1.HorizontalPodAutoscaler{ObjectMeta: metav1.ObjectMeta{Name: "hpa1", Namespace: "ns1"}}, int64(1)), + Entry("ingresses.networking.k8s.io", "ingresses.networking.k8s.io", &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "ing1", Namespace: "ns1"}}, int64(1)), + ) + + It("should count multiple resources of the same type", func() { + ns := "ns1" + rn := corev1.ResourceName("configmaps") + cm1 := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: ns}} + cm2 := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: ns}} + client := fake.NewSimpleClientset(cm1, cm2) + calc := NewObjectCountCalculator(client) + usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + Expect(err).ToNot(HaveOccurred()) + q := usage[rn] + Expect(q.Value()).To(Equal(int64(2))) + }) + + It("should count multiple resource types in the same namespace", func() { + ns := "ns1" + rnCM := corev1.ResourceName("configmaps") + rnSecret := corev1.ResourceName("secrets") + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: ns}} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s1", Namespace: ns}} + client := fake.NewSimpleClientset(cm, secret) + calcCM := NewObjectCountCalculator(client) + calcSecret := NewObjectCountCalculator(client) + usageCM, err := calcCM.CalculateTotalUsage(ctx, rnCM, ns) + Expect(err).ToNot(HaveOccurred()) + usageSecret, err := calcSecret.CalculateTotalUsage(ctx, rnSecret, ns) + Expect(err).ToNot(HaveOccurred()) + qCM := usageCM[rnCM] + qSecret := usageSecret[rnSecret] + Expect((&qCM).Value()).To(Equal(int64(1))) + Expect((&qSecret).Value()).To(Equal(int64(1))) + }) + + It("should return zero for no resources present", func() { + ns := "ns1" + rn := corev1.ResourceName("configmaps") + client := fake.NewSimpleClientset() + calc := NewObjectCountCalculator(client) + usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + Expect(err).ToNot(HaveOccurred()) + q := usage[rn] + Expect(q.Value()).To(Equal(int64(0))) + }) + + It("should return zero for inexistent resource type", func() { + ns := "ns1" + rn := corev1.ResourceName("nonexistent") + client := fake.NewSimpleClientset() + calc := NewObjectCountCalculator(client) + usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + Expect(err).ToNot(HaveOccurred()) + q := usage[rn] + Expect(q.Value()).To(Equal(int64(0))) + }) +}) diff --git a/pkg/kubernetes/pod/pod.go b/pkg/kubernetes/pod/pod.go index 0abef43..12dcbc4 100644 --- a/pkg/kubernetes/pod/pod.go +++ b/pkg/kubernetes/pod/pod.go @@ -2,6 +2,7 @@ package pod import ( "context" + "strings" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" @@ -88,6 +89,14 @@ func getContainerResourceUsage(container corev1.Container, resourceName corev1.R return memory } default: + // Handle extended resources with 'requests.' prefix + s := string(resourceName) + if strings.HasPrefix(s, "requests.") { + extName := corev1.ResourceName(s[len("requests."):]) + if resourceValue, ok := container.Resources.Requests[extName]; ok { + return resourceValue + } + } // Handle hugepages and other resource types if resourceValue, ok := container.Resources.Requests[resourceName]; ok { return resourceValue @@ -96,7 +105,6 @@ func getContainerResourceUsage(container corev1.Container, resourceName corev1.R return resourceValue } } - return resource.Quantity{} } diff --git a/pkg/kubernetes/pod/pod_suite_test.go b/pkg/kubernetes/pod/pod_suite_test.go index da98454..5420e95 100644 --- a/pkg/kubernetes/pod/pod_suite_test.go +++ b/pkg/kubernetes/pod/pod_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package pod import ( diff --git a/pkg/kubernetes/quota/quota.go b/pkg/kubernetes/quota/quota.go index 35fef9f..0dd5b5a 100644 --- a/pkg/kubernetes/quota/quota.go +++ b/pkg/kubernetes/quota/quota.go @@ -25,6 +25,9 @@ func NewCRQClient(c client.Client) *CRQClient { // ListAllCRQs returns all ClusterResourceQuotas in the cluster. func (c *CRQClient) ListAllCRQs(ctx context.Context) ([]quotav1alpha1.ClusterResourceQuota, error) { + if c.Client == nil { + return nil, fmt.Errorf("CRQClient is not configured") + } var crqList quotav1alpha1.ClusterResourceQuotaList if err := c.Client.List(ctx, &crqList); err != nil { return nil, fmt.Errorf("failed to list ClusterResourceQuotas: %w", err) diff --git a/pkg/kubernetes/usage/usage.go b/pkg/kubernetes/usage/usage.go index c7a03fc..c7fc0c7 100644 --- a/pkg/kubernetes/usage/usage.go +++ b/pkg/kubernetes/usage/usage.go @@ -95,33 +95,42 @@ func (r *UsageResult) GetTotalUsage(resourceName corev1.ResourceName) resource.Q // Core resource names used across the application var ( - // CPU resources + // Core compute resources ResourceRequestsCPU = corev1.ResourceRequestsCPU ResourceLimitsCPU = corev1.ResourceLimitsCPU ResourceCPU = corev1.ResourceCPU - // Memory resources + // Core memory resources ResourceRequestsMemory = corev1.ResourceRequestsMemory ResourceLimitsMemory = corev1.ResourceLimitsMemory ResourceMemory = corev1.ResourceMemory - // Storage resources + // Core storage resources ResourceRequestsStorage = corev1.ResourceRequestsStorage ResourceStorage = corev1.ResourceStorage - // Ephemeral storage + // Ephemeral storage resources ResourceRequestsEphemeralStorage = corev1.ResourceRequestsEphemeralStorage ResourceLimitsEphemeralStorage = corev1.ResourceLimitsEphemeralStorage ResourceEphemeralStorage = corev1.ResourceEphemeralStorage - // Count resources + // Core countable resources ResourcePods = corev1.ResourcePods ResourcePersistentVolumeClaims = corev1.ResourcePersistentVolumeClaims ResourceConfigMaps = corev1.ResourceConfigMaps ResourceReplicationControllers = corev1.ResourceReplicationControllers ResourceSecrets = corev1.ResourceSecrets - // Count services + // Additional Kubernetes resource counts + ResourceDeployments = corev1.ResourceName("deployments.apps") + ResourceStatefulSets = corev1.ResourceName("statefulsets.apps") + ResourceDaemonSets = corev1.ResourceName("daemonsets.apps") + ResourceJobs = corev1.ResourceName("jobs.batch") + ResourceCronJobs = corev1.ResourceName("cronjobs.batch") + ResourceHorizontalPodAutoscalers = corev1.ResourceName("horizontalpodautoscalers.autoscaling") + ResourceIngresses = corev1.ResourceName("ingresses.networking.k8s.io") + + // Service-related resources ResourceServices = corev1.ResourceServices ResourceServicesLoadBalancers = corev1.ResourceServicesLoadBalancers ResourceServicesNodePorts = corev1.ResourceServicesNodePorts diff --git a/pkg/webhook/server/server.go b/pkg/webhook/server/server.go index e54524e..cb60cb0 100644 --- a/pkg/webhook/server/server.go +++ b/pkg/webhook/server/server.go @@ -205,6 +205,16 @@ func (s *GinWebhookServer) setupRoutes() { s.pvcHandler = v1alpha1.NewPersistentVolumeClaimWebhook(s.k8sClient, crqClient, s.log) s.engine.POST("/validate--v1-persistentvolumeclaim", s.pvcHandler.Handle) + if s.log != nil { + s.log.Info("Setting up objectcount webhook") + } + // Setup objectcount webhook handler + if s.log != nil { + s.log.Info("Setting up objectcount webhook") + } + objectCountHandler := v1alpha1.NewObjectCountWebhook(s.k8sClient, crqClient, s.log) + s.engine.POST("/validate-objectcount-v1", objectCountHandler.Handle) + if s.log != nil { s.log.Info("All webhook handlers configured with CRQ client support") } diff --git a/pkg/webhook/v1alpha1/objectcount_webhook.go b/pkg/webhook/v1alpha1/objectcount_webhook.go new file mode 100644 index 0000000..322e848 --- /dev/null +++ b/pkg/webhook/v1alpha1/objectcount_webhook.go @@ -0,0 +1,172 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/objectcount" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" + "k8s.io/apimachinery/pkg/api/resource" +) + +// ObjectCountWebhook handles webhook requests for Object count resources +// It enforces object count quotas for objects and subtypes. +type ObjectCountWebhook struct { + client kubernetes.Interface + objectCountCalculator *objectcount.ObjectCountCalculator + crqClient *quota.CRQClient + log *zap.Logger +} + +// NewObjectCountWebhook creates a new ObjectCountWebhook +func NewObjectCountWebhook( + k8sClient kubernetes.Interface, + crqClient *quota.CRQClient, + log *zap.Logger) *ObjectCountWebhook { + return &ObjectCountWebhook{ + client: k8sClient, + objectCountCalculator: objectcount.NewObjectCountCalculator(k8sClient), + crqClient: crqClient, + log: log, + } +} + +// Handle handles the webhook request for Object +func (h *ObjectCountWebhook) Handle(c *gin.Context) { + var admissionReview admissionv1.AdmissionReview + if err := c.ShouldBindJSON(&admissionReview); err != nil { + h.log.Error("Failed to bind admission review", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.log.Info("Received request for ObjectCountWebhook", zap.String("resource", admissionReview.Request.Resource.Resource)) + + // Check for malformed requests (like {}) that don't have proper AdmissionReview structure + if admissionReview.Kind == "" && admissionReview.APIVersion == "" && admissionReview.Request == nil { + h.log.Error("Malformed admission review request") + c.JSON(http.StatusBadRequest, gin.H{"error": "Malformed admission review request"}) + return + } + + // Check for missing namespace in the request + if admissionReview.Request.Namespace == "" { + h.log.Info("Admission review request namespace is empty") + admissionReview.Response = &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: http.StatusBadRequest, + Message: "Missing admission request namespace", + }, + } + c.JSON(http.StatusOK, admissionReview) + return + } + + admissionReview.Response = &admissionv1.AdmissionResponse{ + UID: admissionReview.Request.UID, + } + + crqKey := admissionReview.Request.Resource.Resource + if admissionReview.Request.Resource.Group != "" { + crqKey = crqKey + "." + admissionReview.Request.Resource.Group + } + resourceName := corev1.ResourceName(crqKey) + h.log.Info("Determined resource name for CRQ", zap.String("resourceName", string(resourceName))) + namespace := admissionReview.Request.Namespace + var warnings []string + var err error + ctx := c.Request.Context() + switch admissionReview.Request.Operation { + case admissionv1.Create: + h.log.Info("Validating ObjectCount on create", + zap.String("name", resourceName.String()), + zap.String("namespace", namespace)) + warnings, err = h.validateCreate(ctx, namespace, resourceName) + case admissionv1.Update: + h.log.Info("Validating ObjectCount on update", + zap.String("name", resourceName.String()), + zap.String("namespace", namespace)) + warnings, err = h.validateUpdate(ctx, namespace, resourceName) + default: + h.log.Info("Unsupported operation", zap.String("operation", string(admissionReview.Request.Operation))) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("Operation %s is not supported for Service", admissionReview.Request.Operation), + } + c.JSON(http.StatusOK, admissionReview) + return + } + if err != nil { + h.log.Error("Validation failed", zap.Error(err)) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: http.StatusForbidden, + Message: err.Error(), + } + } else { + admissionReview.Response.Allowed = true + if len(warnings) > 0 { + admissionReview.Response.Warnings = warnings + } + } + + c.JSON(http.StatusOK, admissionReview) +} + +func (h *ObjectCountWebhook) validateCreate(ctx context.Context, namespace string, resourceName corev1.ResourceName) ([]string, error) { + return h.validateObjectOperation(ctx, namespace, resourceName, "creation") +} + +func (h *ObjectCountWebhook) validateUpdate(ctx context.Context, namespace string, resourceName corev1.ResourceName) ([]string, error) { + return h.validateObjectOperation(ctx, namespace, resourceName, "update") +} + +// validateObjectOperation is a shared function for both create and update validation +func (h *ObjectCountWebhook) validateObjectOperation(ctx context.Context, namespace string, resourceName corev1.ResourceName, operation string) ([]string, error) { + if resourceName == "" { + h.log.Info("Skipping CRQ validation for nil object on " + operation) + return nil, nil + } + err := h.validateResourceQuota(ctx, namespace, resourceName, resource.MustParse("1")) + if err != nil { + return nil, err + } + h.log.Info("Object CRQ validation passed", + zap.String("object", resourceName.String()), + zap.String("namespace", namespace), + zap.String("operation", operation)) + return nil, nil +} + +// validateResourceQuota validates if a resource operation would exceed any applicable ClusterResourceQuota +func (h *ObjectCountWebhook) validateResourceQuota( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName, + requestedQuantity resource.Quantity, +) error { + ns, err := h.client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get namespace %s: %w", namespace, err) + } + + return validateCRQResourceQuotaWithNamespace(ctx, h.crqClient, h.client, ns, resourceName, requestedQuantity, + func(ns string, rn corev1.ResourceName) (resource.Quantity, error) { + return h.calculateCurrentUsage(ctx, ns, rn) + }, h.log) +} + +// calculateCurrentUsage calculates the current usage of a resource in a namespace +func (h *ObjectCountWebhook) calculateCurrentUsage(ctx context.Context, namespace string, + resourceName corev1.ResourceName) (resource.Quantity, error) { + return h.objectCountCalculator.CalculateUsage(ctx, namespace, resourceName) +} diff --git a/pkg/webhook/v1alpha1/objectcount_webhook_test.go b/pkg/webhook/v1alpha1/objectcount_webhook_test.go new file mode 100644 index 0000000..e24474a --- /dev/null +++ b/pkg/webhook/v1alpha1/objectcount_webhook_test.go @@ -0,0 +1,329 @@ +package v1alpha1 + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap" + admissionv1 "k8s.io/api/admission/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + ctrlclientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + + quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" + "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" +) + +var _ = Describe("ObjectCountWebhook", func() { + var ( + webhook *ObjectCountWebhook + fakeClient *fake.Clientset + crqClient *quota.CRQClient + logger *zap.Logger + ginEngine *gin.Engine + scheme *runtime.Scheme + ) + + BeforeEach(func() { + scheme = runtime.NewScheme() + _ = quotav1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + fakeClient = fake.NewSimpleClientset() + logger = zap.NewNop() + gin.SetMode(gin.TestMode) + ginEngine = gin.New() + }) + + AfterEach(func() { + // No-op for now, but can be used for cleanup + }) + + Describe("NewObjectCountWebhook", func() { + It("should create a new object count webhook", func() { + fakeClient = fake.NewSimpleClientset() + logger = zap.NewNop() + crqClient = quota.NewCRQClient(nil) + webhook = NewObjectCountWebhook(fakeClient, crqClient, logger) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.client).To(Equal(fakeClient)) + Expect(webhook.log).To(Equal(logger)) + Expect(webhook.crqClient).To(Equal(crqClient)) + }) + + It("should create webhook with nil client", func() { + logger = zap.NewNop() + crqClient = quota.NewCRQClient(nil) + webhook = NewObjectCountWebhook(nil, crqClient, logger) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.client).To(BeNil()) + }) + + It("should create webhook with nil logger", func() { + fakeClient = fake.NewSimpleClientset() + crqClient = quota.NewCRQClient(nil) + webhook = NewObjectCountWebhook(fakeClient, crqClient, nil) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.log).To(BeNil()) + }) + + It("should create webhook with nil CRQ client", func() { + fakeClient = fake.NewSimpleClientset() + logger = zap.NewNop() + webhook = NewObjectCountWebhook(fakeClient, nil, logger) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.crqClient).To(BeNil()) + }) + + It("should create webhook with all nil parameters", func() { + webhook = NewObjectCountWebhook(nil, nil, nil) + Expect(webhook).NotTo(BeNil()) + Expect(webhook.client).To(BeNil()) + Expect(webhook.crqClient).To(BeNil()) + Expect(webhook.log).To(BeNil()) + }) + }) + + Describe("Handle AdmissionRequest", func() { + Context("with CRQ and configmaps", func() { + BeforeEach(func() { + crq := "av1alpha1.ClusterResourceQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "crq"}, + Spec: quotav1alpha1.ClusterResourceQuotaSpec{ + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "test"}}, + Hard: quotav1alpha1.ResourceList{ + "configmaps": resource.MustParse("2"), + "secrets": resource.MustParse("1"), + "replicationcontrollers": resource.MustParse("1"), + "deployments.apps": resource.MustParse("1"), + "statefulsets.apps": resource.MustParse("1"), + "daemonsets.apps": resource.MustParse("1"), + "jobs.batch": resource.MustParse("1"), + "cronjobs.batch": resource.MustParse("1"), + "horizontalpodautoscalers.autoscaling": resource.MustParse("1"), + "ingresses.networking.k8s.io": resource.MustParse("1"), + }, + }, + } + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "test-namespace", Labels: map[string]string{"env": "test"}}, + } + _, _ = fakeClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) + fakeRuntimeClient := ctrlclientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(crq, ns).Build() + crqClient = quota.NewCRQClient(fakeRuntimeClient) + webhook = NewObjectCountWebhook(fakeClient, crqClient, logger) + ginEngine.POST("/webhook", webhook.Handle) + }) + + It("should allow creation when under quota", func() { + _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + review := createObjectCountAdmissionReview("123", "test-namespace", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeTrue()) + }) + + It("should deny creation when quota exceeded", func() { + // Add 2 configmaps to reach quota + _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + review := createObjectCountAdmissionReview("456", "test-namespace", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeFalse()) + Expect(resp.Response.Result.Message).To(ContainSubstring("ClusterResourceQuota")) + Expect(resp.Response.Result.Message).To(ContainSubstring("configmaps limit exceeded")) + }) + + It("should allow creation with multiple objects under quota", func() { + // Add 1 configmap, quota is 2 + _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + // Simulate batch creation (not strictly supported by AdmissionReview, but test logic) + review := createObjectCountAdmissionReview("789", "test-namespace", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeTrue()) + }) + + It("should allow creation of one deployment", func() { + review := createObjectCountAdmissionReview("2001", "test-namespace", "deployments.apps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeTrue()) + }) + + It("should deny creation of two deployments", func() { + // Create one existing deployment + dep, err := webhook.client.AppsV1().Deployments("test-namespace").Create(context.Background(), &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: "test-namespace"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "myapp"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "myapp"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "minimal", + Image: "busybox", + }, + }, + }, + }, + }, + }, metav1.CreateOptions{}) + Expect(err).To(BeNil()) + Expect(dep).NotTo(BeNil()) + Expect(dep).NotTo(BeNil()) + review := createObjectCountAdmissionReview("2002", "test-namespace", "deployments.apps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeFalse()) + Expect(resp.Response.Result.Message).To(ContainSubstring("ClusterResourceQuota")) + Expect(resp.Response.Result.Message).To(ContainSubstring("deployments.apps limit exceeded")) + }) + + It("should allow creation of one deployment and one ingress", func() { + reviewDep := createObjectCountAdmissionReview("2003", "test-namespace", "deployments.apps") + bodyDep, _ := json.Marshal(reviewDep) + reqDep := httptest.NewRequest("POST", "/webhook", bytes.NewReader(bodyDep)) + wDep := httptest.NewRecorder() + ginEngine.ServeHTTP(wDep, reqDep) + Expect(wDep.Code).To(Equal(200)) + var respDep admissionv1.AdmissionReview + _ = json.Unmarshal(wDep.Body.Bytes(), &respDep) + Expect(respDep.Response.Allowed).To(BeTrue()) + + reviewIng := createObjectCountAdmissionReview("2004", "test-namespace", "ingresses.networking.k8s.io") + bodyIng, _ := json.Marshal(reviewIng) + reqIng := httptest.NewRequest("POST", "/webhook", bytes.NewReader(bodyIng)) + wIng := httptest.NewRecorder() + ginEngine.ServeHTTP(wIng, reqIng) + Expect(wIng.Code).To(Equal(200)) + var respIng admissionv1.AdmissionReview + _ = json.Unmarshal(wIng.Body.Bytes(), &respIng) + Expect(respIng.Response.Allowed).To(BeTrue()) + }) + + It("should deny creation with multiple objects over quota", func() { + // Add 2 configmaps, quota is 2 + _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + review := createObjectCountAdmissionReview("1011", "test-namespace", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeFalse()) + Expect(resp.Response.Result.Message).To(ContainSubstring("ClusterResourceQuota")) + Expect(resp.Response.Result.Message).To(ContainSubstring("configmaps limit exceeded")) + }) + + It("should allow creation with zero objects", func() { + // No configmaps present + review := createObjectCountAdmissionReview("1213", "test-namespace", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeTrue()) + }) + + It("should allow creation of unknown resource type", func() { + review := createObjectCountAdmissionReview("1415", "test-namespace", "invalidresource") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeTrue()) + }) + + It("should deny creation when namespace missing", func() { + review := createObjectCountAdmissionReview("1617", "", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeFalse()) + Expect(resp.Response.Result.Message).To(ContainSubstring("Missing admission request namespace")) + }) + + It("should allow creation when CRQClient fails", func() { + // Simulate CRQClient failure by passing nil client + webhook.crqClient = quota.NewCRQClient(nil) + review := createObjectCountAdmissionReview("1819", "test-namespace", "configmaps") + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) + w := httptest.NewRecorder() + ginEngine.ServeHTTP(w, req) + Expect(w.Code).To(Equal(200)) + var resp admissionv1.AdmissionReview + _ = json.Unmarshal(w.Body.Bytes(), &resp) + Expect(resp.Response.Allowed).To(BeTrue()) + }) + }) + }) +}) + +func createObjectCountAdmissionReview(uid, namespace, resource string) admissionv1.AdmissionReview { + return admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: types.UID(uid), + Namespace: namespace, + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{Resource: resource}, + }, + } +} diff --git a/pkg/webhook/v1alpha1/webhook_utils.go b/pkg/webhook/v1alpha1/webhook_utils.go index ebe2810..62b903c 100644 --- a/pkg/webhook/v1alpha1/webhook_utils.go +++ b/pkg/webhook/v1alpha1/webhook_utils.go @@ -61,7 +61,7 @@ func validateCRQResourceQuotaWithNamespace( log.Error("Failed to get CRQ for namespace", zap.String("namespace", ns.Name), zap.Error(err)) - return fmt.Errorf("failed to get CRQ for namespace %s: %w", ns.Name, err) + return nil } // If no CRQ applies to this namespace, allow the operation diff --git a/test/e2e/clusterresourcequota_webhook_test.go b/test/e2e/clusterresourcequota_webhook_test.go index 99c4ad0..c6130c8 100644 --- a/test/e2e/clusterresourcequota_webhook_test.go +++ b/test/e2e/clusterresourcequota_webhook_test.go @@ -56,7 +56,6 @@ var _ = Describe("ClusterResourceQuota Webhook", func() { Expect(k8sClient.Delete(ctx, crq)).To(Succeed()) }) }) - Context("Update scenarios", func() { It("should update a ClusterResourceQuota spec successfully", func() { By("Creating a ClusterResourceQuota with initial spec") diff --git a/test/e2e/controller_reconcile_test.go b/test/e2e/controller_reconcile_test.go index 37268f5..4b7feb4 100644 --- a/test/e2e/controller_reconcile_test.go +++ b/test/e2e/controller_reconcile_test.go @@ -59,7 +59,7 @@ var _ = Describe("ClusterResourceQuota Controller E2E Tests", func() { corev1.ResourceRequestsMemory: resource.MustParse("4Gi"), // 4GB memory request limit corev1.ResourceLimitsCPU: resource.MustParse("4"), // 4 CPU cores limit corev1.ResourceLimitsMemory: resource.MustParse("8Gi"), // 8GB memory limit - "example.com/gpu": resource.MustParse("2"), // 2 GPU units + "requests.example.com/gpu": resource.MustParse("2"), // 2 GPU units "hugepages-2Mi": resource.MustParse("4Gi"), // 4GB hugepages }, ) @@ -192,9 +192,7 @@ var _ = Describe("ClusterResourceQuota Controller E2E Tests", func() { k8sClient, nsName, "test-pod-"+suffix, - corev1.ResourceList{ - "example.com/gpu": resource.MustParse("1"), - }, + nil, corev1.ResourceList{ "example.com/gpu": resource.MustParse("1"), }) @@ -206,7 +204,7 @@ var _ = Describe("ClusterResourceQuota Controller E2E Tests", func() { Eventually(func() error { usage := testutils.GetRefreshedCRQStatusUsage(ctx, k8sClient, crq.Name) return testutils.ExpectCRQUsageToMatch(usage, map[string]string{ - "example.com/gpu": "1", // GPU resource used + "requests.example.com/gpu": "1", // GPU resource used }) }, Timeout, Interval).Should(Succeed()) }) @@ -432,9 +430,7 @@ var _ = Describe("ClusterResourceQuota Controller E2E Tests", func() { k8sClient, "dynamic-ns-"+suffix, "gpu-pod-"+suffix, - corev1.ResourceList{ - "example.com/gpu": resource.MustParse("1"), - }, + nil, corev1.ResourceList{ "example.com/gpu": resource.MustParse("1"), }) @@ -452,11 +448,11 @@ var _ = Describe("ClusterResourceQuota Controller E2E Tests", func() { Eventually(func() error { usage := testutils.GetRefreshedCRQStatusUsage(ctx, k8sClient, crq.Name) return testutils.ExpectCRQUsageToMatch(usage, map[string]string{ - "requests.cpu": "100m", // Pod 1 CPU - "requests.memory": "128Mi", // Pod 1 Memory - "limits.cpu": "200m", // Pod 1 CPU limits - "limits.memory": "256Mi", // Pod 1 Memory limits - "example.com/gpu": "1", // Pod 2 GPU + "requests.cpu": "100m", // Pod 1 CPU + "requests.memory": "128Mi", // Pod 1 Memory + "limits.cpu": "200m", // Pod 1 CPU limits + "limits.memory": "256Mi", // Pod 1 Memory limits + "requests.example.com/gpu": "1", // Pod 2 GPU }) }, Timeout, Interval).Should(Succeed()) diff --git a/test/e2e/objectcount_webhook_test.go b/test/e2e/objectcount_webhook_test.go new file mode 100644 index 0000000..2466fc0 --- /dev/null +++ b/test/e2e/objectcount_webhook_test.go @@ -0,0 +1,322 @@ +package e2e + +import ( + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" + testutils "github.com/powerhome/pac-quota-controller/test/utils" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("ClusterResourceQuota Object Count Webhook E2E", func() { + var ( + testNamespace string + testCRQName string + testSuffix string + ns *corev1.Namespace + crq *quotav1alpha1.ClusterResourceQuota + ) + + BeforeEach(func() { + testSuffix = testutils.GenerateTestSuffix() + testNamespace = testutils.GenerateResourceName("objectcount-ns-" + testSuffix) + testCRQName = testutils.GenerateResourceName("objectcount-crq-" + testSuffix) + ns, _ = testutils.CreateNamespace(ctx, k8sClient, testNamespace, map[string]string{"objectcount-test": "test-label-" + testSuffix}) + crq, _ = testutils.CreateClusterResourceQuota(ctx, k8sClient, testCRQName, &metav1.LabelSelector{ + MatchLabels: map[string]string{"objectcount-test": "test-label-" + testSuffix}, + }, quotav1alpha1.ResourceList{ + "configmaps": resource.MustParse("2"), + "secrets": resource.MustParse("1"), + "replicationcontrollers": resource.MustParse("1"), + "deployments.apps": resource.MustParse("1"), + "statefulsets.apps": resource.MustParse("1"), + "daemonsets.apps": resource.MustParse("1"), + "jobs.batch": resource.MustParse("1"), + "cronjobs.batch": resource.MustParse("1"), + "horizontalpodautoscalers.autoscaling": resource.MustParse("1"), + "ingresses.networking.k8s.io": resource.MustParse("1"), + }) + }) + + AfterEach(func() { + DeferCleanup(func() { _ = k8sClient.Delete(ctx, ns) }) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, crq) }) + }) + + Context("Object Count Quota", func() { + It("should allow creation under quota for configmaps", func() { + cmName := testutils.GenerateResourceName("cm-under-quota-" + testSuffix) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmName, + Namespace: testNamespace, + }, + Data: map[string]string{"key": "value"}, + } + err := k8sClient.Create(ctx, cm) + Expect(err).ToNot(HaveOccurred(), "ConfigMap creation under quota should be allowed") + }) + It("should allow creation at quota for configmaps", func() { + // Create two configmaps (quota is 2) + for i := range 2 { + cmName := testutils.GenerateResourceName("cm-at-quota-" + testSuffix + "-" + strconv.Itoa(i)) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmName, + Namespace: testNamespace, + }, + Data: map[string]string{"key": "value"}, + } + err := k8sClient.Create(ctx, cm) + Expect(err).ToNot(HaveOccurred(), "ConfigMap creation at quota should be allowed") + } + }) + It("should deny creation over quota for configmaps", func() { + // Create up to quota + for i := range 2 { + cmName := testutils.GenerateResourceName("cm-over-quota-" + testSuffix + "-" + strconv.Itoa(i)) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmName, + Namespace: testNamespace, + }, + Data: map[string]string{"key": "value"}, + } + err := k8sClient.Create(ctx, cm) + Expect(err).ToNot(HaveOccurred(), "ConfigMap creation up to quota should be allowed") + } + // Attempt to create one more, should fail + cmName := testutils.GenerateResourceName("cm-over-quota-" + testSuffix + "-extra") + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmName, + Namespace: testNamespace, + }, + Data: map[string]string{"key": "value"}, + } + err := k8sClient.Create(ctx, cm) + Expect(err).To(HaveOccurred(), "ConfigMap creation over quota should be denied") + }) + It("should allow creation under quota for secrets", func() { + secretName := testutils.GenerateResourceName("secret-under-quota-" + testSuffix) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: testNamespace, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + err := k8sClient.Create(ctx, secret) + Expect(err).ToNot(HaveOccurred(), "Secret creation under quota should be allowed") + }) + It("should deny creation over quota for secrets", func() { + secretName := testutils.GenerateResourceName("secret-over-quota-" + testSuffix) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: testNamespace, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + err := k8sClient.Create(ctx, secret) + Expect(err).ToNot(HaveOccurred(), "Secret creation up to quota should be allowed") + // Attempt to create one more, should fail + secretNameExtra := testutils.GenerateResourceName("secret-over-quota-" + testSuffix + "-extra") + secretExtra := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretNameExtra, + Namespace: testNamespace, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + err = k8sClient.Create(ctx, secretExtra) + Expect(err).To(HaveOccurred(), "Secret creation over quota should be denied") + }) + It("should allow creation under quota for replicationcontrollers", func() { + rcName := testutils.GenerateResourceName("rc-under-quota-" + testSuffix) + rc := testutils.NewReplicationController(rcName, testNamespace, 1) + err := k8sClient.Create(ctx, rc) + Expect(err).ToNot(HaveOccurred(), "ReplicationController creation under quota should be allowed") + }) + It("should deny creation over quota for replicationcontrollers", func() { + rcName := testutils.GenerateResourceName("rc-over-quota-" + testSuffix) + rc := testutils.NewReplicationController(rcName, testNamespace, 1) + err := k8sClient.Create(ctx, rc) + Expect(err).ToNot(HaveOccurred(), "ReplicationController creation up to quota should be allowed") + rcNameExtra := testutils.GenerateResourceName("rc-over-quota-" + testSuffix + "-extra") + rcExtra := testutils.NewReplicationController(rcNameExtra, testNamespace, 1) + err = k8sClient.Create(ctx, rcExtra) + Expect(err).To(HaveOccurred(), "ReplicationController creation over quota should be denied") + }) + It("should allow creation under quota for deployments.apps", func() { + depName := testutils.GenerateResourceName("dep-under-quota-" + testSuffix) + dep := testutils.NewDeployment(depName, testNamespace, 1) + err := k8sClient.Create(ctx, dep) + Expect(err).ToNot(HaveOccurred(), "Deployment creation under quota should be allowed") + }) + It("should deny creation over quota for deployments.apps", func() { + depName := testutils.GenerateResourceName("dep-over-quota-" + testSuffix) + dep := testutils.NewDeployment(depName, testNamespace, 1) + err := k8sClient.Create(ctx, dep) + Expect(err).ToNot(HaveOccurred(), "Deployment creation up to quota should be allowed") + depNameExtra := testutils.GenerateResourceName("dep-over-quota-" + testSuffix + "-extra") + depExtra := testutils.NewDeployment(depNameExtra, testNamespace, 1) + err = k8sClient.Create(ctx, depExtra) + Expect(err).To(HaveOccurred(), "Deployment creation over quota should be denied") + }) + It("should allow creation under quota for statefulsets.apps", func() { + ssName := testutils.GenerateResourceName("ss-under-quota-" + testSuffix) + ss := testutils.NewStatefulSet(ssName, testNamespace, 1) + err := k8sClient.Create(ctx, ss) + Expect(err).ToNot(HaveOccurred(), "StatefulSet creation under quota should be allowed") + }) + It("should deny creation over quota for statefulsets.apps", func() { + ssName := testutils.GenerateResourceName("ss-over-quota-" + testSuffix) + ss := testutils.NewStatefulSet(ssName, testNamespace, 1) + err := k8sClient.Create(ctx, ss) + Expect(err).ToNot(HaveOccurred(), "StatefulSet creation up to quota should be allowed") + ssNameExtra := testutils.GenerateResourceName("ss-over-quota-" + testSuffix + "-extra") + ssExtra := testutils.NewStatefulSet(ssNameExtra, testNamespace, 1) + err = k8sClient.Create(ctx, ssExtra) + Expect(err).To(HaveOccurred(), "StatefulSet creation over quota should be denied") + }) + It("should allow creation under quota for daemonsets.apps", func() { + dsName := testutils.GenerateResourceName("ds-under-quota-" + testSuffix) + ds := testutils.NewDaemonSet(dsName, testNamespace) + err := k8sClient.Create(ctx, ds) + Expect(err).ToNot(HaveOccurred(), "DaemonSet creation under quota should be allowed") + }) + It("should deny creation over quota for daemonsets.apps", func() { + dsName := testutils.GenerateResourceName("ds-over-quota-" + testSuffix) + ds := testutils.NewDaemonSet(dsName, testNamespace) + err := k8sClient.Create(ctx, ds) + Expect(err).ToNot(HaveOccurred(), "DaemonSet creation up to quota should be allowed") + dsNameExtra := testutils.GenerateResourceName("ds-over-quota-" + testSuffix + "-extra") + dsExtra := testutils.NewDaemonSet(dsNameExtra, testNamespace) + err = k8sClient.Create(ctx, dsExtra) + Expect(err).To(HaveOccurred(), "DaemonSet creation over quota should be denied") + }) + It("should allow creation under quota for jobs.batch", func() { + jobName := testutils.GenerateResourceName("job-under-quota-" + testSuffix) + command := []string{"echo", "hello"} + requests := corev1.ResourceList{} + limits := corev1.ResourceList{} + _, err := testutils.CreateJob(ctx, k8sClient, testNamespace, jobName, command, requests, limits) + Expect(err).ToNot(HaveOccurred(), "Job creation under quota should be allowed") + }) + It("should deny creation over quota for jobs.batch", func() { + jobName := testutils.GenerateResourceName("job-over-quota-" + testSuffix) + command := []string{"echo", "hello"} + requests := corev1.ResourceList{} + limits := corev1.ResourceList{} + _, err := testutils.CreateJob(ctx, k8sClient, testNamespace, jobName, command, requests, limits) + Expect(err).ToNot(HaveOccurred(), "Job creation up to quota should be allowed") + jobNameExtra := testutils.GenerateResourceName("job-over-quota-" + testSuffix + "-extra") + _, err = testutils.CreateJob(ctx, k8sClient, testNamespace, jobNameExtra, command, requests, limits) + Expect(err).To(HaveOccurred(), "Job creation over quota should be denied") + }) + It("should allow creation under quota for cronjobs.batch", func() { + cjName := testutils.GenerateResourceName("cj-under-quota-" + testSuffix) + cj := testutils.NewCronJob(cjName, testNamespace) + err := k8sClient.Create(ctx, cj) + Expect(err).ToNot(HaveOccurred(), "CronJob creation under quota should be allowed") + }) + It("should deny creation over quota for cronjobs.batch", func() { + cjName := testutils.GenerateResourceName("cj-over-quota-" + testSuffix) + cj := testutils.NewCronJob(cjName, testNamespace) + err := k8sClient.Create(ctx, cj) + Expect(err).ToNot(HaveOccurred(), "CronJob creation up to quota should be allowed") + cjNameExtra := testutils.GenerateResourceName("cj-over-quota-" + testSuffix + "-extra") + cjExtra := testutils.NewCronJob(cjNameExtra, testNamespace) + err = k8sClient.Create(ctx, cjExtra) + Expect(err).To(HaveOccurred(), "CronJob creation over quota should be denied") + }) + It("should allow creation under quota for hpas.autoscaling", func() { + hpaName := testutils.GenerateResourceName("hpa-under-quota-" + testSuffix) + hpa := testutils.NewHPA(hpaName, testNamespace) + err := k8sClient.Create(ctx, hpa) + Expect(err).ToNot(HaveOccurred(), "HPA creation under quota should be allowed") + }) + It("should deny creation over quota for hpas.autoscaling", func() { + hpaName := testutils.GenerateResourceName("hpa-over-quota-" + testSuffix) + hpa := testutils.NewHPA(hpaName, testNamespace) + err := k8sClient.Create(ctx, hpa) + Expect(err).ToNot(HaveOccurred(), "HPA creation up to quota should be allowed") + hpaNameExtra := testutils.GenerateResourceName("hpa-over-quota-" + testSuffix + "-extra") + hpaExtra := testutils.NewHPA(hpaNameExtra, testNamespace) + err = k8sClient.Create(ctx, hpaExtra) + Expect(err).To(HaveOccurred(), "HPA creation over quota should be denied") + }) + It("should allow creation under quota for ingresses.networking.k8s.io", func() { + ingName := testutils.GenerateResourceName("ing-under-quota-" + testSuffix) + ing := testutils.NewIngress(ingName, testNamespace) + err := k8sClient.Create(ctx, ing) + Expect(err).ToNot(HaveOccurred(), "Ingress creation under quota should be allowed") + }) + It("should deny creation over quota for ingresses.networking.k8s.io", func() { + ingName := testutils.GenerateResourceName("ing-over-quota-" + testSuffix) + ing := testutils.NewIngress(ingName, testNamespace) + err := k8sClient.Create(ctx, ing) + Expect(err).ToNot(HaveOccurred(), "Ingress creation up to quota should be allowed") + ingNameExtra := testutils.GenerateResourceName("ing-over-quota-" + testSuffix + "-extra") + ingExtra := testutils.NewIngress(ingNameExtra, testNamespace) + err = k8sClient.Create(ctx, ingExtra) + Expect(err).To(HaveOccurred(), "Ingress creation over quota should be denied") + }) + It("should allow mixed resources under quota", func() { + // Create one of each resource, all under quota + resources := []struct { + name string + obj client.Object + }{ + {"cm-mixed-under-", &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-mixed-under-" + testSuffix), Namespace: testNamespace}}}, + {"secret-mixed-under-", &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("secret-mixed-under-" + testSuffix), Namespace: testNamespace}}}, + {"rc-mixed-under-", testutils.NewReplicationController(testutils.GenerateResourceName("rc-mixed-under-"+testSuffix), testNamespace, 1)}, + {"dep-mixed-under-", testutils.NewDeployment(testutils.GenerateResourceName("dep-mixed-under-"+testSuffix), testNamespace, 1)}, + {"ss-mixed-under-", testutils.NewStatefulSet(testutils.GenerateResourceName("ss-mixed-under-"+testSuffix), testNamespace, 1)}, + {"ds-mixed-under-", testutils.NewDaemonSet(testutils.GenerateResourceName("ds-mixed-under-"+testSuffix), testNamespace)}, + {"cj-mixed-under-", testutils.NewCronJob(testutils.GenerateResourceName("cj-mixed-under-"+testSuffix), testNamespace)}, + {"hpa-mixed-under-", testutils.NewHPA(testutils.GenerateResourceName("hpa-mixed-under-"+testSuffix), testNamespace)}, + {"ing-mixed-under-", testutils.NewIngress(testutils.GenerateResourceName("ing-mixed-under-"+testSuffix), testNamespace)}, + } + for _, r := range resources { + err := k8sClient.Create(ctx, r.obj) + Expect(err).ToNot(HaveOccurred(), r.name+" creation under quota should be allowed") + } + // Job: use CreateJob helper + jobName := testutils.GenerateResourceName("job-mixed-under-" + testSuffix) + command := []string{"echo", "hello"} + requests := corev1.ResourceList{} + limits := corev1.ResourceList{} + _, err := testutils.CreateJob(ctx, k8sClient, testNamespace, jobName, command, requests, limits) + Expect(err).ToNot(HaveOccurred(), "Job creation under quota should be allowed") + }) + It("should deny mixed resources over quota", func() { + // Fill up quota for configmaps and secrets, then try to create one more of each + for i := range 2 { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-mixed-over-" + testSuffix + "-" + strconv.Itoa(i)), Namespace: testNamespace}} + err := k8sClient.Create(ctx, cm) + Expect(err).ToNot(HaveOccurred(), "ConfigMap creation up to quota should be allowed") + } + cmExtra := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-mixed-over-" + testSuffix + "-extra"), Namespace: testNamespace}} + err := k8sClient.Create(ctx, cmExtra) + Expect(err).To(HaveOccurred(), "ConfigMap creation over quota should be denied") + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("secret-mixed-over-" + testSuffix), Namespace: testNamespace}} + err = k8sClient.Create(ctx, secret) + Expect(err).ToNot(HaveOccurred(), "Secret creation up to quota should be allowed") + secretExtra := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("secret-mixed-over-" + testSuffix + "-extra"), Namespace: testNamespace}} + err = k8sClient.Create(ctx, secretExtra) + Expect(err).To(HaveOccurred(), "Secret creation over quota should be denied") + }) + It("should deny creation with missing namespace", func() { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-missing-ns-" + testSuffix)}} + err := k8sClient.Create(ctx, cm) + Expect(err).To(HaveOccurred(), "ConfigMap creation with missing namespace should be denied") + }) + }) +}) diff --git a/test/utils/helpers.go b/test/utils/helpers.go index ede5a75..63f4026 100644 --- a/test/utils/helpers.go +++ b/test/utils/helpers.go @@ -1,4 +1,3 @@ -// ...existing code... package utils import ( @@ -10,9 +9,12 @@ import ( "slices" "time" + appsv1 "k8s.io/api/apps/v1" authenticationv1 "k8s.io/api/authentication/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -562,3 +564,159 @@ func CreatePVC( } return pvc, nil } + +// NewReplicationController returns a ReplicationController object for testing +func NewReplicationController(name, namespace string, replicas int32) *corev1.ReplicationController { + return &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: corev1.ReplicationControllerSpec{ + Replicas: &replicas, + Selector: map[string]string{"app": name}, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "container", Image: "nginx:latest"}}, + }, + }, + }, + } +} + +// NewDeployment returns a Deployment object for testing +func NewDeployment(name, namespace string, replicas int32) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "container", Image: "nginx:latest"}}, + }, + }, + }, + } +} + +// NewStatefulSet returns a StatefulSet object for testing +func NewStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + ServiceName: name + "-svc", + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "container", Image: "nginx:latest"}}, + }, + }, + }, + } +} + +// NewDaemonSet returns a DaemonSet object for testing +func NewDaemonSet(name, namespace string) *appsv1.DaemonSet { + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "container", Image: "nginx:latest"}}, + }, + }, + }, + } +} + +// NewCronJob returns a CronJob object for testing +func NewCronJob(name, namespace string) *batchv1.CronJob { + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "* * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "container", Image: "busybox:latest", Command: []string{"echo", "hello"}}}, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + }, + }, + } +} + +// NewHPA returns a HorizontalPodAutoscaler object for testing +func NewHPA(name, namespace string) *autoscalingv2.HorizontalPodAutoscaler { + minReplicas := int32(1) + maxReplicas := int32(2) + return &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + Kind: "Deployment", + Name: name, + APIVersion: "apps/v1", + }, + MinReplicas: &minReplicas, + MaxReplicas: maxReplicas, + Metrics: []autoscalingv2.MetricSpec{}, + }, + } +} + +// NewIngress returns an Ingress object for testing +func NewIngress(name, namespace string) *networkingv1.Ingress { + var pathType networkingv1.PathType + pathType = "Exact" + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "test.local", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + PathType: &pathType, + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + } +} From f031a736de27a79545b2ab5429b4aa7982758c6d Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:29:27 -0300 Subject: [PATCH 5/7] refactor: removing unused comments and interfaces --- api/v1alpha1/clusterresourcequota_types.go | 16 - api/v1alpha1/groupversion_info.go | 16 - api/v1alpha1/zz_generated.deepcopy.go | 16 - ....powerapp.cloud_clusterresourcequotas.yaml | 6 +- cmd/main.go | 16 - hack/boilerplate.go.txt | 15 - .../clusterresourcequota_controller.go | 38 +- .../clusterresourcequota_controller_test.go | 16 - internal/controller/suite_test.go | 16 - pkg/health/health.go | 16 - pkg/health/health_test.go | 16 - .../namespace/namespace_suite_test.go | 16 - pkg/kubernetes/namespace/types.go | 16 - pkg/kubernetes/objectcount/objectcount.go | 12 +- .../objectcount/objectcount_test.go | 84 ++- pkg/kubernetes/pod/pod.go | 11 +- pkg/kubernetes/pod/pod_test.go | 16 - pkg/kubernetes/pod/types.go | 16 - pkg/kubernetes/quota/quota_suite_test.go | 16 - pkg/kubernetes/services/service.go | 47 +- pkg/kubernetes/services/service_suite_test.go | 3 +- pkg/kubernetes/services/service_test.go | 27 - pkg/kubernetes/services/types.go | 17 - pkg/kubernetes/storage/storage.go | 19 - pkg/kubernetes/storage/storage_suite_test.go | 16 - pkg/kubernetes/storage/types.go | 20 - pkg/kubernetes/usage/usage.go | 16 - pkg/kubernetes/usage/usage_suite_test.go | 16 - pkg/ready/ready.go | 16 - pkg/ready/ready_test.go | 16 - pkg/webhook/certwatcher/certwatcher.go | 16 - pkg/webhook/server/server.go | 16 - pkg/webhook/server/server_test.go | 24 - .../v1alpha1/clusterresourcequota_webhook.go | 77 +-- .../clusterresourcequota_webhook_test.go | 7 - pkg/webhook/v1alpha1/namespace_webhook.go | 16 - .../v1alpha1/namespace_webhook_test.go | 6 - pkg/webhook/v1alpha1/objectcount_webhook.go | 19 +- .../v1alpha1/objectcount_webhook_test.go | 104 +++- .../v1alpha1/persistentvolumeclaim_webhook.go | 20 +- .../persistentvolumeclaim_webhook_test.go | 568 +++++++----------- pkg/webhook/v1alpha1/pod_webhook.go | 58 +- pkg/webhook/v1alpha1/pod_webhook_test.go | 148 ----- pkg/webhook/v1alpha1/service_webhook.go | 44 +- pkg/webhook/v1alpha1/service_webhook_test.go | 86 +-- pkg/webhook/v1alpha1/v1alpha1_suite_test.go | 16 - pkg/webhook/v1alpha1/webhook_utils.go | 37 ++ pkg/webhook/webhook.go | 16 - pkg/webhook/webhook_suite_test.go | 16 - test/e2e/clusterresourcequota_webhook_test.go | 16 - test/e2e/e2e_suite_test.go | 16 - test/e2e/objectcount_webhook_test.go | 124 +++- test/e2e/pvc_webhook_test.go | 16 - test/e2e/service_webhook_test.go | 20 +- test/e2e/storage_class_quota_test.go | 16 - test/e2e/storage_resources_test.go | 16 - test/utils/helpers.go | 11 +- 57 files changed, 713 insertions(+), 1403 deletions(-) delete mode 100644 pkg/kubernetes/namespace/types.go delete mode 100644 pkg/kubernetes/pod/types.go delete mode 100644 pkg/kubernetes/services/types.go delete mode 100644 pkg/kubernetes/storage/types.go diff --git a/api/v1alpha1/clusterresourcequota_types.go b/api/v1alpha1/clusterresourcequota_types.go index 2b0310a..3156a11 100644 --- a/api/v1alpha1/clusterresourcequota_types.go +++ b/api/v1alpha1/clusterresourcequota_types.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index aad038e..126c5c1 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - // Package v1alpha1 contains API Schema definitions for the quota v1alpha1 API group. package v1alpha1 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c34c8af..e519601 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1,21 +1,5 @@ //go:build !ignore_autogenerated -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( diff --git a/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml b/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml index 4ac2588..92a3707 100755 --- a/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml +++ b/charts/pac-quota-controller/crds/quota.powerapp.cloud_clusterresourcequotas.yaml @@ -28,7 +28,7 @@ spec: ClusterResourceQuota is the Schema for the clusterresourcequotas API. It extends the standard Kubernetes ResourceQuota by allowing it to be applied across multiple namespaces that match a label selector. - + Supported object count resources (for use in the 'hard' and 'used' fields): - pods - services @@ -45,7 +45,7 @@ spec: - cronjobs.batch - horizontalpodautoscalers.autoscaling - ingresses.networking.k8s.io - + You may specify quotas for any of these resources. See the Helm chart documentation for details and examples. properties: apiVersion: @@ -93,7 +93,7 @@ spec: 'cronjobs.batch': '3' (CronJob count) 'horizontalpodautoscalers.autoscaling': '2' (HPA count) 'ingresses.networking.k8s.io': '3' (Ingress count) - + ...and so on for all supported native and extended resource types. type: object namespaceSelector: diff --git a/cmd/main.go b/cmd/main.go index 13be9c2..a9f3542 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package main import ( diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 4671de8..e69de29 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,15 +0,0 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ diff --git a/internal/controller/clusterresourcequota_controller.go b/internal/controller/clusterresourcequota_controller.go index 996fd8e..910eff7 100644 --- a/internal/controller/clusterresourcequota_controller.go +++ b/internal/controller/clusterresourcequota_controller.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package controller import ( @@ -115,7 +99,7 @@ type ClusterResourceQuotaReconciler struct { crqClient quota.CRQClientInterface ComputeCalculator *pod.PodResourceCalculator StorageCalculator *storage.StorageResourceCalculator - ServiceCalculator services.ServiceResourceCalculatorInterface + ServiceCalculator *services.ServiceResourceCalculator ExcludeNamespaceLabelKey string ExcludedNamespaces []string } @@ -241,12 +225,12 @@ func (r *ClusterResourceQuotaReconciler) calculateAndAggregateUsage( for _, nsName := range namespaces { var currentUsage resource.Quantity if r.StorageCalculator != nil { - usage, err := r.StorageCalculator.CalculateStorageClassUsage(ctx, nsName, storageClass) + storageUsage, err := r.StorageCalculator.CalculateStorageClassUsage(ctx, nsName, storageClass) if err != nil { log.Error(err, "Failed to calculate storage class usage", "resource", resourceName, "namespace", nsName, "storageClass", storageClass) currentUsage = resource.MustParse("0") } else { - currentUsage = usage + currentUsage = storageUsage } } else { log.Error(nil, "StorageCalculator is nil", "namespace", nsName, "resource", resourceName) @@ -346,22 +330,22 @@ func (r *ClusterResourceQuotaReconciler) calculateObjectCount(ctx context.Contex log.Error(nil, "ServiceCalculator is nil", "namespace", ns, "resource", resourceName) return resource.MustParse("0") } - usage, err := r.ServiceCalculator.CalculateUsage(ctx, ns, resourceName) + serviceCount, err := r.ServiceCalculator.CalculateUsage(ctx, ns, resourceName) if err != nil { log.Error(err, "Failed to calculate service usage", "resource", resourceName, "namespace", ns) return resource.MustParse("0") } - return usage + return serviceCount case usage.ResourceConfigMaps, usage.ResourceSecrets, usage.ResourceReplicationControllers, usage.ResourceDeployments, usage.ResourceStatefulSets, usage.ResourceDaemonSets, usage.ResourceJobs, usage.ResourceCronJobs, usage.ResourceHorizontalPodAutoscalers, usage.ResourceIngresses: calc := objectcount.NewObjectCountCalculator(r.KubeClient) - usage, err := calc.CalculateUsage(ctx, ns, resourceName) + objectCount, err := calc.CalculateUsage(ctx, ns, resourceName) if err != nil { log.Error(err, "Failed to calculate object count usage", "resource", resourceName, "namespace", ns) return resource.MustParse("0") } - return usage + return objectCount default: log.Info("Unsupported object count resource for calculateObjectCount", "resource", resourceName, "namespace", ns) return resource.MustParse("0") @@ -370,12 +354,12 @@ func (r *ClusterResourceQuotaReconciler) calculateObjectCount(ctx context.Contex // calculateComputeResources calculates the usage for compute resource quotas (CPU/Memory). func (r *ClusterResourceQuotaReconciler) calculateComputeResources(ctx context.Context, ns string, resourceName corev1.ResourceName) resource.Quantity { - usage, err := r.ComputeCalculator.CalculateUsage(ctx, ns, resourceName) + computeUsage, err := r.ComputeCalculator.CalculateUsage(ctx, ns, resourceName) if err != nil { log.Error(err, "Failed to calculate compute resources", "resource", resourceName, "namespace", ns) return resource.MustParse("0") } - return usage + return computeUsage } // calculateStorageResources calculates the usage for storage resource quotas. @@ -385,12 +369,12 @@ func (r *ClusterResourceQuotaReconciler) calculateStorageResources(ctx context.C return resource.MustParse("0") } - usage, err := r.StorageCalculator.CalculateUsage(ctx, ns, resourceName) + storageUsage, err := r.StorageCalculator.CalculateUsage(ctx, ns, resourceName) if err != nil { log.Error(err, "Failed to calculate storage resources", "resource", resourceName, "namespace", ns) return resource.MustParse("0") } - return usage + return storageUsage } // updateStatus updates the status of the ClusterResourceQuota object. diff --git a/internal/controller/clusterresourcequota_controller_test.go b/internal/controller/clusterresourcequota_controller_test.go index 9311400..bd80c79 100644 --- a/internal/controller/clusterresourcequota_controller_test.go +++ b/internal/controller/clusterresourcequota_controller_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package controller import ( diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 780531c..0799758 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package controller import ( diff --git a/pkg/health/health.go b/pkg/health/health.go index e14f5ef..8cd8b10 100644 --- a/pkg/health/health.go +++ b/pkg/health/health.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package health import ( diff --git a/pkg/health/health_test.go b/pkg/health/health_test.go index 93934f2..c99ac00 100644 --- a/pkg/health/health_test.go +++ b/pkg/health/health_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package health import ( diff --git a/pkg/kubernetes/namespace/namespace_suite_test.go b/pkg/kubernetes/namespace/namespace_suite_test.go index 9a4ba6e..3949638 100644 --- a/pkg/kubernetes/namespace/namespace_suite_test.go +++ b/pkg/kubernetes/namespace/namespace_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package namespace import ( diff --git a/pkg/kubernetes/namespace/types.go b/pkg/kubernetes/namespace/types.go deleted file mode 100644 index 0a323f8..0000000 --- a/pkg/kubernetes/namespace/types.go +++ /dev/null @@ -1,16 +0,0 @@ -package namespace - -import ( - "context" -) - -//go:generate mockery - -// NamespaceSelector defines the interface for namespace selection operations -type NamespaceSelector interface { - GetSelectedNamespaces(ctx context.Context) ([]string, error) - DetermineNamespaceChanges( - ctx context.Context, - previousNamespaces []string, - ) (added []string, removed []string, err error) -} diff --git a/pkg/kubernetes/objectcount/objectcount.go b/pkg/kubernetes/objectcount/objectcount.go index 61ea3c6..ec76100 100644 --- a/pkg/kubernetes/objectcount/objectcount.go +++ b/pkg/kubernetes/objectcount/objectcount.go @@ -1,5 +1,4 @@ // Package objectcount provides resource calculators for generic object count resources. -// Implements usage.ResourceCalculatorInterface for deployments, statefulsets, daemonsets, jobs, cronjobs, hpas, ingresses, configmaps, secrets, replicationcontrollers. package objectcount import ( @@ -24,11 +23,15 @@ func NewObjectCountCalculator(client kubernetes.Interface) *ObjectCountCalculato } // CalculateUsage returns the count of the specified resource in the namespace. -func (c *ObjectCountCalculator) CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) { +func (c *ObjectCountCalculator) CalculateUsage( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName) (resource.Quantity, error) { var count int64 var err error switch resourceName { + // There is always a kube-root-ca.crt configmap in each namespace case "configmaps": list, e := c.Client.CoreV1().ConfigMaps(namespace).List(ctx, metav1.ListOptions{}) count, err = int64(len(list.Items)), e @@ -70,7 +73,10 @@ func (c *ObjectCountCalculator) CalculateUsage(ctx context.Context, namespace st } // CalculateTotalUsage returns a map with the count for the configured resource in the namespace. -func (c *ObjectCountCalculator) CalculateTotalUsage(ctx context.Context, resourceName corev1.ResourceName, namespace string) (map[corev1.ResourceName]resource.Quantity, error) { +func (c *ObjectCountCalculator) CalculateTotalUsage( + ctx context.Context, + resourceName corev1.ResourceName, + namespace string) (map[corev1.ResourceName]resource.Quantity, error) { usage := make(map[corev1.ResourceName]resource.Quantity) q, err := c.CalculateUsage(ctx, namespace, resourceName) if err != nil { diff --git a/pkg/kubernetes/objectcount/objectcount_test.go b/pkg/kubernetes/objectcount/objectcount_test.go index 4df69e7..611bb6d 100644 --- a/pkg/kubernetes/objectcount/objectcount_test.go +++ b/pkg/kubernetes/objectcount/objectcount_test.go @@ -16,6 +16,8 @@ import ( "k8s.io/client-go/kubernetes/fake" ) +const nsName = "objectcount-test-ns" + var _ = Describe("ObjectCountCalculator", func() { var ( ctx context.Context @@ -34,29 +36,77 @@ var _ = Describe("ObjectCountCalculator", func() { DescribeTable("CalculateTotalUsage for all supported resources", func(resourceName string, object runtime.Object, expected int64) { - ns := "ns1" rn := corev1.ResourceName(resourceName) client := fake.NewSimpleClientset(object) calc := NewObjectCountCalculator(client) - usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + usage, err := calc.CalculateTotalUsage(ctx, rn, nsName) Expect(err).ToNot(HaveOccurred()) q := usage[rn] Expect(q.Value()).To(Equal(expected)) }, - Entry("configmaps", "configmaps", &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "ns1"}}, int64(1)), - Entry("secrets", "secrets", &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s1", Namespace: "ns1"}}, int64(1)), - Entry("replicationcontrollers", "replicationcontrollers", &corev1.ReplicationController{ObjectMeta: metav1.ObjectMeta{Name: "rc1", Namespace: "ns1"}}, int64(1)), - Entry("deployments.apps", "deployments.apps", &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: "ns1"}}, int64(1)), - Entry("statefulsets.apps", "statefulsets.apps", &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "st1", Namespace: "ns1"}}, int64(1)), - Entry("daemonsets.apps", "daemonsets.apps", &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "ds1", Namespace: "ns1"}}, int64(1)), - Entry("jobs.batch", "jobs.batch", &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job1", Namespace: "ns1"}}, int64(1)), - Entry("cronjobs.batch", "cronjobs.batch", &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: "cj1", Namespace: "ns1"}}, int64(1)), - Entry("horizontalpodautoscalers.autoscaling", "horizontalpodautoscalers.autoscaling", &autoscalingv1.HorizontalPodAutoscaler{ObjectMeta: metav1.ObjectMeta{Name: "hpa1", Namespace: "ns1"}}, int64(1)), - Entry("ingresses.networking.k8s.io", "ingresses.networking.k8s.io", &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "ing1", Namespace: "ns1"}}, int64(1)), + Entry( + "Validate configmaps", + "configmaps", + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate secrets", + "secrets", + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s1", Namespace: nsName}}, + int64(1)), + Entry( + "Validate replicationcontrollers", + "replicationcontrollers", + &corev1.ReplicationController{ObjectMeta: metav1.ObjectMeta{Name: "rc1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate deployments", + "deployments.apps", + &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate statefulsets", + "statefulsets.apps", + &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "st1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate daemonsets", + "daemonsets.apps", + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "ds1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate jobs", + "jobs.batch", + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate cronjobs", + "cronjobs.batch", + &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: "cj1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate hpa", + "horizontalpodautoscalers.autoscaling", + &autoscalingv1.HorizontalPodAutoscaler{ObjectMeta: metav1.ObjectMeta{Name: "hpa1", Namespace: nsName}}, + int64(1), + ), + Entry( + "Validate ingresses", + "ingresses.networking.k8s.io", + &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "ing1", Namespace: nsName}}, + int64(1), + ), ) It("should count multiple resources of the same type", func() { - ns := "ns1" + ns := nsName rn := corev1.ResourceName("configmaps") cm1 := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: ns}} cm2 := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: ns}} @@ -69,7 +119,7 @@ var _ = Describe("ObjectCountCalculator", func() { }) It("should count multiple resource types in the same namespace", func() { - ns := "ns1" + ns := nsName rnCM := corev1.ResourceName("configmaps") rnSecret := corev1.ResourceName("secrets") cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: ns}} @@ -88,8 +138,8 @@ var _ = Describe("ObjectCountCalculator", func() { }) It("should return zero for no resources present", func() { - ns := "ns1" - rn := corev1.ResourceName("configmaps") + ns := nsName + rn := corev1.ResourceName("pods") client := fake.NewSimpleClientset() calc := NewObjectCountCalculator(client) usage, err := calc.CalculateTotalUsage(ctx, rn, ns) @@ -99,7 +149,7 @@ var _ = Describe("ObjectCountCalculator", func() { }) It("should return zero for inexistent resource type", func() { - ns := "ns1" + ns := nsName rn := corev1.ResourceName("nonexistent") client := fake.NewSimpleClientset() calc := NewObjectCountCalculator(client) diff --git a/pkg/kubernetes/pod/pod.go b/pkg/kubernetes/pod/pod.go index 12dcbc4..e5665a7 100644 --- a/pkg/kubernetes/pod/pod.go +++ b/pkg/kubernetes/pod/pod.go @@ -21,9 +21,6 @@ type PodResourceCalculator struct { usage.BaseResourceCalculator } -// Ensure PodResourceCalculator implements PodResourceCalculatorInterface -var _ PodResourceCalculatorInterface = &PodResourceCalculator{} - // NewPodResourceCalculator creates a new PodResourceCalculator func NewPodResourceCalculator(c kubernetes.Interface) *PodResourceCalculator { return &PodResourceCalculator{ @@ -90,6 +87,9 @@ func getContainerResourceUsage(container corev1.Container, resourceName corev1.R } default: // Handle extended resources with 'requests.' prefix + // As the CRQ Hard Spec requires the resource name to be in the format 'requests.' + // https://kubernetes.io/docs/concepts/policy/resource-quotas/#quota-for-extended-resources + // We need to remove the prefix, as the pod requests is a nested key s := string(resourceName) if strings.HasPrefix(s, "requests.") { extName := corev1.ResourceName(s[len("requests."):]) @@ -121,7 +121,10 @@ func (c *PodResourceCalculator) CalculateUsage( if err != nil { return resource.Quantity{}, err } - return *resource.NewQuantity(podCount, resource.DecimalSI), nil + return *resource.NewQuantity( + podCount, + resource.DecimalSI, + ), nil } podList, err := c.Client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) diff --git a/pkg/kubernetes/pod/pod_test.go b/pkg/kubernetes/pod/pod_test.go index 839a14e..414d531 100644 --- a/pkg/kubernetes/pod/pod_test.go +++ b/pkg/kubernetes/pod/pod_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package pod import ( diff --git a/pkg/kubernetes/pod/types.go b/pkg/kubernetes/pod/types.go deleted file mode 100644 index 5087764..0000000 --- a/pkg/kubernetes/pod/types.go +++ /dev/null @@ -1,16 +0,0 @@ -package pod - -import ( - "context" - - "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" -) - -//go:generate mockery - -// PodResourceCalculatorInterface defines the interface for pod resource calculations -type PodResourceCalculatorInterface interface { - usage.ResourceCalculatorInterface - // CalculatePodCount calculates the number of non-terminal pods in a namespace - CalculatePodCount(ctx context.Context, namespace string) (int64, error) -} diff --git a/pkg/kubernetes/quota/quota_suite_test.go b/pkg/kubernetes/quota/quota_suite_test.go index 7e02b38..7c2a1d1 100644 --- a/pkg/kubernetes/quota/quota_suite_test.go +++ b/pkg/kubernetes/quota/quota_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package quota import ( diff --git a/pkg/kubernetes/services/service.go b/pkg/kubernetes/services/service.go index c92dc9c..e1bbd32 100644 --- a/pkg/kubernetes/services/service.go +++ b/pkg/kubernetes/services/service.go @@ -12,13 +12,17 @@ import ( ) // CountServices returns the total number of services and a breakdown by type in the namespace (public interface). -func (c *ServiceResourceCalculator) CountServices(ctx context.Context, namespace string) (int64, map[corev1.ServiceType]int64, error) { +func (c *ServiceResourceCalculator) CountServices( + ctx context.Context, + namespace string, +) ( + int64, + map[corev1.ServiceType]int64, + error, +) { return c.countServicesByType(ctx, namespace) } -// Ensure ServiceResourceCalculator implements usage.ResourceCalculatorInterface -var _ usage.ResourceCalculatorInterface = &ServiceResourceCalculator{} - // ServiceResourceCalculator provides methods for counting services and subtypes in a namespace. type ServiceResourceCalculator struct { Client kubernetes.Interface @@ -36,7 +40,14 @@ var resourceNameToServiceType = map[corev1.ResourceName]corev1.ServiceType{ } // CalculateUsage returns the usage count for a specific service type resource in the namespace. -func (c *ServiceResourceCalculator) CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) { +func (c *ServiceResourceCalculator) CalculateUsage( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName, +) ( + resource.Quantity, + error, +) { total, byType, err := c.countServicesByType(ctx, namespace) if err != nil { return resource.Quantity{}, err @@ -57,21 +68,37 @@ func (c *ServiceResourceCalculator) CalculateUsage(ctx context.Context, namespac } // CalculateTotalUsage calculates the total usage for all supported service count resources in a namespace. -func (c *ServiceResourceCalculator) CalculateTotalUsage(ctx context.Context, namespace string) (map[corev1.ResourceName]resource.Quantity, error) { +func (c *ServiceResourceCalculator) CalculateTotalUsage( + ctx context.Context, + namespace string, +) ( + map[corev1.ResourceName]resource.Quantity, + error, +) { total, byType, err := c.countServicesByType(ctx, namespace) if err != nil { return nil, err } result := map[corev1.ResourceName]resource.Quantity{ - usage.ResourceServices: *resource.NewQuantity(total, resource.DecimalSI), - usage.ResourceServicesLoadBalancers: *resource.NewQuantity(byType[corev1.ServiceTypeLoadBalancer], resource.DecimalSI), - usage.ResourceServicesNodePorts: *resource.NewQuantity(byType[corev1.ServiceTypeNodePort], resource.DecimalSI), + usage.ResourceServices: *resource.NewQuantity(total, resource.DecimalSI), + usage.ResourceServicesLoadBalancers: *resource.NewQuantity( + byType[corev1.ServiceTypeLoadBalancer], + resource.DecimalSI, + ), + usage.ResourceServicesNodePorts: *resource.NewQuantity(byType[corev1.ServiceTypeNodePort], resource.DecimalSI), } return result, nil } // CountServices returns the total number of services and a breakdown by type in the namespace. -func (c *ServiceResourceCalculator) countServicesByType(ctx context.Context, namespace string) (total int64, byType map[corev1.ServiceType]int64, err error) { +func (c *ServiceResourceCalculator) countServicesByType( + ctx context.Context, + namespace string, +) ( + total int64, + byType map[corev1.ServiceType]int64, + err error, +) { serviceList, err := c.Client.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return 0, nil, err diff --git a/pkg/kubernetes/services/service_suite_test.go b/pkg/kubernetes/services/service_suite_test.go index c27f20f..01f10ba 100644 --- a/pkg/kubernetes/services/service_suite_test.go +++ b/pkg/kubernetes/services/service_suite_test.go @@ -1,9 +1,10 @@ package services import ( + "testing" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "testing" ) func TestServices(t *testing.T) { diff --git a/pkg/kubernetes/services/service_test.go b/pkg/kubernetes/services/service_test.go index 2b32534..2152f7c 100644 --- a/pkg/kubernetes/services/service_test.go +++ b/pkg/kubernetes/services/service_test.go @@ -3,16 +3,12 @@ package services import ( "context" - "fmt" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" - "k8s.io/client-go/testing" ) var _ = Describe("ServiceResourceCalculator", func() { @@ -71,17 +67,6 @@ var _ = Describe("ServiceResourceCalculator", func() { q = m[usage.ResourceServicesNodePorts] Expect((&q).Value()).To(Equal(int64(0))) }) - - It("returns error if client returns error", func() { - badClient := fake.NewSimpleClientset() - badClient.PrependReactor("list", "services", func(action testing.Action) (handled bool, ret runtime.Object, err error) { - return true, nil, fmt.Errorf("fake list error") - }) - badCalc := NewServiceResourceCalculator(badClient) - m, err := badCalc.CalculateTotalUsage(ctx, "ns1") - Expect(err).To(HaveOccurred()) - Expect(m).To(BeNil()) - }) }) Describe("CalculateUsage", func() { @@ -123,17 +108,5 @@ var _ = Describe("ServiceResourceCalculator", func() { Expect(err).ToNot(HaveOccurred()) Expect(q.Value()).To(Equal(int64(0))) }) - - It("returns error if client returns error", func() { - // Use a fake client with a reactor that always returns an error - badClient := fake.NewSimpleClientset() - badClient.PrependReactor("list", "services", func(action testing.Action) (handled bool, ret runtime.Object, err error) { - return true, nil, fmt.Errorf("fake list error") - }) - badCalc := NewServiceResourceCalculator(badClient) - q, err := badCalc.CalculateUsage(ctx, "ns1", usage.ResourceServices) - Expect(err).To(HaveOccurred()) - Expect(q.Value()).To(Equal(int64(0))) - }) }) }) diff --git a/pkg/kubernetes/services/types.go b/pkg/kubernetes/services/types.go deleted file mode 100644 index f7e756a..0000000 --- a/pkg/kubernetes/services/types.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:generate mockery --name=ServiceResourceCalculatorInterface -package services - -import ( - "context" - - "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" - corev1 "k8s.io/api/core/v1" -) - -//go:generate mockery - -// ServiceResourceCalculatorInterface defines the interface for service resource calculations -type ServiceResourceCalculatorInterface interface { - usage.ResourceCalculatorInterface - CountServices(ctx context.Context, namespace string) (total int64, byType map[corev1.ServiceType]int64, err error) -} diff --git a/pkg/kubernetes/storage/storage.go b/pkg/kubernetes/storage/storage.go index d1040a2..be8112e 100644 --- a/pkg/kubernetes/storage/storage.go +++ b/pkg/kubernetes/storage/storage.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package storage import ( @@ -37,9 +21,6 @@ type StorageResourceCalculator struct { usage.BaseResourceCalculator } -// Ensure StorageResourceCalculator implements StorageResourceCalculatorInterface -var _ StorageResourceCalculatorInterface = &StorageResourceCalculator{} - // NewStorageResourceCalculator creates a new instance of StorageResourceCalculator. func NewStorageResourceCalculator(c kubernetes.Interface) *StorageResourceCalculator { return &StorageResourceCalculator{ diff --git a/pkg/kubernetes/storage/storage_suite_test.go b/pkg/kubernetes/storage/storage_suite_test.go index 36a2fd0..3a6a693 100644 --- a/pkg/kubernetes/storage/storage_suite_test.go +++ b/pkg/kubernetes/storage/storage_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package storage import ( diff --git a/pkg/kubernetes/storage/types.go b/pkg/kubernetes/storage/types.go deleted file mode 100644 index ba9c113..0000000 --- a/pkg/kubernetes/storage/types.go +++ /dev/null @@ -1,20 +0,0 @@ -package storage - -import ( - "context" - - "k8s.io/apimachinery/pkg/api/resource" - - "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" -) - -//go:generate mockery - -// StorageResourceCalculatorInterface defines the interface for storage resource calculations -type StorageResourceCalculatorInterface interface { - usage.ResourceCalculatorInterface - // Additional storage-specific methods - CalculateStorageClassUsage(ctx context.Context, namespace, storageClass string) (resource.Quantity, error) - CalculateStorageClassCount(ctx context.Context, namespace, storageClass string) (int64, error) - CalculatePVCCount(ctx context.Context, namespace string) (int64, error) -} diff --git a/pkg/kubernetes/usage/usage.go b/pkg/kubernetes/usage/usage.go index c7fc0c7..fd1fdd6 100644 --- a/pkg/kubernetes/usage/usage.go +++ b/pkg/kubernetes/usage/usage.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package usage import ( diff --git a/pkg/kubernetes/usage/usage_suite_test.go b/pkg/kubernetes/usage/usage_suite_test.go index ba271bf..81eba88 100644 --- a/pkg/kubernetes/usage/usage_suite_test.go +++ b/pkg/kubernetes/usage/usage_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package usage import ( diff --git a/pkg/ready/ready.go b/pkg/ready/ready.go index 47b2ceb..0e34f19 100644 --- a/pkg/ready/ready.go +++ b/pkg/ready/ready.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package ready import ( diff --git a/pkg/ready/ready_test.go b/pkg/ready/ready_test.go index e0bad55..bfff7bb 100644 --- a/pkg/ready/ready_test.go +++ b/pkg/ready/ready_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package ready import ( diff --git a/pkg/webhook/certwatcher/certwatcher.go b/pkg/webhook/certwatcher/certwatcher.go index e0a874f..98e7968 100644 --- a/pkg/webhook/certwatcher/certwatcher.go +++ b/pkg/webhook/certwatcher/certwatcher.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package certwatcher import ( diff --git a/pkg/webhook/server/server.go b/pkg/webhook/server/server.go index cb60cb0..9d42bd5 100644 --- a/pkg/webhook/server/server.go +++ b/pkg/webhook/server/server.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package server import ( diff --git a/pkg/webhook/server/server_test.go b/pkg/webhook/server/server_test.go index e7b75dc..ebf1688 100644 --- a/pkg/webhook/server/server_test.go +++ b/pkg/webhook/server/server_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package server import ( @@ -127,14 +111,6 @@ var _ = Describe("GinWebhookServer", func() { }) }) - Describe("StartWithSignalHandler", func() { - It("should have proper signal handling setup", func() { - // Test that the function exists and can be called - // We skip the actual execution since it waits for OS signals - Skip("Skipping signal handler test as it requires OS signals and would hang") - }) - }) - Describe("Health and Readiness endpoints", func() { It("should have health endpoint configured in routes", func() { // Test that the server routes are properly configured without starting the server diff --git a/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go b/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go index 002078e..de167c5 100644 --- a/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go +++ b/pkg/webhook/v1alpha1/clusterresourcequota_webhook.go @@ -24,7 +24,7 @@ import ( type ClusterResourceQuotaWebhook struct { client kubernetes.Interface crqClient *quota.CRQClient - serviceCalculator services.ServiceResourceCalculatorInterface + serviceCalculator services.ServiceResourceCalculator log *zap.Logger } @@ -37,7 +37,7 @@ func NewClusterResourceQuotaWebhook( return &ClusterResourceQuotaWebhook{ client: k8sClient, crqClient: crqClient, - serviceCalculator: services.NewServiceResourceCalculator(k8sClient), + serviceCalculator: *services.NewServiceResourceCalculator(k8sClient), log: log, } } @@ -123,17 +123,9 @@ func (h *ClusterResourceQuotaWebhook) Handle(c *gin.Context) { h.log.Info("Validating ClusterResourceQuota on update", zap.String("name", crq.GetName())) err = h.validateUpdate(ctx, &crq) - case admissionv1.Delete: - h.log.Info("Validating ClusterResourceQuota on delete", - zap.String("name", crq.GetName())) - err = h.validateDelete(ctx) default: h.log.Info("Unsupported operation", zap.String("operation", string(admissionReview.Request.Operation))) - admissionReview.Response.Allowed = false - admissionReview.Response.Result = &metav1.Status{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("Operation %s is not supported for ClusterResourceQuota", admissionReview.Request.Operation), - } + admissionReview.Response.Allowed = true c.JSON(http.StatusOK, admissionReview) return } @@ -152,7 +144,8 @@ func (h *ClusterResourceQuotaWebhook) Handle(c *gin.Context) { c.JSON(http.StatusOK, admissionReview) } -func (h *ClusterResourceQuotaWebhook) validateCreate( +// validateOperation is a shared helper for create/update validation +func (h *ClusterResourceQuotaWebhook) validateOperation( ctx context.Context, crq *quotav1alpha1.ClusterResourceQuota, ) error { @@ -192,7 +185,12 @@ func (h *ClusterResourceQuotaWebhook) validateCreate( } hardQty := crq.Spec.Hard[resourceName] if totalUsage.Cmp(hardQty) > 0 { - return fmt.Errorf("quota exceeded for %s: used %s, hard limit %s", resourceName, totalUsage.String(), hardQty.String()) + return fmt.Errorf( + "quota exceeded for %s: used %s, hard limit %s", + resourceName, + totalUsage.String(), + hardQty.String(), + ) } } } @@ -200,55 +198,16 @@ func (h *ClusterResourceQuotaWebhook) validateCreate( return nil } -func (h *ClusterResourceQuotaWebhook) validateUpdate( +func (h *ClusterResourceQuotaWebhook) validateCreate( ctx context.Context, crq *quotav1alpha1.ClusterResourceQuota, ) error { - if h.crqClient == nil { - return fmt.Errorf("CRQ client not available for validation") - } - - validator := namespace.NewNamespaceValidator(h.client, h.crqClient) - if err := validator.ValidateCRQNamespaceConflicts(ctx, crq); err != nil { - return err - } - - // Validate service object count quotas for all supported service resource types - if crq.Spec.Hard != nil && crq.Spec.NamespaceSelector != nil { - selector, err := namespace.NewLabelBasedNamespaceSelector(h.client, crq.Spec.NamespaceSelector) - if err != nil { - return fmt.Errorf("failed to create namespace selector: %w", err) - } - selectedNamespaces, err := selector.GetSelectedNamespaces(ctx) - if err != nil { - return fmt.Errorf("failed to get selected namespaces: %w", err) - } - for resourceName := range crq.Spec.Hard { - switch resourceName { - case "services", "services.loadbalancers", "services.nodeports", "services.clusterips", "services.externalnames": - var totalUsage resource.Quantity - for _, ns := range selectedNamespaces { - usageQty, err := h.serviceCalculator.CalculateUsage(ctx, ns, resourceName) - if err != nil { - return fmt.Errorf("failed to calculate usage for %s in namespace %s: %w", resourceName, ns, err) - } - if totalUsage.IsZero() { - totalUsage = usageQty.DeepCopy() - } else { - totalUsage.Add(usageQty) - } - } - hardQty := crq.Spec.Hard[resourceName] - if totalUsage.Cmp(hardQty) > 0 { - return fmt.Errorf("quota exceeded for %s: used %s, hard limit %s", resourceName, totalUsage.String(), hardQty.String()) - } - } - } - } - return nil + return h.validateOperation(ctx, crq) } -func (h *ClusterResourceQuotaWebhook) validateDelete(_ context.Context) error { - // No validation needed for delete operations - return nil +func (h *ClusterResourceQuotaWebhook) validateUpdate( + ctx context.Context, + crq *quotav1alpha1.ClusterResourceQuota, +) error { + return h.validateOperation(ctx, crq) } diff --git a/pkg/webhook/v1alpha1/clusterresourcequota_webhook_test.go b/pkg/webhook/v1alpha1/clusterresourcequota_webhook_test.go index 9194fca..054e240 100644 --- a/pkg/webhook/v1alpha1/clusterresourcequota_webhook_test.go +++ b/pkg/webhook/v1alpha1/clusterresourcequota_webhook_test.go @@ -123,13 +123,6 @@ var _ = Describe("ClusterResourceQuotaWebhook", func() { }) }) - Describe("validateDelete", func() { - It("should validate cluster resource quota deletion", func() { - err := webhook.validateDelete(ctx) - Expect(err).ToNot(HaveOccurred()) - }) - }) - Describe("Handle", func() { It("should handle create operation", func() { // Create admission review diff --git a/pkg/webhook/v1alpha1/namespace_webhook.go b/pkg/webhook/v1alpha1/namespace_webhook.go index b00d374..b4b3fd9 100644 --- a/pkg/webhook/v1alpha1/namespace_webhook.go +++ b/pkg/webhook/v1alpha1/namespace_webhook.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( diff --git a/pkg/webhook/v1alpha1/namespace_webhook_test.go b/pkg/webhook/v1alpha1/namespace_webhook_test.go index b63818a..81ffd3f 100644 --- a/pkg/webhook/v1alpha1/namespace_webhook_test.go +++ b/pkg/webhook/v1alpha1/namespace_webhook_test.go @@ -247,12 +247,6 @@ var _ = Describe("NamespaceWebhook", func() { Expect(w.Code).To(Equal(http.StatusOK)) }) - It("should handle decode error", func() { - // Skip this test for now as the webhook successfully decodes valid JSON - // In real scenarios, decode errors would occur with malformed Namespace data - Skip("Skipping decode error test - webhook successfully handles valid JSON") - }) - Describe("validateNamespaceAgainstCRQs edge cases", func() { var ctx context.Context diff --git a/pkg/webhook/v1alpha1/objectcount_webhook.go b/pkg/webhook/v1alpha1/objectcount_webhook.go index 322e848..eb32c9e 100644 --- a/pkg/webhook/v1alpha1/objectcount_webhook.go +++ b/pkg/webhook/v1alpha1/objectcount_webhook.go @@ -47,7 +47,6 @@ func (h *ObjectCountWebhook) Handle(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - h.log.Info("Received request for ObjectCountWebhook", zap.String("resource", admissionReview.Request.Resource.Resource)) // Check for malformed requests (like {}) that don't have proper AdmissionReview structure if admissionReview.Kind == "" && admissionReview.APIVersion == "" && admissionReview.Request == nil { @@ -122,16 +121,28 @@ func (h *ObjectCountWebhook) Handle(c *gin.Context) { c.JSON(http.StatusOK, admissionReview) } -func (h *ObjectCountWebhook) validateCreate(ctx context.Context, namespace string, resourceName corev1.ResourceName) ([]string, error) { +func (h *ObjectCountWebhook) validateCreate( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName) ([]string, error) { return h.validateObjectOperation(ctx, namespace, resourceName, "creation") } -func (h *ObjectCountWebhook) validateUpdate(ctx context.Context, namespace string, resourceName corev1.ResourceName) ([]string, error) { +func (h *ObjectCountWebhook) validateUpdate( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName, +) ([]string, error) { return h.validateObjectOperation(ctx, namespace, resourceName, "update") } // validateObjectOperation is a shared function for both create and update validation -func (h *ObjectCountWebhook) validateObjectOperation(ctx context.Context, namespace string, resourceName corev1.ResourceName, operation string) ([]string, error) { +func (h *ObjectCountWebhook) validateObjectOperation( + ctx context.Context, + namespace string, + resourceName corev1.ResourceName, + operation string, +) ([]string, error) { if resourceName == "" { h.log.Info("Skipping CRQ validation for nil object on " + operation) return nil, nil diff --git a/pkg/webhook/v1alpha1/objectcount_webhook_test.go b/pkg/webhook/v1alpha1/objectcount_webhook_test.go index e24474a..df42676 100644 --- a/pkg/webhook/v1alpha1/objectcount_webhook_test.go +++ b/pkg/webhook/v1alpha1/objectcount_webhook_test.go @@ -32,6 +32,7 @@ var _ = Describe("ObjectCountWebhook", func() { logger *zap.Logger ginEngine *gin.Engine scheme *runtime.Scheme + nsName string ) BeforeEach(func() { @@ -43,6 +44,7 @@ var _ = Describe("ObjectCountWebhook", func() { logger = zap.NewNop() gin.SetMode(gin.TestMode) ginEngine = gin.New() + nsName = "test-namespace" }) AfterEach(func() { @@ -116,7 +118,7 @@ var _ = Describe("ObjectCountWebhook", func() { }, } ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "test-namespace", Labels: map[string]string{"env": "test"}}, + ObjectMeta: metav1.ObjectMeta{Name: nsName, Labels: map[string]string{"env": "test"}}, } _, _ = fakeClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) fakeRuntimeClient := ctrlclientfake.NewClientBuilder(). @@ -128,8 +130,17 @@ var _ = Describe("ObjectCountWebhook", func() { }) It("should allow creation when under quota", func() { - _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) - review := createObjectCountAdmissionReview("123", "test-namespace", "configmaps") + _, _ = fakeClient.CoreV1().ConfigMaps(nsName).Create( + context.Background(), + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cm1", + Namespace: nsName, + }, + }, + metav1.CreateOptions{}, + ) + review := createObjectCountAdmissionReview("123", nsName, "configmaps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -142,9 +153,28 @@ var _ = Describe("ObjectCountWebhook", func() { It("should deny creation when quota exceeded", func() { // Add 2 configmaps to reach quota - _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) - _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: "test-namespace"}}, metav1.CreateOptions{}) - review := createObjectCountAdmissionReview("456", "test-namespace", "configmaps") + _, err := fakeClient.CoreV1().ConfigMaps(nsName).Create( + context.Background(), + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cm1", + Namespace: nsName, + }, + }, metav1.CreateOptions{}, + ) + Expect(err).ToNot(HaveOccurred()) + _, err = fakeClient.CoreV1().ConfigMaps(nsName).Create( + context.Background(), + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cm2", + Namespace: nsName, + }, + }, + metav1.CreateOptions{}, + ) + Expect(err).ToNot(HaveOccurred()) + review := createObjectCountAdmissionReview("456", nsName, "configmaps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -159,9 +189,20 @@ var _ = Describe("ObjectCountWebhook", func() { It("should allow creation with multiple objects under quota", func() { // Add 1 configmap, quota is 2 - _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cm1", + Namespace: nsName, + }, + } + _, err := fakeClient.CoreV1().ConfigMaps(nsName).Create( + context.Background(), + cm, + metav1.CreateOptions{}, + ) + Expect(err).ToNot(HaveOccurred()) // Simulate batch creation (not strictly supported by AdmissionReview, but test logic) - review := createObjectCountAdmissionReview("789", "test-namespace", "configmaps") + review := createObjectCountAdmissionReview("789", nsName, "configmaps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -173,7 +214,7 @@ var _ = Describe("ObjectCountWebhook", func() { }) It("should allow creation of one deployment", func() { - review := createObjectCountAdmissionReview("2001", "test-namespace", "deployments.apps") + review := createObjectCountAdmissionReview("2001", nsName, "deployments.apps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -186,8 +227,8 @@ var _ = Describe("ObjectCountWebhook", func() { It("should deny creation of two deployments", func() { // Create one existing deployment - dep, err := webhook.client.AppsV1().Deployments("test-namespace").Create(context.Background(), &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: "test-namespace"}, + dep, err := webhook.client.AppsV1().Deployments(nsName).Create(context.Background(), &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: nsName}, Spec: appsv1.DeploymentSpec{ Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{"app": "myapp"}, @@ -207,10 +248,10 @@ var _ = Describe("ObjectCountWebhook", func() { }, }, }, metav1.CreateOptions{}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(dep).NotTo(BeNil()) Expect(dep).NotTo(BeNil()) - review := createObjectCountAdmissionReview("2002", "test-namespace", "deployments.apps") + review := createObjectCountAdmissionReview("2002", nsName, "deployments.apps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -224,7 +265,7 @@ var _ = Describe("ObjectCountWebhook", func() { }) It("should allow creation of one deployment and one ingress", func() { - reviewDep := createObjectCountAdmissionReview("2003", "test-namespace", "deployments.apps") + reviewDep := createObjectCountAdmissionReview("2003", nsName, "deployments.apps") bodyDep, _ := json.Marshal(reviewDep) reqDep := httptest.NewRequest("POST", "/webhook", bytes.NewReader(bodyDep)) wDep := httptest.NewRecorder() @@ -234,7 +275,7 @@ var _ = Describe("ObjectCountWebhook", func() { _ = json.Unmarshal(wDep.Body.Bytes(), &respDep) Expect(respDep.Response.Allowed).To(BeTrue()) - reviewIng := createObjectCountAdmissionReview("2004", "test-namespace", "ingresses.networking.k8s.io") + reviewIng := createObjectCountAdmissionReview("2004", nsName, "ingresses.networking.k8s.io") bodyIng, _ := json.Marshal(reviewIng) reqIng := httptest.NewRequest("POST", "/webhook", bytes.NewReader(bodyIng)) wIng := httptest.NewRecorder() @@ -247,9 +288,26 @@ var _ = Describe("ObjectCountWebhook", func() { It("should deny creation with multiple objects over quota", func() { // Add 2 configmaps, quota is 2 - _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "test-namespace"}}, metav1.CreateOptions{}) - _, _ = fakeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: "test-namespace"}}, metav1.CreateOptions{}) - review := createObjectCountAdmissionReview("1011", "test-namespace", "configmaps") + // Add 2 configmaps, quota is 2 + cm1 := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cm1", + Namespace: nsName, + }, + } + cm2 := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cm2", + Namespace: nsName, + }, + } + _, err := fakeClient.CoreV1().ConfigMaps(nsName).Create( + context.Background(), cm1, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + _, err = fakeClient.CoreV1().ConfigMaps(nsName).Create( + context.Background(), cm2, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + review := createObjectCountAdmissionReview("1011", nsName, "configmaps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -264,7 +322,7 @@ var _ = Describe("ObjectCountWebhook", func() { It("should allow creation with zero objects", func() { // No configmaps present - review := createObjectCountAdmissionReview("1213", "test-namespace", "configmaps") + review := createObjectCountAdmissionReview("1213", nsName, "configmaps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -276,7 +334,7 @@ var _ = Describe("ObjectCountWebhook", func() { }) It("should allow creation of unknown resource type", func() { - review := createObjectCountAdmissionReview("1415", "test-namespace", "invalidresource") + review := createObjectCountAdmissionReview("1415", nsName, "invalidresource") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -303,7 +361,7 @@ var _ = Describe("ObjectCountWebhook", func() { It("should allow creation when CRQClient fails", func() { // Simulate CRQClient failure by passing nil client webhook.crqClient = quota.NewCRQClient(nil) - review := createObjectCountAdmissionReview("1819", "test-namespace", "configmaps") + review := createObjectCountAdmissionReview("1819", nsName, "configmaps") body, _ := json.Marshal(review) req := httptest.NewRequest("POST", "/webhook", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -317,13 +375,13 @@ var _ = Describe("ObjectCountWebhook", func() { }) }) -func createObjectCountAdmissionReview(uid, namespace, resource string) admissionv1.AdmissionReview { +func createObjectCountAdmissionReview(uid, namespace, resourceName string) admissionv1.AdmissionReview { return admissionv1.AdmissionReview{ Request: &admissionv1.AdmissionRequest{ UID: types.UID(uid), Namespace: namespace, Operation: admissionv1.Create, - Resource: metav1.GroupVersionResource{Resource: resource}, + Resource: metav1.GroupVersionResource{Resource: resourceName}, }, } } diff --git a/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook.go b/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook.go index ea0d166..be9eda1 100644 --- a/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook.go +++ b/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( @@ -40,7 +24,7 @@ import ( // PersistentVolumeClaimWebhook handles webhook requests for PersistentVolumeClaim resources type PersistentVolumeClaimWebhook struct { client kubernetes.Interface - storageCalculator storage.StorageResourceCalculatorInterface + storageCalculator storage.StorageResourceCalculator crqClient *quota.CRQClient log *zap.Logger } @@ -53,7 +37,7 @@ func NewPersistentVolumeClaimWebhook( ) *PersistentVolumeClaimWebhook { return &PersistentVolumeClaimWebhook{ client: k8sClient, - storageCalculator: storage.NewStorageResourceCalculator(k8sClient), + storageCalculator: *storage.NewStorageResourceCalculator(k8sClient), crqClient: crqClient, log: log, } diff --git a/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook_test.go b/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook_test.go index 5f65887..bc32e3f 100644 --- a/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook_test.go +++ b/pkg/webhook/v1alpha1/persistentvolumeclaim_webhook_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "net/http" "net/http/httptest" @@ -25,7 +24,6 @@ import ( quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" - "github.com/powerhome/pac-quota-controller/pkg/mocks" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,7 +32,6 @@ import ( const ( premiumSSDStorageClass = "premium-ssd" fastSSDStorageClassResourceName = "fast-ssd.storageclass.storage.k8s.io/persistentvolumeclaims" - premiumSSDStorageClassRequests = "premium-ssd.storageclass.storage.k8s.io/requests.storage" ) var _ = Describe("PersistentVolumeClaimWebhook", func() { @@ -118,7 +115,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -139,7 +136,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -160,7 +157,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -179,7 +176,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, } @@ -200,7 +197,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, } @@ -256,7 +253,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -277,7 +274,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -298,7 +295,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -317,7 +314,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -333,7 +330,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { Describe("validateResourceQuota", func() { It("should validate storage quota successfully when within limits", func() { - err := webhook.validateResourceQuota(ctx, "test-namespace", corev1.ResourceStorage, resource.MustParse("1Gi")) + err := webhook.validateResourceQuota(ctx, testNamespace.Name, corev1.ResourceStorage, resource.MustParse("1Gi")) Expect(err).NotTo(HaveOccurred()) }) @@ -560,7 +557,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -570,10 +567,10 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { }, }, } - _, err := k8sClient.CoreV1().PersistentVolumeClaims("test-namespace").Create(ctx, pvc, metav1.CreateOptions{}) + _, err := k8sClient.CoreV1().PersistentVolumeClaims(testNamespace.Name).Create(ctx, pvc, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) - usage, err := webhook.calculateCurrentUsage(ctx, "test-namespace", usage.ResourceRequestsStorage) + usage, err := webhook.calculateCurrentUsage(ctx, testNamespace.Name, usage.ResourceRequestsStorage) Expect(err).NotTo(HaveOccurred()) Expect(usage.Value()).To(Equal(int64(10 * 1024 * 1024 * 1024))) // 10Gi in bytes }) @@ -584,7 +581,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pvc-premium", - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ StorageClassName: &storageClass, @@ -595,17 +592,17 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { }, }, } - _, err := k8sClient.CoreV1().PersistentVolumeClaims("test-namespace").Create(ctx, pvc, metav1.CreateOptions{}) + _, err := k8sClient.CoreV1().PersistentVolumeClaims(testNamespace.Name).Create(ctx, pvc, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) - usage, err := webhook.calculateCurrentUsage(ctx, "test-namespace", + usage, err := webhook.calculateCurrentUsage(ctx, testNamespace.Name, corev1.ResourceName("premium-ssd.storageclass.storage.k8s.io/requests.storage")) Expect(err).NotTo(HaveOccurred()) Expect(usage.Value()).To(Equal(int64(20 * 1024 * 1024 * 1024))) // 20Gi in bytes }) It("should return error for unsupported resource types", func() { - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", corev1.ResourceName("unsupported.resource")) + _, err := webhook.calculateCurrentUsage(ctx, testNamespace.Name, corev1.ResourceName("unsupported.resource")) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("unsupported resource type")) }) @@ -622,7 +619,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("count-pvc-%d", i), - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ @@ -632,14 +629,14 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { }, }, } - _, err := k8sClient.CoreV1().PersistentVolumeClaims("test-namespace").Create(ctx, pvc, metav1.CreateOptions{}) + _, err := k8sClient.CoreV1().PersistentVolumeClaims(testNamespace.Name).Create(ctx, pvc, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) } // Test PVC count - usage, err := webhook.calculateCurrentUsage(ctx, "test-namespace", corev1.ResourceName("persistentvolumeclaims")) + usage, err := webhook.calculateCurrentUsage(ctx, testNamespace.Name, corev1.ResourceName("persistentvolumeclaims")) Expect(err).NotTo(HaveOccurred()) - Expect(usage.Value()).To(BeNumerically(">", 0)) // Should count the PVCs + Expect(usage.Value()).To(BeNumerically("==", 3)) // Should count the PVCs }) It("should handle storage class specific PVC count", func() { @@ -649,7 +646,7 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("fast-pvc-%d", i), - Namespace: "test-namespace", + Namespace: testNamespace.Name, }, Spec: corev1.PersistentVolumeClaimSpec{ StorageClassName: &storageClass, @@ -660,386 +657,285 @@ var _ = Describe("PersistentVolumeClaimWebhook", func() { }, }, } - _, err := k8sClient.CoreV1().PersistentVolumeClaims("test-namespace").Create(ctx, pvc, metav1.CreateOptions{}) + _, err := k8sClient.CoreV1().PersistentVolumeClaims(testNamespace.Name).Create(ctx, pvc, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) } // Test storage class specific PVC count usage, err := webhook.calculateCurrentUsage( ctx, - "test-namespace", + testNamespace.Name, corev1.ResourceName(fastSSDStorageClassResourceName), ) Expect(err).NotTo(HaveOccurred()) - Expect(usage.Value()).To(BeNumerically(">", 0)) // Should count the PVCs with specific storage class + Expect(usage.Value()).To(BeNumerically("==", 2)) // Should count the PVCs with specific storage class }) - // Error handling tests using mocks - Context("Error handling", func() { - var mockCalculator *mocks.MockStorageResourceCalculatorInterface + Describe("validateStorageQuota edge cases", func() { + var namespace *corev1.Namespace + var crq *quotav1alpha1.ClusterResourceQuota + var ctx context.Context BeforeEach(func() { - mockCalculator = mocks.NewMockStorageResourceCalculatorInterface(GinkgoT()) - }) - - It("should handle CalculateUsage errors for storage requests", func() { - // Create webhook with mock calculator - webhook := &PersistentVolumeClaimWebhook{ - client: k8sClient, - storageCalculator: mockCalculator, - crqClient: crqClient, - log: zap.NewNop(), - } - - // Mock the CalculateUsage to return an error - mockCalculator.On("CalculateUsage", ctx, "test-namespace", usage.ResourceRequestsStorage). - Return(resource.Quantity{}, errors.New("failed to calculate storage usage")) - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", usage.ResourceRequestsStorage) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to calculate storage usage")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) - }) - - It("should handle CalculatePVCCount errors", func() { - // Create webhook with mock calculator - webhook := &PersistentVolumeClaimWebhook{ - client: k8sClient, - storageCalculator: mockCalculator, - crqClient: crqClient, - log: zap.NewNop(), + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "storage-test-ns", + Labels: map[string]string{ + "test-label": "storage-validation", + }, + }, } + Expect(fakeRuntimeClient.Create(ctx, namespace)).To(Succeed()) + // Also create in k8sClient for storage calculator + _, err := k8sClient.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) - // Mock the CalculatePVCCount to return an error - mockCalculator.On("CalculatePVCCount", ctx, "test-namespace"). - Return(int64(0), errors.New("failed to count PVCs")) - - // Call calculateCurrentUsage for PVCs and expect error - ctx := ctx - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", usage.ResourcePersistentVolumeClaims) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to count PVCs")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) - }) - - It("should handle CalculateStorageClassUsage errors", func() { - // Create webhook with mock calculator - webhook := &PersistentVolumeClaimWebhook{ - client: k8sClient, - storageCalculator: mockCalculator, - crqClient: crqClient, - log: zap.NewNop(), + crq = "av1alpha1.ClusterResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "storage-test-crq", + }, + Spec: quotav1alpha1.ClusterResourceQuotaSpec{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "test-label": "storage-validation", + }, + }, + Hard: quotav1alpha1.ResourceList{ + "requests.storage": resource.MustParse("100Gi"), + "premium-ssd.storageclass.storage.k8s.io/requests.storage": resource.MustParse("50Gi"), + }, + }, } - - // Mock the CalculateStorageClassUsage to return an error - mockCalculator.On("CalculateStorageClassUsage", ctx, "test-namespace", premiumSSDStorageClass). - Return(resource.Quantity{}, errors.New("storage class usage calculation failed")) - - // Call calculateCurrentUsage for storage class specific resource and expect error - ctx := ctx - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", corev1.ResourceName(premiumSSDStorageClassRequests)) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("storage class usage calculation failed")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) + Expect(fakeRuntimeClient.Create(ctx, crq)).To(Succeed()) }) - It("should handle CalculateStorageClassCount errors", func() { - // Create webhook with mock calculator - webhook := &PersistentVolumeClaimWebhook{ - client: k8sClient, - storageCalculator: mockCalculator, - crqClient: crqClient, - log: zap.NewNop(), + It("should validate storage class specific quotas", func() { + storageClass := premiumSSDStorageClass + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "premium-test-pvc", + Namespace: "storage-test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: &storageClass, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("60Gi"), // Exceeds storage class quota + }, + }, + }, } - // Mock the CalculateStorageClassCount to return an error - mockCalculator.On("CalculateStorageClassCount", ctx, "test-namespace", "fast-ssd"). - Return(int64(0), errors.New("storage class count failed")) - - // Call calculateCurrentUsage for storage class PVC count and expect error - _, err := webhook.calculateCurrentUsage( - ctx, - "test-namespace", - corev1.ResourceName(fastSSDStorageClassResourceName), - ) + err := webhook.validateStorageQuota(ctx, pvc) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("storage class count failed")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) + Expect(err.Error()).To(ContainSubstring("premium-ssd.storageclass.storage.k8s.io/requests.storage")) }) - }) - }) - - Describe("validateStorageQuota edge cases", func() { - var namespace *corev1.Namespace - var crq *quotav1alpha1.ClusterResourceQuota - var ctx context.Context - BeforeEach(func() { - namespace = &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "storage-test-ns", - Labels: map[string]string{ - "test-label": "storage-validation", + It("should validate general storage quota when storage class quota passes", func() { + storageClass := premiumSSDStorageClass + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "general-test-pvc", + Namespace: "storage-test-ns", }, - }, - } - Expect(fakeRuntimeClient.Create(ctx, namespace)).To(Succeed()) - // Also create in k8sClient for storage calculator - _, err := k8sClient.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) - Expect(err).NotTo(HaveOccurred()) - - crq = "av1alpha1.ClusterResourceQuota{ - ObjectMeta: metav1.ObjectMeta{ - Name: "storage-test-crq", - }, - Spec: quotav1alpha1.ClusterResourceQuotaSpec{ - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "test-label": "storage-validation", + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: &storageClass, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("30Gi"), // Within storage class quota but test general + }, }, }, - Hard: quotav1alpha1.ResourceList{ - "requests.storage": resource.MustParse("100Gi"), - "premium-ssd.storageclass.storage.k8s.io/requests.storage": resource.MustParse("50Gi"), - }, - }, - } - Expect(fakeRuntimeClient.Create(ctx, crq)).To(Succeed()) - }) + } - It("should validate storage class specific quotas", func() { - storageClass := premiumSSDStorageClass - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "premium-test-pvc", - Namespace: "storage-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - StorageClassName: &storageClass, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("60Gi"), // Exceeds storage class quota + // Add existing PVCs to push general storage over limit + for i := 0; i < 5; i++ { + existingPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("existing-pvc-%d", i), + Namespace: "storage-test-ns", }, - }, - }, - } - - err := webhook.validateStorageQuota(ctx, pvc) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("premium-ssd.storageclass.storage.k8s.io/requests.storage")) - }) - - It("should validate general storage quota when storage class quota passes", func() { - storageClass := premiumSSDStorageClass - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "general-test-pvc", - Namespace: "storage-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - StorageClassName: &storageClass, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("30Gi"), // Within storage class quota but test general + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("20Gi"), + }, + }, }, - }, - }, - } + } + _, err := k8sClient.CoreV1().PersistentVolumeClaims("storage-test-ns").Create( + ctx, existingPVC, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + } + + err := webhook.validateStorageQuota(ctx, pvc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("requests.storage")) + }) - // Add existing PVCs to push general storage over limit - for i := 0; i < 5; i++ { - existingPVC := &corev1.PersistentVolumeClaim{ + It("should handle PVC without storage class", func() { + pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("existing-pvc-%d", i), + Name: "no-storage-class-pvc", Namespace: "storage-test-ns", }, Spec: corev1.PersistentVolumeClaimSpec{ Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("20Gi"), + corev1.ResourceStorage: resource.MustParse("5Gi"), }, }, }, } - _, err := k8sClient.CoreV1().PersistentVolumeClaims("storage-test-ns").Create( - ctx, existingPVC, metav1.CreateOptions{}) - Expect(err).NotTo(HaveOccurred()) - } - err := webhook.validateStorageQuota(ctx, pvc) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("requests.storage")) - }) + err := webhook.validateStorageQuota(ctx, pvc) + Expect(err).NotTo(HaveOccurred()) // Should pass with general storage quota + }) - It("should handle PVC without storage class", func() { - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "no-storage-class-pvc", - Namespace: "storage-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("5Gi"), - }, + It("should handle missing storage requests", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-storage-requests-pvc", + Namespace: "storage-test-ns", }, - }, - } - - err := webhook.validateStorageQuota(ctx, pvc) - Expect(err).NotTo(HaveOccurred()) // Should pass with general storage quota - }) - - It("should handle missing storage requests", func() { - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "no-storage-requests-pvc", - Namespace: "storage-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - // No requests specified + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + // No requests specified + }, }, - }, - } + } - err := webhook.validateStorageQuota(ctx, pvc) - Expect(err).NotTo(HaveOccurred()) // Should pass when no storage requested + err := webhook.validateStorageQuota(ctx, pvc) + Expect(err).NotTo(HaveOccurred()) // Should pass when no storage requested + }) }) - }) - - Describe("validateUpdate edge cases", func() { - var namespace *corev1.Namespace - var crq *quotav1alpha1.ClusterResourceQuota - var ctx context.Context - BeforeEach(func() { - namespace = &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "update-test-ns", - Labels: map[string]string{ - "test-label": "update-validation", - }, - }, - } - Expect(fakeRuntimeClient.Create(ctx, namespace)).To(Succeed()) - // Also create in k8sClient for storage calculator - _, err := k8sClient.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) - Expect(err).NotTo(HaveOccurred()) + Describe("validateUpdate edge cases", func() { + var namespace *corev1.Namespace + var crq *quotav1alpha1.ClusterResourceQuota + var ctx context.Context - crq = "av1alpha1.ClusterResourceQuota{ - ObjectMeta: metav1.ObjectMeta{ - Name: "update-test-crq", - }, - Spec: quotav1alpha1.ClusterResourceQuotaSpec{ - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ + BeforeEach(func() { + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "update-test-ns", + Labels: map[string]string{ "test-label": "update-validation", }, }, - Hard: quotav1alpha1.ResourceList{ - "requests.storage": resource.MustParse("50Gi"), + } + Expect(fakeRuntimeClient.Create(ctx, namespace)).To(Succeed()) + // Also create in k8sClient for storage calculator + _, err := k8sClient.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + crq = "av1alpha1.ClusterResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "update-test-crq", + }, + Spec: quotav1alpha1.ClusterResourceQuotaSpec{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "test-label": "update-validation", + }, + }, + Hard: quotav1alpha1.ResourceList{ + "requests.storage": resource.MustParse("50Gi"), + }, }, - }, - } - Expect(fakeRuntimeClient.Create(ctx, crq)).To(Succeed()) - }) + } + Expect(fakeRuntimeClient.Create(ctx, crq)).To(Succeed()) + }) - It("should allow updates that don't increase storage", func() { - // Create original PVC - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "update-pvc", - Namespace: "update-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("10Gi"), + It("should allow updates that don't increase storage", func() { + // Create original PVC + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "update-pvc", + Namespace: "update-test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, }, }, - }, - } - Expect(fakeRuntimeClient.Create(ctx, pvc)).To(Succeed()) + } + Expect(fakeRuntimeClient.Create(ctx, pvc)).To(Succeed()) - // Simulate update with same storage - updatedPVC := pvc.DeepCopy() - // Just change labels, not storage - updatedPVC.Labels = map[string]string{"updated": "true"} + // Simulate update with same storage + updatedPVC := pvc.DeepCopy() + // Just change labels, not storage + updatedPVC.Labels = map[string]string{"updated": "true"} - err := webhook.validateUpdate(ctx, updatedPVC) - Expect(err).NotTo(HaveOccurred()) - }) + err := webhook.validateUpdate(ctx, updatedPVC) + Expect(err).NotTo(HaveOccurred()) + }) - It("should validate updates that increase storage", func() { - // Create original PVC - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "expand-pvc", - Namespace: "update-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("10Gi"), + It("should validate updates that increase storage", func() { + // Create original PVC + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "expand-pvc", + Namespace: "update-test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, }, }, - }, - } - _, err := k8sClient.CoreV1().PersistentVolumeClaims("update-test-ns").Create(ctx, pvc, metav1.CreateOptions{}) - Expect(err).NotTo(HaveOccurred()) + } + _, err := k8sClient.CoreV1().PersistentVolumeClaims("update-test-ns").Create(ctx, pvc, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) - // Fill up quota with another PVC - otherPVC := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "other-pvc", - Namespace: "update-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("35Gi"), // Total would be 45Gi + // Fill up quota with another PVC + otherPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-pvc", + Namespace: "update-test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("35Gi"), // Total would be 45Gi + }, }, }, - }, - } - _, err = k8sClient.CoreV1().PersistentVolumeClaims("update-test-ns").Create(ctx, otherPVC, metav1.CreateOptions{}) - Expect(err).NotTo(HaveOccurred()) + } + _, err = k8sClient.CoreV1().PersistentVolumeClaims("update-test-ns").Create(ctx, otherPVC, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) - // Try to expand beyond quota - updatedPVC := pvc.DeepCopy() - // Would make total 55Gi > 50Gi - updatedPVC.Spec.Resources.Requests[corev1.ResourceStorage] = resource.MustParse("20Gi") + // Try to expand beyond quota + updatedPVC := pvc.DeepCopy() + // Would make total 55Gi > 50Gi + updatedPVC.Spec.Resources.Requests[corev1.ResourceStorage] = resource.MustParse("20Gi") - err = webhook.validateUpdate(ctx, updatedPVC) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("requests.storage")) - }) + err = webhook.validateUpdate(ctx, updatedPVC) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("requests.storage")) + }) - It("should handle update with nil PVC", func() { - newPVC := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "nil-test-pvc", - Namespace: "update-test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("5Gi"), + It("should handle update with nil PVC", func() { + newPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nil-test-pvc", + Namespace: "update-test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, }, }, - }, - } + } - err := webhook.validateUpdate(ctx, newPVC) - Expect(err).NotTo(HaveOccurred()) // Should validate as normal + err := webhook.validateUpdate(ctx, newPVC) + Expect(err).NotTo(HaveOccurred()) // Should validate as normal + }) }) }) }) diff --git a/pkg/webhook/v1alpha1/pod_webhook.go b/pkg/webhook/v1alpha1/pod_webhook.go index a7083e1..261a298 100644 --- a/pkg/webhook/v1alpha1/pod_webhook.go +++ b/pkg/webhook/v1alpha1/pod_webhook.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( @@ -39,7 +23,7 @@ import ( // PodWebhook handles webhook requests for Pod resources type PodWebhook struct { client kubernetes.Interface - podCalculator pod.PodResourceCalculatorInterface + podCalculator pod.PodResourceCalculator crqClient *quota.CRQClient log *zap.Logger } @@ -48,7 +32,7 @@ type PodWebhook struct { func NewPodWebhook(k8sClient kubernetes.Interface, crqClient *quota.CRQClient, log *zap.Logger) *PodWebhook { return &PodWebhook{ client: k8sClient, - podCalculator: pod.NewPodResourceCalculator(k8sClient), + podCalculator: *pod.NewPodResourceCalculator(k8sClient), crqClient: crqClient, log: log, } @@ -130,27 +114,17 @@ func (h *PodWebhook) Handle(c *gin.Context) { var err error ctx := c.Request.Context() - switch admissionReview.Request.Operation { - case admissionv1.Create: - h.log.Info("Validating Pod on create", - zap.String("name", podObj.GetName()), - zap.String("namespace", podObj.GetNamespace())) - warnings, err = h.validateCreate(ctx, &podObj) - case admissionv1.Update: - h.log.Info("Validating Pod on update", - zap.String("name", podObj.GetName()), - zap.String("namespace", podObj.GetNamespace())) - warnings, err = h.validateUpdate(ctx, &podObj) - default: - h.log.Info("Unsupported operation", zap.String("operation", string(admissionReview.Request.Operation))) - admissionReview.Response.Allowed = false - admissionReview.Response.Result = &metav1.Status{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("Operation %s is not supported for Pod", admissionReview.Request.Operation), - } - c.JSON(http.StatusOK, admissionReview) - return - } + warnings, err = handleWebhookOperation( + h.log, + admissionReview.Request.Operation, + podObj.GetName(), + podObj.GetNamespace(), + func() ([]string, error) { return h.validateCreate(ctx, &podObj) }, + func() ([]string, error) { return h.validateUpdate(ctx, &podObj) }, + c, + &admissionReview, + "Pod", + ) if err != nil { h.log.Error("Validation failed", zap.Error(err)) @@ -178,7 +152,11 @@ func (h *PodWebhook) validateUpdate(ctx context.Context, podObj *corev1.Pod) ([] } // validatePodOperation is a shared function for both create and update validation -func (h *PodWebhook) validatePodOperation(ctx context.Context, podObj *corev1.Pod, operation operation) ([]string, error) { +func (h *PodWebhook) validatePodOperation( + ctx context.Context, + podObj *corev1.Pod, + operation operation, +) ([]string, error) { // Handle nil pod case if podObj == nil { h.log.Info("Skipping CRQ validation for nil pod on " + string(operation)) diff --git a/pkg/webhook/v1alpha1/pod_webhook_test.go b/pkg/webhook/v1alpha1/pod_webhook_test.go index 8ecc811..35eb5d4 100644 --- a/pkg/webhook/v1alpha1/pod_webhook_test.go +++ b/pkg/webhook/v1alpha1/pod_webhook_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "net/http" "net/http/httptest" @@ -25,7 +24,6 @@ import ( quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" - "github.com/powerhome/pac-quota-controller/pkg/mocks" ) var _ = Describe("PodWebhook", func() { @@ -59,41 +57,6 @@ var _ = Describe("PodWebhook", func() { ginEngine.POST("/webhook", webhook.Handle) }) - Describe("NewPodWebhook", func() { - It("should create a new pod webhook", func() { - Expect(webhook).NotTo(BeNil()) - Expect(webhook.client).To(Equal(fakeClient)) - Expect(webhook.log).To(Equal(logger)) - Expect(webhook.podCalculator).NotTo(BeNil()) - }) - - It("should create webhook with nil client", func() { - webhook := NewPodWebhook(nil, crqClient, logger) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.client).To(BeNil()) - }) - - It("should create webhook with nil logger", func() { - webhook := NewPodWebhook(fakeClient, crqClient, nil) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.log).To(BeNil()) - }) - - It("should create webhook with nil CRQ client", func() { - webhook := NewPodWebhook(fakeClient, nil, logger) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.crqClient).To(BeNil()) - }) - - It("should create webhook with all nil parameters", func() { - webhook := NewPodWebhook(nil, nil, nil) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.client).To(BeNil()) - Expect(webhook.crqClient).To(BeNil()) - Expect(webhook.log).To(BeNil()) - }) - }) - Describe("Handle", func() { It("should handle valid pod creation request", func() { pod := &corev1.Pod{ @@ -207,21 +170,6 @@ var _ = Describe("PodWebhook", func() { Expect(w.Code).To(Equal(http.StatusBadRequest)) }) - It("should reject unsupported operation", func() { - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-namespace", - }, - } - - admissionReview := createPodAdmissionReview(pod, admissionv1.Delete) - response := sendWebhookRequest(ginEngine, admissionReview) - - Expect(response.Response.Allowed).To(BeFalse()) - Expect(response.Response.Result.Message).To(ContainSubstring("Operation DELETE is not supported")) - }) - It("should handle pod with no containers", func() { pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -374,27 +322,6 @@ var _ = Describe("PodWebhook", func() { Expect(response.Response.Allowed).To(BeFalse()) Expect(response.Response.Result.Message).To(ContainSubstring("Expected Pod resource")) }) - - It("should handle failed pod decoding", func() { - // Skip this test for now since it's difficult to simulate the exact failure condition - // TODO: Implement proper error simulation when needed - Skip("Skipping test that requires specific error simulation") - }) - - It("should handle unsupported operations", func() { - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-namespace", - }, - } - - admissionReview := createPodAdmissionReview(pod, admissionv1.Delete) - response := sendWebhookRequest(ginEngine, admissionReview) - - Expect(response.Response.Allowed).To(BeFalse()) - Expect(response.Response.Result.Message).To(ContainSubstring("Operation DELETE is not supported")) - }) }) Describe("validateCreate", func() { @@ -1434,81 +1361,6 @@ var _ = Describe("PodWebhook", func() { Expect(err).NotTo(HaveOccurred()) Expect(cpuLimitsUsage.MilliValue()).To(Equal(int64(200))) // 200m }) - - // Error handling tests using mocks - Context("Error handling", func() { - var mockCalculator *mocks.MockPodResourceCalculatorInterface - - BeforeEach(func() { - mockCalculator = mocks.NewMockPodResourceCalculatorInterface(GinkgoT()) - }) - - It("should handle CalculateUsage errors for CPU requests", func() { - // Create webhook with mock calculator - webhook := &PodWebhook{ - client: fakeClient, - podCalculator: mockCalculator, - crqClient: crqClient, - log: logger, - } - - // Mock the CalculateUsage to return an error - mockCalculator.On("CalculateUsage", ctx, "test-namespace", corev1.ResourceRequestsCPU). - Return(resource.Quantity{}, errors.New("failed to calculate CPU usage")) - - // Call calculateCurrentUsage and expect error - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", "requests.cpu") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to calculate CPU usage")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) - }) - - It("should handle CalculateUsage errors for memory limits", func() { - // Create webhook with mock calculator - webhook := &PodWebhook{ - client: fakeClient, - podCalculator: mockCalculator, - crqClient: crqClient, - log: logger, - } - - // Mock the CalculateUsage to return an error - mockCalculator.On("CalculateUsage", ctx, "test-namespace", corev1.ResourceLimitsMemory). - Return(resource.Quantity{}, errors.New("memory calculation failed")) - - // Call calculateCurrentUsage and expect error - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", "limits.memory") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("memory calculation failed")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) - }) - - It("should handle CalculatePodCount errors", func() { - // Create webhook with mock calculator - webhook := &PodWebhook{ - client: fakeClient, - podCalculator: mockCalculator, - crqClient: crqClient, - log: logger, - } - - // Mock the CalculatePodCount to return an error - mockCalculator.On("CalculatePodCount", ctx, "test-namespace"). - Return(int64(0), errors.New("failed to count pods")) - - // Call calculateCurrentUsage for pods and expect error - _, err := webhook.calculateCurrentUsage(ctx, "test-namespace", "pods") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to count pods")) - - // Verify mock expectations - mockCalculator.AssertExpectations(GinkgoT()) - }) - }) }) }) }) diff --git a/pkg/webhook/v1alpha1/service_webhook.go b/pkg/webhook/v1alpha1/service_webhook.go index 9be3001..0a10573 100644 --- a/pkg/webhook/v1alpha1/service_webhook.go +++ b/pkg/webhook/v1alpha1/service_webhook.go @@ -8,7 +8,6 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" admissionv1 "k8s.io/api/admission/v1" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -17,6 +16,7 @@ import ( "github.com/powerhome/pac-quota-controller/pkg/kubernetes/quota" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/services" "github.com/powerhome/pac-quota-controller/pkg/kubernetes/usage" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" ) @@ -24,7 +24,7 @@ import ( // It enforces object count quotas for services and subtypes. type ServiceWebhook struct { client kubernetes.Interface - serviceCalculator services.ServiceResourceCalculatorInterface + serviceCalculator services.ServiceResourceCalculator crqClient *quota.CRQClient log *zap.Logger } @@ -33,7 +33,7 @@ type ServiceWebhook struct { func NewServiceWebhook(k8sClient kubernetes.Interface, crqClient *quota.CRQClient, log *zap.Logger) *ServiceWebhook { return &ServiceWebhook{ client: k8sClient, - serviceCalculator: services.NewServiceResourceCalculator(k8sClient), + serviceCalculator: *services.NewServiceResourceCalculator(k8sClient), crqClient: crqClient, log: log, } @@ -109,27 +109,17 @@ func (h *ServiceWebhook) Handle(c *gin.Context) { var warnings []string var err error ctx := c.Request.Context() - switch admissionReview.Request.Operation { - case admissionv1.Create: - h.log.Info("Validating Service on create", - zap.String("name", svc.GetName()), - zap.String("namespace", svc.GetNamespace())) - warnings, err = h.validateCreate(ctx, &svc) - case admissionv1.Update: - h.log.Info("Validating Service on update", - zap.String("name", svc.GetName()), - zap.String("namespace", svc.GetNamespace())) - warnings, err = h.validateUpdate(ctx, &svc) - default: - h.log.Info("Unsupported operation", zap.String("operation", string(admissionReview.Request.Operation))) - admissionReview.Response.Allowed = false - admissionReview.Response.Result = &metav1.Status{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("Operation %s is not supported for Service", admissionReview.Request.Operation), - } - c.JSON(http.StatusOK, admissionReview) - return - } + warnings, err = handleWebhookOperation( + h.log, + admissionReview.Request.Operation, + svc.GetName(), + svc.GetNamespace(), + func() ([]string, error) { return h.validateCreate(ctx, &svc) }, + func() ([]string, error) { return h.validateUpdate(ctx, &svc) }, + c, + &admissionReview, + "Service", + ) if err != nil { h.log.Error("Validation failed", zap.Error(err)) @@ -157,7 +147,11 @@ func (h *ServiceWebhook) validateUpdate(ctx context.Context, svc *corev1.Service } // validateServiceOperation is a shared function for both create and update validation -func (h *ServiceWebhook) validateServiceOperation(ctx context.Context, svc *corev1.Service, operation string) ([]string, error) { +func (h *ServiceWebhook) validateServiceOperation( + ctx context.Context, + svc *corev1.Service, + operation string, +) ([]string, error) { if svc == nil { h.log.Info("Skipping CRQ validation for nil service on " + operation) return nil, nil diff --git a/pkg/webhook/v1alpha1/service_webhook_test.go b/pkg/webhook/v1alpha1/service_webhook_test.go index cdc019e..1811e9f 100644 --- a/pkg/webhook/v1alpha1/service_webhook_test.go +++ b/pkg/webhook/v1alpha1/service_webhook_test.go @@ -54,7 +54,7 @@ var _ = Describe("ServiceWebhook", func() { logger = zap.NewNop() webhook = &ServiceWebhook{ client: fakeClient, - serviceCalculator: services.NewServiceResourceCalculator(fakeClient), + serviceCalculator: *services.NewServiceResourceCalculator(fakeClient), crqClient: crqClient, log: logger, } @@ -141,41 +141,6 @@ var _ = Describe("ServiceWebhook", func() { }) }) - Describe("NewServiceWebhook", func() { - It("should create a new service webhook", func() { - Expect(webhook).NotTo(BeNil()) - Expect(webhook.client).To(Equal(fakeClient)) - Expect(webhook.log).To(Equal(logger)) - Expect(webhook.serviceCalculator).NotTo(BeNil()) - }) - - It("should create webhook with nil client", func() { - webhook := NewServiceWebhook(nil, crqClient, logger) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.client).To(BeNil()) - }) - - It("should create webhook with nil logger", func() { - webhook := NewServiceWebhook(fakeClient, crqClient, nil) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.log).To(BeNil()) - }) - - It("should create webhook with nil CRQ client", func() { - webhook := NewServiceWebhook(fakeClient, nil, logger) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.crqClient).To(BeNil()) - }) - - It("should create webhook with all nil parameters", func() { - webhook := NewServiceWebhook(nil, nil, nil) - Expect(webhook).NotTo(BeNil()) - Expect(webhook.client).To(BeNil()) - Expect(webhook.crqClient).To(BeNil()) - Expect(webhook.log).To(BeNil()) - }) - }) - Describe("Handle", func() { It("should handle valid service creation request", func() { svc := &corev1.Service{ @@ -295,21 +260,6 @@ var _ = Describe("ServiceWebhook", func() { Expect(w.Code).To(Equal(http.StatusBadRequest)) }) - It("should reject unsupported operation", func() { - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-service", - Namespace: "test-namespace", - }, - } - - admissionReview := createServiceAdmissionReview(svc, admissionv1.Delete) - response := sendWebhookRequest(ginEngine, admissionReview) - - Expect(response.Response.Allowed).To(BeFalse()) - Expect(response.Response.Result.Message).To(ContainSubstring("Operation DELETE is not supported")) - }) - Describe("validateCreate", func() { It("should validate service creation", func() { svc := &corev1.Service{ @@ -404,7 +354,6 @@ var _ = Describe("ServiceWebhook", func() { Expect(response.Response.Allowed).To(BeTrue()) }) }) - Describe("Cross-Namespace Quota Validation", func() { var ( crq *quotav1alpha1.ClusterResourceQuota @@ -578,9 +527,12 @@ var _ = Describe("ServiceWebhook", func() { response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeFalse()) - Expect(response.Response.Result.Message).To(ContainSubstring("ClusterResourceQuota service count validation failed for")) - Expect(response.Response.Result.Message).To(ContainSubstring("test-crq")) - Expect(response.Response.Result.Message).To(ContainSubstring("limit exceeded")) + Expect(response.Response.Result.Message). + To(ContainSubstring("ClusterResourceQuota service count validation failed for")) + Expect(response.Response.Result.Message). + To(ContainSubstring("test-crq")) + Expect(response.Response.Result.Message). + To(ContainSubstring("limit exceeded")) }) It("should allow svc that fits within cross-namespace quota limits", func() { @@ -684,7 +636,7 @@ var _ = Describe("ServiceWebhook", func() { ginEngine = gin.New() webhook = &ServiceWebhook{ client: fakeClient, - serviceCalculator: services.NewServiceResourceCalculator(fakeClient), + serviceCalculator: *services.NewServiceResourceCalculator(fakeClient), crqClient: crqClient, log: logger, } @@ -711,7 +663,7 @@ var _ = Describe("ServiceWebhook", func() { Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, }, } - fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + _, err := fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-svc-1", Namespace: "test-ns-2", @@ -721,6 +673,7 @@ var _ = Describe("ServiceWebhook", func() { Ports: []corev1.ServicePort{{Name: "http", Port: 81, TargetPort: intstr.FromInt(8081)}}, }, }, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -728,7 +681,7 @@ var _ = Describe("ServiceWebhook", func() { It("should reject creation of a NodePort service if NodePort quota exceeded", func() { // Add two NodePort services to reach the quota (quota is 2) - fakeClient.CoreV1().Services("test-ns-1").Create(ctx, &corev1.Service{ + _, err := fakeClient.CoreV1().Services("test-ns-1").Create(ctx, &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-nodeport-1", Namespace: "test-ns-1", @@ -738,7 +691,8 @@ var _ = Describe("ServiceWebhook", func() { Ports: []corev1.ServicePort{{Name: "http", Port: 82, TargetPort: intstr.FromInt(8082)}}, }, }, metav1.CreateOptions{}) - fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + Expect(err).ToNot(HaveOccurred()) + _, err = fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-nodeport-2", Namespace: "test-ns-2", @@ -748,6 +702,7 @@ var _ = Describe("ServiceWebhook", func() { Ports: []corev1.ServicePort{{Name: "http", Port: 83, TargetPort: intstr.FromInt(8083)}}, }, }, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) newSvc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "new-nodeport", @@ -776,7 +731,7 @@ var _ = Describe("ServiceWebhook", func() { }, } // Add a ClusterIP service to test-ns-2 to ensure quota logic is exercised - fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + _, err := fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-svc-ext", Namespace: "test-ns-2", @@ -786,6 +741,7 @@ var _ = Describe("ServiceWebhook", func() { Ports: []corev1.ServicePort{{Name: "http", Port: 87, TargetPort: intstr.FromInt(8087)}}, }, }, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) admissionReview := createServiceAdmissionReview(newSvc, admissionv1.Create) response := sendWebhookRequest(ginEngine, admissionReview) Expect(response.Response.Allowed).To(BeTrue()) @@ -793,7 +749,7 @@ var _ = Describe("ServiceWebhook", func() { It("should reject creation of a LoadBalancer service if quota exceeded", func() { // Add a LoadBalancer service to reach the quota (quota is 1) - fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ + _, err := fakeClient.CoreV1().Services("test-ns-2").Create(ctx, &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-lb", Namespace: "test-ns-2", @@ -803,6 +759,7 @@ var _ = Describe("ServiceWebhook", func() { Ports: []corev1.ServicePort{{Name: "http", Port: 88, TargetPort: intstr.FromInt(8088)}}, }, }, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) newSvc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "new-loadbalancer", @@ -852,7 +809,7 @@ var _ = Describe("ServiceWebhook", func() { crqClient := quota.NewCRQClient(fakeRuntimeClient) webhook := &ServiceWebhook{ client: fakeClient, - serviceCalculator: services.NewServiceResourceCalculator(fakeClient), + serviceCalculator: *services.NewServiceResourceCalculator(fakeClient), crqClient: crqClient, log: logger, } @@ -879,7 +836,10 @@ var _ = Describe("ServiceWebhook", func() { }) // Helper functions for testing -func createServiceAdmissionReview(service *corev1.Service, operation admissionv1.Operation) *admissionv1.AdmissionReview { +func createServiceAdmissionReview( + service *corev1.Service, + operation admissionv1.Operation, +) *admissionv1.AdmissionReview { raw, _ := json.Marshal(service) return &admissionv1.AdmissionReview{ Request: &admissionv1.AdmissionRequest{ diff --git a/pkg/webhook/v1alpha1/v1alpha1_suite_test.go b/pkg/webhook/v1alpha1/v1alpha1_suite_test.go index 346b901..ccf5228 100644 --- a/pkg/webhook/v1alpha1/v1alpha1_suite_test.go +++ b/pkg/webhook/v1alpha1/v1alpha1_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package v1alpha1 import ( diff --git a/pkg/webhook/v1alpha1/webhook_utils.go b/pkg/webhook/v1alpha1/webhook_utils.go index 62b903c..b1e9755 100644 --- a/pkg/webhook/v1alpha1/webhook_utils.go +++ b/pkg/webhook/v1alpha1/webhook_utils.go @@ -205,3 +205,40 @@ func sendWebhookRequest(engine *gin.Engine, admissionReview *admissionv1.Admissi } return &response } + +// handleWebhookOperation is a shared helper for operation switch logic in pod/service webhooks +func handleWebhookOperation( + log *zap.Logger, + operation admissionv1.Operation, + name, ns string, + createFn func() ([]string, error), + updateFn func() ([]string, error), + c *gin.Context, + admissionReview *admissionv1.AdmissionReview, + resourceType string, +) ([]string, error) { + var warnings []string + var err error + switch operation { + case admissionv1.Create: + log.Info(fmt.Sprintf("Validating %s on create", resourceType), + zap.String("name", name), + zap.String("namespace", ns)) + warnings, err = createFn() + case admissionv1.Update: + log.Info(fmt.Sprintf("Validating %s on update", resourceType), + zap.String("name", name), + zap.String("namespace", ns)) + warnings, err = updateFn() + default: + log.Info("Unsupported operation", zap.String("operation", string(operation))) + admissionReview.Response.Allowed = false + admissionReview.Response.Result = &metav1.Status{ + Code: 400, + Message: fmt.Sprintf("Operation %s is not supported for %s", operation, resourceType), + } + c.JSON(200, admissionReview) + return nil, fmt.Errorf("unsupported operation") + } + return warnings, err +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index c11a447..3e15e16 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package webhook import ( diff --git a/pkg/webhook/webhook_suite_test.go b/pkg/webhook/webhook_suite_test.go index 42d2f77..db46b3b 100644 --- a/pkg/webhook/webhook_suite_test.go +++ b/pkg/webhook/webhook_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package webhook import ( diff --git a/test/e2e/clusterresourcequota_webhook_test.go b/test/e2e/clusterresourcequota_webhook_test.go index c6130c8..4cfe460 100644 --- a/test/e2e/clusterresourcequota_webhook_test.go +++ b/test/e2e/clusterresourcequota_webhook_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package e2e import ( diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index b55f0ae..4beb32f 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package e2e import ( diff --git a/test/e2e/objectcount_webhook_test.go b/test/e2e/objectcount_webhook_test.go index 2466fc0..948a395 100644 --- a/test/e2e/objectcount_webhook_test.go +++ b/test/e2e/objectcount_webhook_test.go @@ -26,11 +26,16 @@ var _ = Describe("ClusterResourceQuota Object Count Webhook E2E", func() { testSuffix = testutils.GenerateTestSuffix() testNamespace = testutils.GenerateResourceName("objectcount-ns-" + testSuffix) testCRQName = testutils.GenerateResourceName("objectcount-crq-" + testSuffix) - ns, _ = testutils.CreateNamespace(ctx, k8sClient, testNamespace, map[string]string{"objectcount-test": "test-label-" + testSuffix}) + ns, _ = testutils.CreateNamespace( + ctx, + k8sClient, + testNamespace, + map[string]string{"objectcount-test": "test-label-" + testSuffix}, + ) crq, _ = testutils.CreateClusterResourceQuota(ctx, k8sClient, testCRQName, &metav1.LabelSelector{ MatchLabels: map[string]string{"objectcount-test": "test-label-" + testSuffix}, }, quotav1alpha1.ResourceList{ - "configmaps": resource.MustParse("2"), + "configmaps": resource.MustParse("3"), // There is always a kube-root-ca.crt in the NS "secrets": resource.MustParse("1"), "replicationcontrollers": resource.MustParse("1"), "deployments.apps": resource.MustParse("1"), @@ -50,7 +55,7 @@ var _ = Describe("ClusterResourceQuota Object Count Webhook E2E", func() { Context("Object Count Quota", func() { It("should allow creation under quota for configmaps", func() { - cmName := testutils.GenerateResourceName("cm-under-quota-" + testSuffix) + cmName := testutils.GenerateResourceName("cm-at-quota-" + testSuffix) cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: cmName, @@ -274,15 +279,76 @@ var _ = Describe("ClusterResourceQuota Object Count Webhook E2E", func() { name string obj client.Object }{ - {"cm-mixed-under-", &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-mixed-under-" + testSuffix), Namespace: testNamespace}}}, - {"secret-mixed-under-", &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("secret-mixed-under-" + testSuffix), Namespace: testNamespace}}}, - {"rc-mixed-under-", testutils.NewReplicationController(testutils.GenerateResourceName("rc-mixed-under-"+testSuffix), testNamespace, 1)}, - {"dep-mixed-under-", testutils.NewDeployment(testutils.GenerateResourceName("dep-mixed-under-"+testSuffix), testNamespace, 1)}, - {"ss-mixed-under-", testutils.NewStatefulSet(testutils.GenerateResourceName("ss-mixed-under-"+testSuffix), testNamespace, 1)}, - {"ds-mixed-under-", testutils.NewDaemonSet(testutils.GenerateResourceName("ds-mixed-under-"+testSuffix), testNamespace)}, - {"cj-mixed-under-", testutils.NewCronJob(testutils.GenerateResourceName("cj-mixed-under-"+testSuffix), testNamespace)}, - {"hpa-mixed-under-", testutils.NewHPA(testutils.GenerateResourceName("hpa-mixed-under-"+testSuffix), testNamespace)}, - {"ing-mixed-under-", testutils.NewIngress(testutils.GenerateResourceName("ing-mixed-under-"+testSuffix), testNamespace)}, + { + "cm-mixed-under-", + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("cm-mixed-under-" + testSuffix), + Namespace: testNamespace, + }, + }, + }, + { + "secret-mixed-under-", + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("secret-mixed-under-" + testSuffix), + Namespace: testNamespace, + }, + }, + }, + { + "rc-mixed-under-", + testutils.NewReplicationController( + testutils.GenerateResourceName("rc-mixed-under-"+testSuffix), + testNamespace, + 1, + ), + }, + { + "dep-mixed-under-", + testutils.NewDeployment( + testutils.GenerateResourceName("dep-mixed-under-"+testSuffix), + testNamespace, + 1, + ), + }, + { + "ss-mixed-under-", + testutils.NewStatefulSet( + testutils.GenerateResourceName("ss-mixed-under-"+testSuffix), + testNamespace, + 1, + ), + }, + { + "ds-mixed-under-", + testutils.NewDaemonSet( + testutils.GenerateResourceName("ds-mixed-under-"+testSuffix), + testNamespace, + ), + }, + { + "cj-mixed-under-", + testutils.NewCronJob( + testutils.GenerateResourceName("cj-mixed-under-"+testSuffix), + testNamespace, + ), + }, + { + "hpa-mixed-under-", + testutils.NewHPA( + testutils.GenerateResourceName("hpa-mixed-under-"+testSuffix), + testNamespace, + ), + }, + { + "ing-mixed-under-", + testutils.NewIngress( + testutils.GenerateResourceName("ing-mixed-under-"+testSuffix), + testNamespace, + ), + }, } for _, r := range resources { err := k8sClient.Create(ctx, r.obj) @@ -299,22 +365,46 @@ var _ = Describe("ClusterResourceQuota Object Count Webhook E2E", func() { It("should deny mixed resources over quota", func() { // Fill up quota for configmaps and secrets, then try to create one more of each for i := range 2 { - cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-mixed-over-" + testSuffix + "-" + strconv.Itoa(i)), Namespace: testNamespace}} + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("cm-mixed-over-" + testSuffix + "-" + strconv.Itoa(i)), + Namespace: testNamespace, + }, + } err := k8sClient.Create(ctx, cm) Expect(err).ToNot(HaveOccurred(), "ConfigMap creation up to quota should be allowed") } - cmExtra := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-mixed-over-" + testSuffix + "-extra"), Namespace: testNamespace}} + cmExtra := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("cm-mixed-over-" + testSuffix + "-extra"), + Namespace: testNamespace, + }, + } err := k8sClient.Create(ctx, cmExtra) Expect(err).To(HaveOccurred(), "ConfigMap creation over quota should be denied") - secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("secret-mixed-over-" + testSuffix), Namespace: testNamespace}} + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("secret-mixed-over-" + testSuffix), + Namespace: testNamespace, + }, + } err = k8sClient.Create(ctx, secret) Expect(err).ToNot(HaveOccurred(), "Secret creation up to quota should be allowed") - secretExtra := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("secret-mixed-over-" + testSuffix + "-extra"), Namespace: testNamespace}} + secretExtra := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("secret-mixed-over-" + testSuffix + "-extra"), + Namespace: testNamespace, + }, + } err = k8sClient.Create(ctx, secretExtra) Expect(err).To(HaveOccurred(), "Secret creation over quota should be denied") }) It("should deny creation with missing namespace", func() { - cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: testutils.GenerateResourceName("cm-missing-ns-" + testSuffix)}} + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: testutils.GenerateResourceName("cm-missing-ns-" + testSuffix), + }, + } err := k8sClient.Create(ctx, cm) Expect(err).To(HaveOccurred(), "ConfigMap creation with missing namespace should be denied") }) diff --git a/test/e2e/pvc_webhook_test.go b/test/e2e/pvc_webhook_test.go index d91e94e..e985e8b 100644 --- a/test/e2e/pvc_webhook_test.go +++ b/test/e2e/pvc_webhook_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package e2e import ( diff --git a/test/e2e/service_webhook_test.go b/test/e2e/service_webhook_test.go index c668160..0af6c9e 100644 --- a/test/e2e/service_webhook_test.go +++ b/test/e2e/service_webhook_test.go @@ -1,6 +1,8 @@ package e2e import ( + "time" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" quotav1alpha1 "github.com/powerhome/pac-quota-controller/api/v1alpha1" @@ -47,8 +49,11 @@ var _ = Describe("Service Quota Webhook", func() { // Wait for CRQ status to include the test namespace before proceeding By("Waiting for CRQ status to include the test namespace") - err = testutils.WaitForCRQStatus(ctx, k8sClient, testCRQName, []string{testNamespace} /*timeout*/, 60*1e9 /*interval*/, 1*1e9) - Expect(err).NotTo(HaveOccurred(), "CRQ status did not include the test namespace in time; check CRQ selector and namespace labels") + err = testutils.WaitForCRQStatus(ctx, k8sClient, testCRQName, []string{testNamespace}, 10*time.Second, 1*time.Second) + Expect(err).NotTo( + HaveOccurred(), + "CRQ status did not include the test namespace in time; check CRQ selector and namespace labels", + ) }) AfterEach(func() { @@ -261,7 +266,16 @@ var _ = Describe("Service Quota Webhook", func() { Expect(k8sClient.Create(ctx, lbService)).To(Succeed()) // Try to update the ClusterIP service to LoadBalancer (should fail) var fetched corev1.Service - Expect(k8sClient.Get(ctx, ctrlclient.ObjectKey{Name: service.Name, Namespace: testNamespace}, &fetched)).To(Succeed()) + Expect( + k8sClient.Get( + ctx, + ctrlclient.ObjectKey{ + Name: service.Name, + Namespace: testNamespace, + }, + &fetched, + ), + ).To(Succeed()) fetched.Spec.Type = corev1.ServiceTypeLoadBalancer err := k8sClient.Update(ctx, &fetched) Expect(err).To(HaveOccurred()) diff --git a/test/e2e/storage_class_quota_test.go b/test/e2e/storage_class_quota_test.go index 4756425..b81556d 100644 --- a/test/e2e/storage_class_quota_test.go +++ b/test/e2e/storage_class_quota_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package e2e import ( diff --git a/test/e2e/storage_resources_test.go b/test/e2e/storage_resources_test.go index ac4204d..44e6b1f 100644 --- a/test/e2e/storage_resources_test.go +++ b/test/e2e/storage_resources_test.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package e2e import ( diff --git a/test/utils/helpers.go b/test/utils/helpers.go index 63f4026..92627cb 100644 --- a/test/utils/helpers.go +++ b/test/utils/helpers.go @@ -658,7 +658,13 @@ func NewCronJob(name, namespace string) *batchv1.CronJob { Spec: batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "container", Image: "busybox:latest", Command: []string{"echo", "hello"}}}, + Containers: []corev1.Container{ + { + Name: "container", + Image: "busybox:latest", + Command: []string{"echo", "hello"}, + }, + }, RestartPolicy: corev1.RestartPolicyNever, }, }, @@ -692,8 +698,7 @@ func NewHPA(name, namespace string) *autoscalingv2.HorizontalPodAutoscaler { // NewIngress returns an Ingress object for testing func NewIngress(name, namespace string) *networkingv1.Ingress { - var pathType networkingv1.PathType - pathType = "Exact" + pathType := networkingv1.PathType("Exact") return &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: name, From fb8100cd9899d35c6325da0fd7f72f0a168047b7 Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:31:05 -0300 Subject: [PATCH 6/7] chore: remove unused boilerplate --- hack/boilerplate.go.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 hack/boilerplate.go.txt diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt deleted file mode 100644 index e69de29..0000000 From c0a9afb3dd5dcad75ed892404cecdcb926370029 Mon Sep 17 00:00:00 2001 From: Felipe Peiter <11605227+fdpeiter@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:39:06 -0300 Subject: [PATCH 7/7] fix: remove unused code --- pkg/kubernetes/objectcount/objectcount.go | 14 - .../objectcount/objectcount_test.go | 32 +- pkg/kubernetes/pod/pod.go | 27 -- pkg/kubernetes/services/service.go | 23 -- pkg/kubernetes/services/service_test.go | 24 -- pkg/kubernetes/storage/storage.go | 23 -- pkg/kubernetes/storage/storage_test.go | 46 --- pkg/kubernetes/usage/usage.go | 3 - pkg/kubernetes/usage/usage_test.go | 13 - pkg/mocks/NamespaceSelector_mock.go | 176 -------- .../PodResourceCalculatorInterface_mock.go | 246 ----------- ...ServiceResourceCalculatorInterface_mock.go | 67 --- ...StorageResourceCalculatorInterface_mock.go | 390 ------------------ 13 files changed, 13 insertions(+), 1071 deletions(-) delete mode 100644 pkg/mocks/NamespaceSelector_mock.go delete mode 100644 pkg/mocks/PodResourceCalculatorInterface_mock.go delete mode 100644 pkg/mocks/ServiceResourceCalculatorInterface_mock.go delete mode 100644 pkg/mocks/StorageResourceCalculatorInterface_mock.go diff --git a/pkg/kubernetes/objectcount/objectcount.go b/pkg/kubernetes/objectcount/objectcount.go index ec76100..6b29be2 100644 --- a/pkg/kubernetes/objectcount/objectcount.go +++ b/pkg/kubernetes/objectcount/objectcount.go @@ -71,17 +71,3 @@ func (c *ObjectCountCalculator) CalculateUsage( } return *resource.NewQuantity(count, resource.DecimalSI), nil } - -// CalculateTotalUsage returns a map with the count for the configured resource in the namespace. -func (c *ObjectCountCalculator) CalculateTotalUsage( - ctx context.Context, - resourceName corev1.ResourceName, - namespace string) (map[corev1.ResourceName]resource.Quantity, error) { - usage := make(map[corev1.ResourceName]resource.Quantity) - q, err := c.CalculateUsage(ctx, namespace, resourceName) - if err != nil { - return usage, err - } - usage[resourceName] = q - return usage, nil -} diff --git a/pkg/kubernetes/objectcount/objectcount_test.go b/pkg/kubernetes/objectcount/objectcount_test.go index 611bb6d..7554c34 100644 --- a/pkg/kubernetes/objectcount/objectcount_test.go +++ b/pkg/kubernetes/objectcount/objectcount_test.go @@ -34,15 +34,14 @@ var _ = Describe("ObjectCountCalculator", func() { _ = networkingv1.AddToScheme(scheme) }) - DescribeTable("CalculateTotalUsage for all supported resources", + DescribeTable("CalculateUsage for all supported resources", func(resourceName string, object runtime.Object, expected int64) { rn := corev1.ResourceName(resourceName) client := fake.NewSimpleClientset(object) calc := NewObjectCountCalculator(client) - usage, err := calc.CalculateTotalUsage(ctx, rn, nsName) + usage, err := calc.CalculateUsage(ctx, nsName, rn) Expect(err).ToNot(HaveOccurred()) - q := usage[rn] - Expect(q.Value()).To(Equal(expected)) + Expect(usage.Value()).To(Equal(expected)) }, Entry( "Validate configmaps", @@ -112,10 +111,9 @@ var _ = Describe("ObjectCountCalculator", func() { cm2 := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: ns}} client := fake.NewSimpleClientset(cm1, cm2) calc := NewObjectCountCalculator(client) - usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + usage, err := calc.CalculateUsage(ctx, ns, rn) Expect(err).ToNot(HaveOccurred()) - q := usage[rn] - Expect(q.Value()).To(Equal(int64(2))) + Expect(usage.Value()).To(Equal(int64(2))) }) It("should count multiple resource types in the same namespace", func() { @@ -127,14 +125,12 @@ var _ = Describe("ObjectCountCalculator", func() { client := fake.NewSimpleClientset(cm, secret) calcCM := NewObjectCountCalculator(client) calcSecret := NewObjectCountCalculator(client) - usageCM, err := calcCM.CalculateTotalUsage(ctx, rnCM, ns) + usageCM, err := calcCM.CalculateUsage(ctx, ns, rnCM) Expect(err).ToNot(HaveOccurred()) - usageSecret, err := calcSecret.CalculateTotalUsage(ctx, rnSecret, ns) + usageSecret, err := calcSecret.CalculateUsage(ctx, ns, rnSecret) Expect(err).ToNot(HaveOccurred()) - qCM := usageCM[rnCM] - qSecret := usageSecret[rnSecret] - Expect((&qCM).Value()).To(Equal(int64(1))) - Expect((&qSecret).Value()).To(Equal(int64(1))) + Expect(usageCM.Value()).To(Equal(int64(1))) + Expect(usageSecret.Value()).To(Equal(int64(1))) }) It("should return zero for no resources present", func() { @@ -142,10 +138,9 @@ var _ = Describe("ObjectCountCalculator", func() { rn := corev1.ResourceName("pods") client := fake.NewSimpleClientset() calc := NewObjectCountCalculator(client) - usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + usage, err := calc.CalculateUsage(ctx, ns, rn) Expect(err).ToNot(HaveOccurred()) - q := usage[rn] - Expect(q.Value()).To(Equal(int64(0))) + Expect(usage.Value()).To(Equal(int64(0))) }) It("should return zero for inexistent resource type", func() { @@ -153,9 +148,8 @@ var _ = Describe("ObjectCountCalculator", func() { rn := corev1.ResourceName("nonexistent") client := fake.NewSimpleClientset() calc := NewObjectCountCalculator(client) - usage, err := calc.CalculateTotalUsage(ctx, rn, ns) + usage, err := calc.CalculateUsage(ctx, ns, rn) Expect(err).ToNot(HaveOccurred()) - q := usage[rn] - Expect(q.Value()).To(Equal(int64(0))) + Expect(usage.Value()).To(Equal(int64(0))) }) }) diff --git a/pkg/kubernetes/pod/pod.go b/pkg/kubernetes/pod/pod.go index e5665a7..8a74c4d 100644 --- a/pkg/kubernetes/pod/pod.go +++ b/pkg/kubernetes/pod/pod.go @@ -153,33 +153,6 @@ func (c *PodResourceCalculator) CalculateUsage( return *totalUsage, nil } -// CalculateTotalUsage calculates the total usage across all resources in a namespace -func (c *PodResourceCalculator) CalculateTotalUsage(ctx context.Context, namespace string) ( - map[corev1.ResourceName]resource.Quantity, error) { - result := make(map[corev1.ResourceName]resource.Quantity) - - // Calculate usage for common resources - resources := []corev1.ResourceName{ - usage.ResourceRequestsCPU, - usage.ResourceRequestsMemory, - usage.ResourceLimitsCPU, - usage.ResourceLimitsMemory, - usage.ResourceRequestsEphemeralStorage, - usage.ResourceLimitsEphemeralStorage, - usage.ResourcePods, // Add pod count - } - - for _, resourceName := range resources { - resourceUsage, err := c.CalculateUsage(ctx, namespace, resourceName) - if err != nil { - return nil, err - } - result[resourceName] = resourceUsage - } - - return result, nil -} - // CalculatePodCount calculates the number of non-terminal pods in a namespace func (c *PodResourceCalculator) CalculatePodCount(ctx context.Context, namespace string) (int64, error) { podList, err := c.Client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) diff --git a/pkg/kubernetes/services/service.go b/pkg/kubernetes/services/service.go index e1bbd32..cc38c10 100644 --- a/pkg/kubernetes/services/service.go +++ b/pkg/kubernetes/services/service.go @@ -67,29 +67,6 @@ func (c *ServiceResourceCalculator) CalculateUsage( } } -// CalculateTotalUsage calculates the total usage for all supported service count resources in a namespace. -func (c *ServiceResourceCalculator) CalculateTotalUsage( - ctx context.Context, - namespace string, -) ( - map[corev1.ResourceName]resource.Quantity, - error, -) { - total, byType, err := c.countServicesByType(ctx, namespace) - if err != nil { - return nil, err - } - result := map[corev1.ResourceName]resource.Quantity{ - usage.ResourceServices: *resource.NewQuantity(total, resource.DecimalSI), - usage.ResourceServicesLoadBalancers: *resource.NewQuantity( - byType[corev1.ServiceTypeLoadBalancer], - resource.DecimalSI, - ), - usage.ResourceServicesNodePorts: *resource.NewQuantity(byType[corev1.ServiceTypeNodePort], resource.DecimalSI), - } - return result, nil -} - // CountServices returns the total number of services and a breakdown by type in the namespace. func (c *ServiceResourceCalculator) countServicesByType( ctx context.Context, diff --git a/pkg/kubernetes/services/service_test.go b/pkg/kubernetes/services/service_test.go index 2152f7c..a261630 100644 --- a/pkg/kubernetes/services/service_test.go +++ b/pkg/kubernetes/services/service_test.go @@ -45,30 +45,6 @@ var _ = Describe("ServiceResourceCalculator", func() { calc = NewServiceResourceCalculator(client) }) - Describe("CalculateTotalUsage", func() { - It("returns correct map for all supported resources in ns1", func() { - m, err := calc.CalculateTotalUsage(ctx, "ns1") - Expect(err).ToNot(HaveOccurred()) - q := m[usage.ResourceServices] - Expect((&q).Value()).To(Equal(int64(4))) - q = m[usage.ResourceServicesLoadBalancers] - Expect((&q).Value()).To(Equal(int64(1))) - q = m[usage.ResourceServicesNodePorts] - Expect((&q).Value()).To(Equal(int64(1))) - }) - - It("returns correct map for all supported resources in ns2", func() { - m, err := calc.CalculateTotalUsage(ctx, "ns2") - Expect(err).ToNot(HaveOccurred()) - q := m[usage.ResourceServices] - Expect((&q).Value()).To(Equal(int64(1))) - q = m[usage.ResourceServicesLoadBalancers] - Expect((&q).Value()).To(Equal(int64(0))) - q = m[usage.ResourceServicesNodePorts] - Expect((&q).Value()).To(Equal(int64(0))) - }) - }) - Describe("CalculateUsage", func() { It("returns correct count for ResourceServices in ns1", func() { q, err := calc.CalculateUsage(ctx, "ns1", usage.ResourceServices) diff --git a/pkg/kubernetes/storage/storage.go b/pkg/kubernetes/storage/storage.go index be8112e..33ebe03 100644 --- a/pkg/kubernetes/storage/storage.go +++ b/pkg/kubernetes/storage/storage.go @@ -83,29 +83,6 @@ func (c *StorageResourceCalculator) CalculateUsage( } } -// CalculateTotalUsage calculates the total usage across all storage resources in a namespace -func (c *StorageResourceCalculator) CalculateTotalUsage(ctx context.Context, namespace string) ( - map[corev1.ResourceName]resource.Quantity, error) { - result := make(map[corev1.ResourceName]resource.Quantity) - - // Calculate usage for storage resources - resources := []corev1.ResourceName{ - usage.ResourceRequestsStorage, - usage.ResourceStorage, - usage.ResourcePersistentVolumeClaims, // Add PVC count - } - - for _, resourceName := range resources { - resourceUsage, err := c.CalculateUsage(ctx, namespace, resourceName) - if err != nil { - return nil, err - } - result[resourceName] = resourceUsage - } - - return result, nil -} - // CalculatePVCCount calculates the number of PersistentVolumeClaims in a namespace func (c *StorageResourceCalculator) CalculatePVCCount(ctx context.Context, namespace string) (int64, error) { log.Info("Calculating PVC count", zap.String("namespace", namespace)) diff --git a/pkg/kubernetes/storage/storage_test.go b/pkg/kubernetes/storage/storage_test.go index 789ccb3..e286b0e 100644 --- a/pkg/kubernetes/storage/storage_test.go +++ b/pkg/kubernetes/storage/storage_test.go @@ -472,52 +472,6 @@ var _ = Describe("StorageResourceCalculator", func() { }) }) - Describe("StorageResourceCalculator CalculateTotalUsage", func() { - var ( - calculator *StorageResourceCalculator - fakeClient *fake.Clientset - ) - - BeforeEach(func() { - fakeClient = fake.NewSimpleClientset() - calculator = NewStorageResourceCalculator(fakeClient) - }) - - It("should calculate total usage for all storage resources", func() { - pvc := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pvc", - Namespace: "test-ns", - }, - Spec: corev1.PersistentVolumeClaimSpec{ - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("10Gi"), - }, - }, - }, - } - - _, err := fakeClient.CoreV1().PersistentVolumeClaims("test-ns").Create( - ctx, pvc, metav1.CreateOptions{}) - Expect(err).NotTo(HaveOccurred()) - - totalUsage, err := calculator.CalculateTotalUsage(ctx, "test-ns") - - Expect(err).NotTo(HaveOccurred()) - Expect(totalUsage).NotTo(BeNil()) - Expect(totalUsage).To(HaveLen(3)) // ResourceRequestsStorage, ResourceStorage, and ResourcePersistentVolumeClaims - }) - - It("should return empty map for empty namespace", func() { - totalUsage, err := calculator.CalculateTotalUsage(ctx, "empty-ns") - - Expect(err).NotTo(HaveOccurred()) - Expect(totalUsage).NotTo(BeNil()) - Expect(totalUsage).To(HaveLen(3)) // ResourceRequestsStorage, ResourceStorage, and ResourcePersistentVolumeClaims - }) - }) - Describe("StorageResourceCalculator CalculateStorageClassUsage", func() { var ( calculator *StorageResourceCalculator diff --git a/pkg/kubernetes/usage/usage.go b/pkg/kubernetes/usage/usage.go index fd1fdd6..03ae3c0 100644 --- a/pkg/kubernetes/usage/usage.go +++ b/pkg/kubernetes/usage/usage.go @@ -12,9 +12,6 @@ import ( type ResourceCalculatorInterface interface { // CalculateUsage calculates the total usage for a specific resource in a namespace CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) - - // CalculateTotalUsage calculates the total usage across all resources in a namespace - CalculateTotalUsage(ctx context.Context, namespace string) (map[corev1.ResourceName]resource.Quantity, error) } // BaseResourceCalculator provides common functionality for resource calculators diff --git a/pkg/kubernetes/usage/usage_test.go b/pkg/kubernetes/usage/usage_test.go index dd90d05..806affb 100644 --- a/pkg/kubernetes/usage/usage_test.go +++ b/pkg/kubernetes/usage/usage_test.go @@ -288,19 +288,6 @@ var _ = Describe("Usage", func() { }) }) - Describe("ResourceCalculatorInterface", func() { - It("should define interface methods", func() { - // This test verifies that the interface is properly defined - var calculator ResourceCalculatorInterface - Expect(calculator).To(BeNil()) // Interface is nil by default - - // The interface should have these methods: - // - CalculateUsage(ctx context.Context, namespace string, - // resourceName corev1.ResourceName) (resource.Quantity, error) - // - CalculateTotalUsage(ctx context.Context, namespace string) (map[corev1.ResourceName]resource.Quantity, error) - }) - }) - Describe("Performance characteristics", func() { It("should handle large number of resources efficiently", func() { result := NewUsageResult("test-namespace") diff --git a/pkg/mocks/NamespaceSelector_mock.go b/pkg/mocks/NamespaceSelector_mock.go deleted file mode 100644 index 8309b9e..0000000 --- a/pkg/mocks/NamespaceSelector_mock.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package mocks - -import ( - "context" - - mock "github.com/stretchr/testify/mock" -) - -// NewMockNamespaceSelector creates a new instance of MockNamespaceSelector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockNamespaceSelector(t interface { - mock.TestingT - Cleanup(func()) -}) *MockNamespaceSelector { - mock := &MockNamespaceSelector{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockNamespaceSelector is an autogenerated mock type for the NamespaceSelector type -type MockNamespaceSelector struct { - mock.Mock -} - -type MockNamespaceSelector_Expecter struct { - mock *mock.Mock -} - -func (_m *MockNamespaceSelector) EXPECT() *MockNamespaceSelector_Expecter { - return &MockNamespaceSelector_Expecter{mock: &_m.Mock} -} - -// DetermineNamespaceChanges provides a mock function for the type MockNamespaceSelector -func (_mock *MockNamespaceSelector) DetermineNamespaceChanges(ctx context.Context, previousNamespaces []string) ([]string, []string, error) { - ret := _mock.Called(ctx, previousNamespaces) - - if len(ret) == 0 { - panic("no return value specified for DetermineNamespaceChanges") - } - - var r0 []string - var r1 []string - var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, []string) ([]string, []string, error)); ok { - return returnFunc(ctx, previousNamespaces) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, []string) []string); ok { - r0 = returnFunc(ctx, previousNamespaces) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]string) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, []string) []string); ok { - r1 = returnFunc(ctx, previousNamespaces) - } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).([]string) - } - } - if returnFunc, ok := ret.Get(2).(func(context.Context, []string) error); ok { - r2 = returnFunc(ctx, previousNamespaces) - } else { - r2 = ret.Error(2) - } - return r0, r1, r2 -} - -// MockNamespaceSelector_DetermineNamespaceChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetermineNamespaceChanges' -type MockNamespaceSelector_DetermineNamespaceChanges_Call struct { - *mock.Call -} - -// DetermineNamespaceChanges is a helper method to define mock.On call -// - ctx context.Context -// - previousNamespaces []string -func (_e *MockNamespaceSelector_Expecter) DetermineNamespaceChanges(ctx interface{}, previousNamespaces interface{}) *MockNamespaceSelector_DetermineNamespaceChanges_Call { - return &MockNamespaceSelector_DetermineNamespaceChanges_Call{Call: _e.mock.On("DetermineNamespaceChanges", ctx, previousNamespaces)} -} - -func (_c *MockNamespaceSelector_DetermineNamespaceChanges_Call) Run(run func(ctx context.Context, previousNamespaces []string)) *MockNamespaceSelector_DetermineNamespaceChanges_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 []string - if args[1] != nil { - arg1 = args[1].([]string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockNamespaceSelector_DetermineNamespaceChanges_Call) Return(added []string, removed []string, err error) *MockNamespaceSelector_DetermineNamespaceChanges_Call { - _c.Call.Return(added, removed, err) - return _c -} - -func (_c *MockNamespaceSelector_DetermineNamespaceChanges_Call) RunAndReturn(run func(ctx context.Context, previousNamespaces []string) ([]string, []string, error)) *MockNamespaceSelector_DetermineNamespaceChanges_Call { - _c.Call.Return(run) - return _c -} - -// GetSelectedNamespaces provides a mock function for the type MockNamespaceSelector -func (_mock *MockNamespaceSelector) GetSelectedNamespaces(ctx context.Context) ([]string, error) { - ret := _mock.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GetSelectedNamespaces") - } - - var r0 []string - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { - return returnFunc(ctx) - } - if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { - r0 = returnFunc(ctx) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]string) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = returnFunc(ctx) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockNamespaceSelector_GetSelectedNamespaces_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSelectedNamespaces' -type MockNamespaceSelector_GetSelectedNamespaces_Call struct { - *mock.Call -} - -// GetSelectedNamespaces is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockNamespaceSelector_Expecter) GetSelectedNamespaces(ctx interface{}) *MockNamespaceSelector_GetSelectedNamespaces_Call { - return &MockNamespaceSelector_GetSelectedNamespaces_Call{Call: _e.mock.On("GetSelectedNamespaces", ctx)} -} - -func (_c *MockNamespaceSelector_GetSelectedNamespaces_Call) Run(run func(ctx context.Context)) *MockNamespaceSelector_GetSelectedNamespaces_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - run( - arg0, - ) - }) - return _c -} - -func (_c *MockNamespaceSelector_GetSelectedNamespaces_Call) Return(strings []string, err error) *MockNamespaceSelector_GetSelectedNamespaces_Call { - _c.Call.Return(strings, err) - return _c -} - -func (_c *MockNamespaceSelector_GetSelectedNamespaces_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockNamespaceSelector_GetSelectedNamespaces_Call { - _c.Call.Return(run) - return _c -} diff --git a/pkg/mocks/PodResourceCalculatorInterface_mock.go b/pkg/mocks/PodResourceCalculatorInterface_mock.go deleted file mode 100644 index 0ca8571..0000000 --- a/pkg/mocks/PodResourceCalculatorInterface_mock.go +++ /dev/null @@ -1,246 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package mocks - -import ( - "context" - - mock "github.com/stretchr/testify/mock" - "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" -) - -// NewMockPodResourceCalculatorInterface creates a new instance of MockPodResourceCalculatorInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockPodResourceCalculatorInterface(t interface { - mock.TestingT - Cleanup(func()) -}) *MockPodResourceCalculatorInterface { - mock := &MockPodResourceCalculatorInterface{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockPodResourceCalculatorInterface is an autogenerated mock type for the PodResourceCalculatorInterface type -type MockPodResourceCalculatorInterface struct { - mock.Mock -} - -type MockPodResourceCalculatorInterface_Expecter struct { - mock *mock.Mock -} - -func (_m *MockPodResourceCalculatorInterface) EXPECT() *MockPodResourceCalculatorInterface_Expecter { - return &MockPodResourceCalculatorInterface_Expecter{mock: &_m.Mock} -} - -// CalculatePodCount provides a mock function for the type MockPodResourceCalculatorInterface -func (_mock *MockPodResourceCalculatorInterface) CalculatePodCount(ctx context.Context, namespace string) (int64, error) { - ret := _mock.Called(ctx, namespace) - - if len(ret) == 0 { - panic("no return value specified for CalculatePodCount") - } - - var r0 int64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int64, error)); ok { - return returnFunc(ctx, namespace) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) int64); ok { - r0 = returnFunc(ctx, namespace) - } else { - r0 = ret.Get(0).(int64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, namespace) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockPodResourceCalculatorInterface_CalculatePodCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculatePodCount' -type MockPodResourceCalculatorInterface_CalculatePodCount_Call struct { - *mock.Call -} - -// CalculatePodCount is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -func (_e *MockPodResourceCalculatorInterface_Expecter) CalculatePodCount(ctx interface{}, namespace interface{}) *MockPodResourceCalculatorInterface_CalculatePodCount_Call { - return &MockPodResourceCalculatorInterface_CalculatePodCount_Call{Call: _e.mock.On("CalculatePodCount", ctx, namespace)} -} - -func (_c *MockPodResourceCalculatorInterface_CalculatePodCount_Call) Run(run func(ctx context.Context, namespace string)) *MockPodResourceCalculatorInterface_CalculatePodCount_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockPodResourceCalculatorInterface_CalculatePodCount_Call) Return(n int64, err error) *MockPodResourceCalculatorInterface_CalculatePodCount_Call { - _c.Call.Return(n, err) - return _c -} - -func (_c *MockPodResourceCalculatorInterface_CalculatePodCount_Call) RunAndReturn(run func(ctx context.Context, namespace string) (int64, error)) *MockPodResourceCalculatorInterface_CalculatePodCount_Call { - _c.Call.Return(run) - return _c -} - -// CalculateTotalUsage provides a mock function for the type MockPodResourceCalculatorInterface -func (_mock *MockPodResourceCalculatorInterface) CalculateTotalUsage(ctx context.Context, namespace string) (map[v1.ResourceName]resource.Quantity, error) { - ret := _mock.Called(ctx, namespace) - - if len(ret) == 0 { - panic("no return value specified for CalculateTotalUsage") - } - - var r0 map[v1.ResourceName]resource.Quantity - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[v1.ResourceName]resource.Quantity, error)); ok { - return returnFunc(ctx, namespace) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[v1.ResourceName]resource.Quantity); ok { - r0 = returnFunc(ctx, namespace) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(map[v1.ResourceName]resource.Quantity) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, namespace) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockPodResourceCalculatorInterface_CalculateTotalUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculateTotalUsage' -type MockPodResourceCalculatorInterface_CalculateTotalUsage_Call struct { - *mock.Call -} - -// CalculateTotalUsage is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -func (_e *MockPodResourceCalculatorInterface_Expecter) CalculateTotalUsage(ctx interface{}, namespace interface{}) *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call { - return &MockPodResourceCalculatorInterface_CalculateTotalUsage_Call{Call: _e.mock.On("CalculateTotalUsage", ctx, namespace)} -} - -func (_c *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call) Run(run func(ctx context.Context, namespace string)) *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call) Return(resourceNameToQuantity map[v1.ResourceName]resource.Quantity, err error) *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call { - _c.Call.Return(resourceNameToQuantity, err) - return _c -} - -func (_c *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call) RunAndReturn(run func(ctx context.Context, namespace string) (map[v1.ResourceName]resource.Quantity, error)) *MockPodResourceCalculatorInterface_CalculateTotalUsage_Call { - _c.Call.Return(run) - return _c -} - -// CalculateUsage provides a mock function for the type MockPodResourceCalculatorInterface -func (_mock *MockPodResourceCalculatorInterface) CalculateUsage(ctx context.Context, namespace string, resourceName v1.ResourceName) (resource.Quantity, error) { - ret := _mock.Called(ctx, namespace, resourceName) - - if len(ret) == 0 { - panic("no return value specified for CalculateUsage") - } - - var r0 resource.Quantity - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, v1.ResourceName) (resource.Quantity, error)); ok { - return returnFunc(ctx, namespace, resourceName) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, v1.ResourceName) resource.Quantity); ok { - r0 = returnFunc(ctx, namespace, resourceName) - } else { - r0 = ret.Get(0).(resource.Quantity) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, v1.ResourceName) error); ok { - r1 = returnFunc(ctx, namespace, resourceName) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockPodResourceCalculatorInterface_CalculateUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculateUsage' -type MockPodResourceCalculatorInterface_CalculateUsage_Call struct { - *mock.Call -} - -// CalculateUsage is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - resourceName v1.ResourceName -func (_e *MockPodResourceCalculatorInterface_Expecter) CalculateUsage(ctx interface{}, namespace interface{}, resourceName interface{}) *MockPodResourceCalculatorInterface_CalculateUsage_Call { - return &MockPodResourceCalculatorInterface_CalculateUsage_Call{Call: _e.mock.On("CalculateUsage", ctx, namespace, resourceName)} -} - -func (_c *MockPodResourceCalculatorInterface_CalculateUsage_Call) Run(run func(ctx context.Context, namespace string, resourceName v1.ResourceName)) *MockPodResourceCalculatorInterface_CalculateUsage_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 v1.ResourceName - if args[2] != nil { - arg2 = args[2].(v1.ResourceName) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockPodResourceCalculatorInterface_CalculateUsage_Call) Return(quantity resource.Quantity, err error) *MockPodResourceCalculatorInterface_CalculateUsage_Call { - _c.Call.Return(quantity, err) - return _c -} - -func (_c *MockPodResourceCalculatorInterface_CalculateUsage_Call) RunAndReturn(run func(ctx context.Context, namespace string, resourceName v1.ResourceName) (resource.Quantity, error)) *MockPodResourceCalculatorInterface_CalculateUsage_Call { - _c.Call.Return(run) - return _c -} diff --git a/pkg/mocks/ServiceResourceCalculatorInterface_mock.go b/pkg/mocks/ServiceResourceCalculatorInterface_mock.go deleted file mode 100644 index 951996a..0000000 --- a/pkg/mocks/ServiceResourceCalculatorInterface_mock.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by mockery. DO NOT EDIT. -package mocks - -import ( - context "context" - - testify_mock "github.com/stretchr/testify/mock" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" -) - -// ServiceResourceCalculatorInterface is an autogenerated mock type for the ServiceResourceCalculatorInterface type -// -//go:generate mockery --name=ServiceResourceCalculatorInterface -type ServiceResourceCalculatorInterface struct { - testify_mock.Mock -} - -// CalculateUsage provides a mock function with given fields: ctx, namespace, resourceName -func (_m *ServiceResourceCalculatorInterface) CalculateUsage(ctx context.Context, namespace string, resourceName corev1.ResourceName) (resource.Quantity, error) { - ret := _m.Called(ctx, namespace, resourceName) - - var r0 resource.Quantity - if rf, ok := ret.Get(0).(func(context.Context, string, corev1.ResourceName) resource.Quantity); ok { - r0 = rf(ctx, namespace, resourceName) - } else { - r0 = ret.Get(0).(resource.Quantity) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string, corev1.ResourceName) error); ok { - r1 = rf(ctx, namespace, resourceName) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// CountServices provides a mock function with given fields: ctx, namespace -func (_m *ServiceResourceCalculatorInterface) CountServices(ctx context.Context, namespace string) (int64, map[corev1.ServiceType]int64, error) { - ret := _m.Called(ctx, namespace) - - var r0 int64 - if rf, ok := ret.Get(0).(func(context.Context, string) int64); ok { - r0 = rf(ctx, namespace) - } else { - r0 = ret.Get(0).(int64) - } - - var r1 map[corev1.ServiceType]int64 - if rf, ok := ret.Get(1).(func(context.Context, string) map[corev1.ServiceType]int64); ok { - r1 = rf(ctx, namespace) - } else { - r1 = ret.Get(1).(map[corev1.ServiceType]int64) - } - - var r2 error - if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { - r2 = rf(ctx, namespace) - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 -} diff --git a/pkg/mocks/StorageResourceCalculatorInterface_mock.go b/pkg/mocks/StorageResourceCalculatorInterface_mock.go deleted file mode 100644 index 439a131..0000000 --- a/pkg/mocks/StorageResourceCalculatorInterface_mock.go +++ /dev/null @@ -1,390 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package mocks - -import ( - "context" - - mock "github.com/stretchr/testify/mock" - "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" -) - -// NewMockStorageResourceCalculatorInterface creates a new instance of MockStorageResourceCalculatorInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockStorageResourceCalculatorInterface(t interface { - mock.TestingT - Cleanup(func()) -}) *MockStorageResourceCalculatorInterface { - mock := &MockStorageResourceCalculatorInterface{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockStorageResourceCalculatorInterface is an autogenerated mock type for the StorageResourceCalculatorInterface type -type MockStorageResourceCalculatorInterface struct { - mock.Mock -} - -type MockStorageResourceCalculatorInterface_Expecter struct { - mock *mock.Mock -} - -func (_m *MockStorageResourceCalculatorInterface) EXPECT() *MockStorageResourceCalculatorInterface_Expecter { - return &MockStorageResourceCalculatorInterface_Expecter{mock: &_m.Mock} -} - -// CalculatePVCCount provides a mock function for the type MockStorageResourceCalculatorInterface -func (_mock *MockStorageResourceCalculatorInterface) CalculatePVCCount(ctx context.Context, namespace string) (int64, error) { - ret := _mock.Called(ctx, namespace) - - if len(ret) == 0 { - panic("no return value specified for CalculatePVCCount") - } - - var r0 int64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int64, error)); ok { - return returnFunc(ctx, namespace) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) int64); ok { - r0 = returnFunc(ctx, namespace) - } else { - r0 = ret.Get(0).(int64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, namespace) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockStorageResourceCalculatorInterface_CalculatePVCCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculatePVCCount' -type MockStorageResourceCalculatorInterface_CalculatePVCCount_Call struct { - *mock.Call -} - -// CalculatePVCCount is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -func (_e *MockStorageResourceCalculatorInterface_Expecter) CalculatePVCCount(ctx interface{}, namespace interface{}) *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call { - return &MockStorageResourceCalculatorInterface_CalculatePVCCount_Call{Call: _e.mock.On("CalculatePVCCount", ctx, namespace)} -} - -func (_c *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call) Run(run func(ctx context.Context, namespace string)) *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call) Return(n int64, err error) *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call { - _c.Call.Return(n, err) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call) RunAndReturn(run func(ctx context.Context, namespace string) (int64, error)) *MockStorageResourceCalculatorInterface_CalculatePVCCount_Call { - _c.Call.Return(run) - return _c -} - -// CalculateStorageClassCount provides a mock function for the type MockStorageResourceCalculatorInterface -func (_mock *MockStorageResourceCalculatorInterface) CalculateStorageClassCount(ctx context.Context, namespace string, storageClass string) (int64, error) { - ret := _mock.Called(ctx, namespace, storageClass) - - if len(ret) == 0 { - panic("no return value specified for CalculateStorageClassCount") - } - - var r0 int64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (int64, error)); ok { - return returnFunc(ctx, namespace, storageClass) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) int64); ok { - r0 = returnFunc(ctx, namespace, storageClass) - } else { - r0 = ret.Get(0).(int64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { - r1 = returnFunc(ctx, namespace, storageClass) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculateStorageClassCount' -type MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call struct { - *mock.Call -} - -// CalculateStorageClassCount is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - storageClass string -func (_e *MockStorageResourceCalculatorInterface_Expecter) CalculateStorageClassCount(ctx interface{}, namespace interface{}, storageClass interface{}) *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call { - return &MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call{Call: _e.mock.On("CalculateStorageClassCount", ctx, namespace, storageClass)} -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call) Run(run func(ctx context.Context, namespace string, storageClass string)) *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call) Return(n int64, err error) *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call { - _c.Call.Return(n, err) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call) RunAndReturn(run func(ctx context.Context, namespace string, storageClass string) (int64, error)) *MockStorageResourceCalculatorInterface_CalculateStorageClassCount_Call { - _c.Call.Return(run) - return _c -} - -// CalculateStorageClassUsage provides a mock function for the type MockStorageResourceCalculatorInterface -func (_mock *MockStorageResourceCalculatorInterface) CalculateStorageClassUsage(ctx context.Context, namespace string, storageClass string) (resource.Quantity, error) { - ret := _mock.Called(ctx, namespace, storageClass) - - if len(ret) == 0 { - panic("no return value specified for CalculateStorageClassUsage") - } - - var r0 resource.Quantity - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (resource.Quantity, error)); ok { - return returnFunc(ctx, namespace, storageClass) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) resource.Quantity); ok { - r0 = returnFunc(ctx, namespace, storageClass) - } else { - r0 = ret.Get(0).(resource.Quantity) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { - r1 = returnFunc(ctx, namespace, storageClass) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculateStorageClassUsage' -type MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call struct { - *mock.Call -} - -// CalculateStorageClassUsage is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - storageClass string -func (_e *MockStorageResourceCalculatorInterface_Expecter) CalculateStorageClassUsage(ctx interface{}, namespace interface{}, storageClass interface{}) *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call { - return &MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call{Call: _e.mock.On("CalculateStorageClassUsage", ctx, namespace, storageClass)} -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call) Run(run func(ctx context.Context, namespace string, storageClass string)) *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call) Return(quantity resource.Quantity, err error) *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call { - _c.Call.Return(quantity, err) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call) RunAndReturn(run func(ctx context.Context, namespace string, storageClass string) (resource.Quantity, error)) *MockStorageResourceCalculatorInterface_CalculateStorageClassUsage_Call { - _c.Call.Return(run) - return _c -} - -// CalculateTotalUsage provides a mock function for the type MockStorageResourceCalculatorInterface -func (_mock *MockStorageResourceCalculatorInterface) CalculateTotalUsage(ctx context.Context, namespace string) (map[v1.ResourceName]resource.Quantity, error) { - ret := _mock.Called(ctx, namespace) - - if len(ret) == 0 { - panic("no return value specified for CalculateTotalUsage") - } - - var r0 map[v1.ResourceName]resource.Quantity - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[v1.ResourceName]resource.Quantity, error)); ok { - return returnFunc(ctx, namespace) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[v1.ResourceName]resource.Quantity); ok { - r0 = returnFunc(ctx, namespace) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(map[v1.ResourceName]resource.Quantity) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, namespace) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculateTotalUsage' -type MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call struct { - *mock.Call -} - -// CalculateTotalUsage is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -func (_e *MockStorageResourceCalculatorInterface_Expecter) CalculateTotalUsage(ctx interface{}, namespace interface{}) *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call { - return &MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call{Call: _e.mock.On("CalculateTotalUsage", ctx, namespace)} -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call) Run(run func(ctx context.Context, namespace string)) *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call) Return(resourceNameToQuantity map[v1.ResourceName]resource.Quantity, err error) *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call { - _c.Call.Return(resourceNameToQuantity, err) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call) RunAndReturn(run func(ctx context.Context, namespace string) (map[v1.ResourceName]resource.Quantity, error)) *MockStorageResourceCalculatorInterface_CalculateTotalUsage_Call { - _c.Call.Return(run) - return _c -} - -// CalculateUsage provides a mock function for the type MockStorageResourceCalculatorInterface -func (_mock *MockStorageResourceCalculatorInterface) CalculateUsage(ctx context.Context, namespace string, resourceName v1.ResourceName) (resource.Quantity, error) { - ret := _mock.Called(ctx, namespace, resourceName) - - if len(ret) == 0 { - panic("no return value specified for CalculateUsage") - } - - var r0 resource.Quantity - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, v1.ResourceName) (resource.Quantity, error)); ok { - return returnFunc(ctx, namespace, resourceName) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, v1.ResourceName) resource.Quantity); ok { - r0 = returnFunc(ctx, namespace, resourceName) - } else { - r0 = ret.Get(0).(resource.Quantity) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, v1.ResourceName) error); ok { - r1 = returnFunc(ctx, namespace, resourceName) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockStorageResourceCalculatorInterface_CalculateUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CalculateUsage' -type MockStorageResourceCalculatorInterface_CalculateUsage_Call struct { - *mock.Call -} - -// CalculateUsage is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - resourceName v1.ResourceName -func (_e *MockStorageResourceCalculatorInterface_Expecter) CalculateUsage(ctx interface{}, namespace interface{}, resourceName interface{}) *MockStorageResourceCalculatorInterface_CalculateUsage_Call { - return &MockStorageResourceCalculatorInterface_CalculateUsage_Call{Call: _e.mock.On("CalculateUsage", ctx, namespace, resourceName)} -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateUsage_Call) Run(run func(ctx context.Context, namespace string, resourceName v1.ResourceName)) *MockStorageResourceCalculatorInterface_CalculateUsage_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 v1.ResourceName - if args[2] != nil { - arg2 = args[2].(v1.ResourceName) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateUsage_Call) Return(quantity resource.Quantity, err error) *MockStorageResourceCalculatorInterface_CalculateUsage_Call { - _c.Call.Return(quantity, err) - return _c -} - -func (_c *MockStorageResourceCalculatorInterface_CalculateUsage_Call) RunAndReturn(run func(ctx context.Context, namespace string, resourceName v1.ResourceName) (resource.Quantity, error)) *MockStorageResourceCalculatorInterface_CalculateUsage_Call { - _c.Call.Return(run) - return _c -}