diff --git a/examples/resource_quota_compare.py b/examples/resource_quota_compare.py new file mode 100644 index 0000000000..0dc002edad --- /dev/null +++ b/examples/resource_quota_compare.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python +# Copyright 2024 The Kubernetes Authors. +# +# 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. + +""" +Example: Semantic ResourceQuota comparison using the Python client. + +This example mirrors a kubebuilder operator pattern in Go where two +ResourceQuota objects are fetched from etcd and compared using +``equality.Semantic.DeepEqual`` / ``resource.Quantity.Cmp()``: + + https://github.com/kubernetes/apimachinery/blob/master/pkg/api/equality/semantic.go + https://github.com/kubernetes/apimachinery/blob/master/pkg/api/resource/quantity.go + +The Python implementation delegates all unit normalisation to +``kubernetes.utils.quantity.parse_quantity`` so that semantically equal +quantities expressed in different units (e.g. ``"1Gi"`` vs +``"1073741824"``, or ``"1000m"`` vs ``"1"``) compare correctly without +any manual conversion. + +Prerequisites: + - A running Kubernetes cluster accessible via kubeconfig or in-cluster + service account. + - At least one ResourceQuota in each namespace referenced below. + - The ``kubernetes`` Python package installed. + +Usage:: + + python examples/resource_quota_compare.py +""" + +from kubernetes import client, config +from kubernetes.utils.resource_quota import ( + ResourceChangeKind, + compare_resource_quotas, + get_resource_list_diff, + resource_quotas_equal, +) + + +# --------------------------------------------------------------------------- +# Configuration — adjust to match your cluster setup +# --------------------------------------------------------------------------- + +BASELINE_QUOTA_NAME = "baseline-quota" +BASELINE_NAMESPACE = "default" + +TARGET_QUOTA_NAME = "team-quota" +TARGET_NAMESPACE = "team-ns" + + +# --------------------------------------------------------------------------- +# Helper printers +# --------------------------------------------------------------------------- + +_KIND_SYMBOLS = { + ResourceChangeKind.ADDED: "➕", + ResourceChangeKind.REMOVED: "➖", + ResourceChangeKind.MODIFIED: "✏️ ", + ResourceChangeKind.UNCHANGED: "✅", +} + + +def print_section(title: str) -> None: + print("\n" + "=" * 60) + print(title) + print("=" * 60) + + +def print_diffs(diffs) -> None: + if not diffs: + print(" (no differences)") + return + for diff in diffs: + symbol = _KIND_SYMBOLS.get(diff.kind, "?") + print(f" {symbol} {diff}") + + +# --------------------------------------------------------------------------- +# Demo 1 — Compare two quotas fetched from the cluster +# --------------------------------------------------------------------------- + +def demo_cluster_comparison(api_client: client.ApiClient) -> None: + """Fetch two ResourceQuotas from the cluster and print their differences.""" + print_section("Demo 1 — Cluster ResourceQuota comparison") + + try: + diffs = compare_resource_quotas( + api_client, + name_a=BASELINE_QUOTA_NAME, + namespace_a=BASELINE_NAMESPACE, + name_b=TARGET_QUOTA_NAME, + namespace_b=TARGET_NAMESPACE, + ) + if diffs: + print( + f" '{BASELINE_QUOTA_NAME}' ({BASELINE_NAMESPACE}) differs from " + f"'{TARGET_QUOTA_NAME}' ({TARGET_NAMESPACE}):") + print_diffs(diffs) + else: + print( + f" '{BASELINE_QUOTA_NAME}' and '{TARGET_QUOTA_NAME}' " + "are semantically identical." + ) + except client.exceptions.ApiException as exc: + print(f" API error — make sure both quotas exist: {exc}") + + +# --------------------------------------------------------------------------- +# Demo 2 — Static ResourceList comparison (no cluster required) +# --------------------------------------------------------------------------- + +def demo_static_comparison() -> None: + """Compare two ResourceLists offline to illustrate unit normalisation.""" + print_section( + "Demo 2 — Offline ResourceList comparison (unit normalisation)") + + hard_baseline = { + "cpu": "1", # 1 core + "memory": "1Gi", # 1 gibibyte + "pods": "10", + } + hard_target = { + "cpu": "1000m", # semantically equal to "1" + "memory": "1073741824", # semantically equal to "1Gi" + "pods": "20", # genuinely different + } + + print(" Baseline hard limits:", hard_baseline) + print(" Target hard limits:", hard_target) + + diffs = get_resource_list_diff(hard_baseline, hard_target) + print("\n Differences (unit-normalised):") + print_diffs(diffs) + + # Show all fields including unchanged ones + diffs_all = get_resource_list_diff( + hard_baseline, hard_target, include_unchanged=True + ) + print("\n All resources (include_unchanged=True):") + print_diffs(diffs_all) + + +# --------------------------------------------------------------------------- +# Demo 3 — Boolean equality guard (reconciler pattern) +# --------------------------------------------------------------------------- + +def demo_equality_guard() -> None: + """Show the boolean helper — mirrors the Go reconciler guard pattern.""" + print_section("Demo 3 — Boolean equality guard (reconciler pattern)") + + # In Go: + # if !equality.Semantic.DeepEqual(current.Spec, stored.Spec) { + # // update needed + # } + + spec_current = client.V1ResourceQuotaSpec( + hard={"cpu": "1000m", "memory": "1073741824"} + ) + spec_stored = client.V1ResourceQuotaSpec( + hard={"cpu": "1", "memory": "1Gi"} + ) + + quota_current = client.V1ResourceQuota(spec=spec_current) + quota_stored = client.V1ResourceQuota(spec=spec_stored) + + if resource_quotas_equal(quota_current, quota_stored): + print(" ✅ Specs are semantically equal — no update needed.") + else: + print(" ✏️ Specs differ — would trigger an update.") + + # Now change memory on the stored quota + quota_stored.spec.hard["memory"] = "2Gi" + if not resource_quotas_equal(quota_current, quota_stored): + diffs = get_resource_list_diff( + quota_current.spec.hard, quota_stored.spec.hard + ) + print("\n After changing stored memory to 2Gi:") + print_diffs(diffs) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + try: + config.load_incluster_config() + except config.ConfigException: + config.load_kube_config() + + api_client = client.ApiClient() + + demo_static_comparison() + demo_equality_guard() + demo_cluster_comparison(api_client) + + +if __name__ == "__main__": + main() diff --git a/kubernetes/test/test_resource_quota.py b/kubernetes/test/test_resource_quota.py new file mode 100644 index 0000000000..8673ac5d7d --- /dev/null +++ b/kubernetes/test/test_resource_quota.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- + +# 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. + +""" +Unit tests for kubernetes.utils.resource_quota. + +Mirrors the semantic-equality behaviour expected of +``equality.Semantic.DeepEqual`` and ``resource.Quantity.Cmp()`` from the Go +apimachinery package: + + https://github.com/kubernetes/apimachinery/blob/master/pkg/api/equality/semantic.go + https://github.com/kubernetes/apimachinery/blob/master/pkg/api/resource/quantity.go +""" + +import unittest +from unittest.mock import MagicMock, patch + +from kubernetes import client +from kubernetes.utils.resource_quota import ( + ResourceChangeKind, + ResourceDiff, + compare_resource_quotas, + get_resource_list_diff, + resource_quotas_equal, +) + + +def _make_quota(hard: dict) -> client.V1ResourceQuota: + """Build a minimal V1ResourceQuota with the given spec.hard dict.""" + spec = client.V1ResourceQuotaSpec(hard=hard) + return client.V1ResourceQuota(spec=spec) + + +class TestGetResourceListDiff(unittest.TestCase): + """Tests for get_resource_list_diff — the core comparison helper.""" + + # ------------------------------------------------------------------ + # Semantic equality (unit normalisation) + # ------------------------------------------------------------------ + + def test_cpu_milli_equals_whole(self): + """1000m and 1 are semantically equal for CPU.""" + diffs = get_resource_list_diff({"cpu": "1000m"}, {"cpu": "1"}) + self.assertEqual(diffs, []) + + def test_memory_binary_equals_raw_bytes(self): + """1Gi and 1073741824 are semantically equal for memory.""" + diffs = get_resource_list_diff( + {"memory": "1Gi"}, {"memory": "1073741824"} + ) + self.assertEqual(diffs, []) + + def test_cpu_decimal_equals_milli(self): + """0.5 and 500m are semantically equal for CPU.""" + diffs = get_resource_list_diff({"cpu": "0.5"}, {"cpu": "500m"}) + self.assertEqual(diffs, []) + + def test_identical_plain_values_equal(self): + """Identical string values with no unit are equal.""" + diffs = get_resource_list_diff({"pods": "10"}, {"pods": "10"}) + self.assertEqual(diffs, []) + + def test_multiple_resources_all_equal(self): + """Multiple semantically equal resources produce no diffs.""" + hard_a = {"cpu": "1000m", "memory": "1Gi", "pods": "10"} + hard_b = {"cpu": "1", "memory": "1073741824", "pods": "10"} + self.assertEqual(get_resource_list_diff(hard_a, hard_b), []) + + # ------------------------------------------------------------------ + # MODIFIED + # ------------------------------------------------------------------ + + def test_modified_cpu(self): + """Detects CPU change from 500m to 1.""" + diffs = get_resource_list_diff({"cpu": "500m"}, {"cpu": "1"}) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].resource, "cpu") + self.assertEqual(diffs[0].kind, ResourceChangeKind.MODIFIED) + self.assertEqual(diffs[0].value_a, "500m") + self.assertEqual(diffs[0].value_b, "1") + + def test_modified_memory(self): + """Detects memory increase from 1Gi to 2Gi.""" + diffs = get_resource_list_diff({"memory": "1Gi"}, {"memory": "2Gi"}) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].resource, "memory") + self.assertEqual(diffs[0].kind, ResourceChangeKind.MODIFIED) + + def test_multiple_resources_some_modified(self): + """Only modified resources appear in the result.""" + hard_a = {"cpu": "1000m", "memory": "1Gi", "pods": "10"} + hard_b = {"cpu": "1", "memory": "2Gi", "pods": "10"} + diffs = get_resource_list_diff(hard_a, hard_b) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].resource, "memory") + self.assertEqual(diffs[0].kind, ResourceChangeKind.MODIFIED) + + # ------------------------------------------------------------------ + # ADDED / REMOVED + # ------------------------------------------------------------------ + + def test_added_resource(self): + """Resource present in b but absent in a is ADDED.""" + diffs = get_resource_list_diff({}, {"pods": "20"}) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].resource, "pods") + self.assertEqual(diffs[0].kind, ResourceChangeKind.ADDED) + self.assertIsNone(diffs[0].value_a) + self.assertEqual(diffs[0].value_b, "20") + + def test_removed_resource(self): + """Resource present in a but absent in b is REMOVED.""" + diffs = get_resource_list_diff({"pods": "20"}, {}) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].resource, "pods") + self.assertEqual(diffs[0].kind, ResourceChangeKind.REMOVED) + self.assertEqual(diffs[0].value_a, "20") + self.assertIsNone(diffs[0].value_b) + + # ------------------------------------------------------------------ + # include_unchanged flag + # ------------------------------------------------------------------ + + def test_include_unchanged_false_by_default(self): + """Unchanged resources are excluded by default.""" + diffs = get_resource_list_diff({"pods": "10"}, {"pods": "10"}) + self.assertEqual(diffs, []) + + def test_include_unchanged_true(self): + """Unchanged resources appear when include_unchanged=True.""" + diffs = get_resource_list_diff( + {"pods": "10"}, {"pods": "10"}, include_unchanged=True + ) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].kind, ResourceChangeKind.UNCHANGED) + + # ------------------------------------------------------------------ + # Sorting + # ------------------------------------------------------------------ + + def test_output_sorted_alphabetically(self): + """Diffs are sorted by resource name regardless of insertion order.""" + hard_a = {"pods": "5", "cpu": "500m", "memory": "1Gi"} + hard_b = {"pods": "10", "cpu": "1", "memory": "2Gi"} + diffs = get_resource_list_diff(hard_a, hard_b) + names = [d.resource for d in diffs] + self.assertEqual(names, sorted(names)) + + # ------------------------------------------------------------------ + # Edge cases + # ------------------------------------------------------------------ + + def test_both_empty(self): + """Empty dicts produce no diffs.""" + self.assertEqual(get_resource_list_diff({}, {}), []) + + def test_none_treated_as_empty(self): + """None values are treated as empty ResourceLists.""" + self.assertEqual(get_resource_list_diff(None, None), []) + + def test_invalid_quantity_raises_value_error(self): + """An unparseable quantity value raises ValueError.""" + with self.assertRaises(ValueError): + get_resource_list_diff({"cpu": "not-a-quantity"}, {"cpu": "1"}) + + +class TestResourceQuotasEqual(unittest.TestCase): + """Tests for resource_quotas_equal — the high-level boolean shortcut.""" + + def test_equal_quotas_returns_true(self): + """Two quotas with semantically identical specs are equal.""" + quota_a = _make_quota({"cpu": "1000m", "memory": "1Gi"}) + quota_b = _make_quota({"cpu": "1", "memory": "1073741824"}) + self.assertTrue(resource_quotas_equal(quota_a, quota_b)) + + def test_different_quotas_returns_false(self): + """Two quotas with differing specs are not equal.""" + quota_a = _make_quota({"cpu": "500m"}) + quota_b = _make_quota({"cpu": "2"}) + self.assertFalse(resource_quotas_equal(quota_a, quota_b)) + + def test_added_resource_returns_false(self): + """A quota with an extra resource is not equal.""" + quota_a = _make_quota({"cpu": "1"}) + quota_b = _make_quota({"cpu": "1", "pods": "10"}) + self.assertFalse(resource_quotas_equal(quota_a, quota_b)) + + def test_empty_specs_are_equal(self): + """Two quotas with empty hard specs are equal.""" + quota_a = _make_quota({}) + quota_b = _make_quota({}) + self.assertTrue(resource_quotas_equal(quota_a, quota_b)) + + def test_differing_scopes_returns_false(self): + """Quotas with different scopes are not equal despite equal hard.""" + spec_a = client.V1ResourceQuotaSpec(hard={"cpu": "1"}, scopes=["BestEffort"]) + spec_b = client.V1ResourceQuotaSpec(hard={"cpu": "1"}, scopes=["NotBestEffort"]) + quota_a = client.V1ResourceQuota(spec=spec_a) + quota_b = client.V1ResourceQuota(spec=spec_b) + self.assertFalse(resource_quotas_equal(quota_a, quota_b)) + + def test_same_scopes_different_order_returns_true(self): + """Scope list order does not affect equality.""" + spec_a = client.V1ResourceQuotaSpec( + hard={"cpu": "1"}, scopes=["BestEffort", "NotTerminating"] + ) + spec_b = client.V1ResourceQuotaSpec( + hard={"cpu": "1"}, scopes=["NotTerminating", "BestEffort"] + ) + quota_a = client.V1ResourceQuota(spec=spec_a) + quota_b = client.V1ResourceQuota(spec=spec_b) + self.assertTrue(resource_quotas_equal(quota_a, quota_b)) + + def test_differing_scope_selector_returns_false(self): + """Quotas with different scope selectors are not equal.""" + selector_a = client.V1ScopeSelector( + match_expressions=[ + client.V1ScopedResourceSelectorRequirement( + scope_name="PriorityClass", + operator="In", + values=["high"], + ) + ] + ) + selector_b = client.V1ScopeSelector( + match_expressions=[ + client.V1ScopedResourceSelectorRequirement( + scope_name="PriorityClass", + operator="In", + values=["low"], + ) + ] + ) + spec_a = client.V1ResourceQuotaSpec(hard={"cpu": "1"}, scope_selector=selector_a) + spec_b = client.V1ResourceQuotaSpec(hard={"cpu": "1"}, scope_selector=selector_b) + quota_a = client.V1ResourceQuota(spec=spec_a) + quota_b = client.V1ResourceQuota(spec=spec_b) + self.assertFalse(resource_quotas_equal(quota_a, quota_b)) + + +class TestCompareResourceQuotas(unittest.TestCase): + """Tests for compare_resource_quotas — the cluster-aware helper.""" + + def setUp(self): + self.mock_api_client = MagicMock(spec=client.ApiClient) + + @patch("kubernetes.utils.resource_quota.CoreV1Api") + def test_compare_returns_diffs(self, mock_core_v1_class): + """Diffs are returned when the two cluster quotas differ.""" + mock_v1 = MagicMock() + mock_core_v1_class.return_value = mock_v1 + + mock_v1.read_namespaced_resource_quota.side_effect = [ + _make_quota({"cpu": "1000m", "memory": "1Gi"}), + _make_quota({"cpu": "1", "memory": "2Gi"}), + ] + + diffs = compare_resource_quotas( + self.mock_api_client, + name_a="quota-a", namespace_a="ns-a", + name_b="quota-b", namespace_b="ns-b", + ) + + mock_core_v1_class.assert_called_once_with(self.mock_api_client) + self.assertEqual( + mock_v1.read_namespaced_resource_quota.call_count, 2 + ) + # cpu: 1000m == 1 → no diff. memory: 1Gi != 2Gi → diff. + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].resource, "memory") + self.assertEqual(diffs[0].kind, ResourceChangeKind.MODIFIED) + + @patch("kubernetes.utils.resource_quota.CoreV1Api") + def test_compare_returns_empty_when_equal(self, mock_core_v1_class): + """Empty list returned when quotas are semantically identical.""" + mock_v1 = MagicMock() + mock_core_v1_class.return_value = mock_v1 + + mock_v1.read_namespaced_resource_quota.side_effect = [ + _make_quota({"cpu": "1000m"}), + _make_quota({"cpu": "1"}), + ] + + diffs = compare_resource_quotas( + self.mock_api_client, + name_a="q1", namespace_a="default", + name_b="q2", namespace_b="default", + ) + self.assertEqual(diffs, []) + + @patch("kubernetes.utils.resource_quota.CoreV1Api") + def test_compare_propagates_api_exception(self, mock_core_v1_class): + """ApiException from the cluster is propagated to the caller.""" + from kubernetes.client.exceptions import ApiException + + mock_v1 = MagicMock() + mock_core_v1_class.return_value = mock_v1 + mock_v1.read_namespaced_resource_quota.side_effect = ApiException( + status=404, reason="Not Found" + ) + + with self.assertRaises(ApiException): + compare_resource_quotas( + self.mock_api_client, + name_a="missing", namespace_a="default", + name_b="other", namespace_b="default", + ) + + @patch("kubernetes.utils.resource_quota.CoreV1Api") + def test_compare_include_unchanged(self, mock_core_v1_class): + """include_unchanged=True includes equal resources in the output.""" + mock_v1 = MagicMock() + mock_core_v1_class.return_value = mock_v1 + + mock_v1.read_namespaced_resource_quota.side_effect = [ + _make_quota({"cpu": "1", "pods": "10"}), + _make_quota({"cpu": "1", "pods": "10"}), + ] + + diffs = compare_resource_quotas( + self.mock_api_client, + name_a="q1", namespace_a="default", + name_b="q2", namespace_b="default", + include_unchanged=True, + ) + + self.assertEqual(len(diffs), 2) + self.assertTrue( + all(d.kind == ResourceChangeKind.UNCHANGED for d in diffs) + ) + + +class TestResourceDiffStr(unittest.TestCase): + """Tests for ResourceDiff.__str__ rendering.""" + + def test_added_str(self): + d = ResourceDiff("pods", ResourceChangeKind.ADDED, None, "20") + self.assertIn("added", str(d)) + self.assertIn("20", str(d)) + + def test_removed_str(self): + d = ResourceDiff("pods", ResourceChangeKind.REMOVED, "20", None) + self.assertIn("removed", str(d)) + self.assertIn("20", str(d)) + + def test_modified_str(self): + d = ResourceDiff("memory", ResourceChangeKind.MODIFIED, "1Gi", "2Gi") + self.assertIn("1Gi", str(d)) + self.assertIn("2Gi", str(d)) + self.assertIn("→", str(d)) + + def test_unchanged_str(self): + d = ResourceDiff("cpu", ResourceChangeKind.UNCHANGED, "1", "1") + self.assertIn("unchanged", str(d)) + + +if __name__ == "__main__": + unittest.main() diff --git a/kubernetes/utils/__init__.py b/kubernetes/utils/__init__.py index 92ab696782..381b56afeb 100644 --- a/kubernetes/utils/__init__.py +++ b/kubernetes/utils/__init__.py @@ -19,3 +19,10 @@ from .duration import parse_duration from .metrics import (get_nodes_metrics, get_pods_metrics, get_pods_metrics_in_all_namespaces) +from .resource_quota import ( + ResourceChangeKind, + ResourceDiff, + get_resource_list_diff, + resource_quotas_equal, + compare_resource_quotas, +) diff --git a/kubernetes/utils/resource_quota.py b/kubernetes/utils/resource_quota.py new file mode 100644 index 0000000000..dd8c3bcb2c --- /dev/null +++ b/kubernetes/utils/resource_quota.py @@ -0,0 +1,306 @@ +# Copyright 2024 The Kubernetes Authors. +# +# 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. + +""" +ResourceQuota comparison utilities for the Kubernetes Python client. + +Provides semantic comparison of ResourceQuota objects, mirroring the behaviour +of ``equality.Semantic.DeepEqual`` and ``resource.Quantity.Cmp()`` from the Go +apimachinery package: + + https://github.com/kubernetes/apimachinery/blob/master/pkg/api/equality/semantic.go + https://github.com/kubernetes/apimachinery/blob/master/pkg/api/resource/quantity.go + +Plain string comparison of Kubernetes quantity values (e.g. ``"1Gi"`` vs +``"1073741824"``, or ``"1000m"`` vs ``"1"``) would produce false negatives. +All comparisons here are delegated to :func:`kubernetes.utils.quantity.parse_quantity` +so that unit normalisation is applied automatically. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Mapping, Optional + +from kubernetes.client.api.core_v1_api import CoreV1Api +from kubernetes.client.models.v1_resource_quota import V1ResourceQuota +from kubernetes.utils.quantity import parse_quantity + + +# --------------------------------------------------------------------------- +# Public data structures +# --------------------------------------------------------------------------- + + +class ResourceChangeKind(str, Enum): + """The kind of change detected for a single resource entry.""" + + ADDED = "added" + REMOVED = "removed" + MODIFIED = "modified" + UNCHANGED = "unchanged" + + +@dataclass +class ResourceDiff: + """Describes a semantic difference for a single resource within a ResourceList. + + Attributes: + resource: The resource name (e.g. ``"cpu"``, ``"memory"``, ``"pods"``). + kind: The :class:`ResourceChangeKind` of the detected change. + value_a: Raw quantity string from the first (baseline) ResourceList, + or ``None`` when the resource was absent. + value_b: Raw quantity string from the second (target) ResourceList, + or ``None`` when the resource was absent. + """ + + resource: str + kind: ResourceChangeKind + value_a: Optional[str] = field(default=None) + value_b: Optional[str] = field(default=None) + + def __str__(self) -> str: + if self.kind == ResourceChangeKind.ADDED: + return f"{self.resource}: added ({self.value_b})" + if self.kind == ResourceChangeKind.REMOVED: + return f"{self.resource}: removed ({self.value_a})" + if self.kind == ResourceChangeKind.MODIFIED: + return f"{self.resource}: {self.value_a} → {self.value_b}" + return f"{self.resource}: unchanged ({self.value_a})" + + +# --------------------------------------------------------------------------- +# Core comparison helpers +# --------------------------------------------------------------------------- + + +def get_resource_list_diff( + resource_list_a: Optional[Mapping[str, str]], + resource_list_b: Optional[Mapping[str, str]], + *, + include_unchanged: bool = False, +) -> List[ResourceDiff]: + """Return semantic differences between two Kubernetes ResourceList dicts. + + A *ResourceList* is the ``dict[str, str]`` mapping found in + ``ResourceQuota.spec.hard``, ``ResourceQuota.status.hard``, and + ``ResourceQuota.status.used``. Values are Kubernetes quantity strings + such as ``"500m"``, ``"1Gi"``, or ``"10"``. + + Comparison is performed by :func:`~kubernetes.utils.quantity.parse_quantity` + so that semantically equal values expressed in different units (e.g. + ``"1"`` and ``"1000m"`` for CPU) are correctly treated as equal — mirroring + ``equality.Semantic.DeepEqual`` from the Go apimachinery package. + + Args: + resource_list_a: Baseline ResourceList (treated as the *left* side), + or ``None`` (treated as an empty mapping). + resource_list_b: Target ResourceList (treated as the *right* side), + or ``None`` (treated as an empty mapping). + include_unchanged: When ``True``, resources that are semantically equal + are also included in the output as + :attr:`ResourceChangeKind.UNCHANGED` entries. Defaults to + ``False``. + + Returns: + A list of :class:`ResourceDiff` objects, one per resource key that + differs (or per key overall when *include_unchanged* is ``True``). + The list is sorted alphabetically by resource name for deterministic + output. + + Raises: + ValueError: If any quantity string cannot be parsed. + + Example:: + + from kubernetes.utils.resource_quota import get_resource_list_diff + + hard_a = {"cpu": "1000m", "memory": "1Gi", "pods": "10"} + hard_b = {"cpu": "1", "memory": "2Gi", "pods": "10"} + + diffs = get_resource_list_diff(hard_a, hard_b) + # → [ResourceDiff(resource="memory", kind=MODIFIED, value_a="1Gi", value_b="2Gi")] + # "cpu" is omitted because 1000m == 1 (semantic equality). + """ + resource_list_a = resource_list_a or {} + resource_list_b = resource_list_b or {} + + all_keys = sorted(set(resource_list_a) | set(resource_list_b)) + diffs: List[ResourceDiff] = [] + + for resource in all_keys: + val_a = resource_list_a.get(resource) + val_b = resource_list_b.get(resource) + + if val_a is None and val_b is not None: + diffs.append( + ResourceDiff( + resource=resource, + kind=ResourceChangeKind.ADDED, + value_a=None, + value_b=str(val_b), + ) + ) + elif val_a is not None and val_b is None: + diffs.append( + ResourceDiff( + resource=resource, + kind=ResourceChangeKind.REMOVED, + value_a=str(val_a), + value_b=None, + ) + ) + else: + # Both sides present — compare semantically. + parsed_a = parse_quantity(val_a) + parsed_b = parse_quantity(val_b) + if parsed_a != parsed_b: + diffs.append( + ResourceDiff( + resource=resource, + kind=ResourceChangeKind.MODIFIED, + value_a=str(val_a), + value_b=str(val_b), + ) + ) + elif include_unchanged: + diffs.append( + ResourceDiff( + resource=resource, + kind=ResourceChangeKind.UNCHANGED, + value_a=str(val_a), + value_b=str(val_b), + ) + ) + + return diffs + + +def resource_quotas_equal( + quota_a: V1ResourceQuota, + quota_b: V1ResourceQuota, +) -> bool: + """Return ``True`` when two ResourceQuota specs are fully identical. + + Compares the complete ``spec`` of both quotas: + + * ``spec.hard`` — compared semantically via :func:`get_resource_list_diff` + so that unit-equivalent quantities (e.g. ``"1"`` and ``"1000m"``) are + treated as equal, mirroring ``equality.Semantic.DeepEqual`` from the Go + apimachinery package. + * ``spec.scopes`` — compared as sets (order-independent). + * ``spec.scope_selector`` — compared by equality of the serialised object. + + Status and metadata are intentionally excluded; only desired state matters + for reconciler guards. + + Args: + quota_a: First :class:`~kubernetes.client.V1ResourceQuota` object. + quota_b: Second :class:`~kubernetes.client.V1ResourceQuota` object. + + Returns: + ``True`` if both specs are fully identical, ``False`` otherwise. + """ + spec_a = quota_a.spec + spec_b = quota_b.spec + + hard_a = (spec_a and spec_a.hard) or {} + hard_b = (spec_b and spec_b.hard) or {} + if get_resource_list_diff(hard_a, hard_b): + return False + + scopes_a = set(spec_a.scopes or []) if spec_a else set() + scopes_b = set(spec_b.scopes or []) if spec_b else set() + if scopes_a != scopes_b: + return False + + selector_a = (spec_a and spec_a.scope_selector) or None + selector_b = (spec_b and spec_b.scope_selector) or None + if selector_a != selector_b: + return False + + return True + + +# --------------------------------------------------------------------------- +# Cluster-aware helpers (require an API client) +# --------------------------------------------------------------------------- + + +def compare_resource_quotas( + api_client, + name_a: str, + namespace_a: str, + name_b: str, + namespace_b: str, + *, + include_unchanged: bool = False, +) -> List[ResourceDiff]: + """Fetch two ResourceQuotas from the cluster and return their differences. + + Both objects are retrieved via the ``CoreV1Api`` and their ``spec.hard`` + ResourceLists are compared semantically using + :func:`get_resource_list_diff`. + + This is the Python equivalent of a kubebuilder reconciler fetching two + ``ResourceQuota`` objects and comparing them with + ``equality.Semantic.DeepEqual`` / ``resource.Quantity.Cmp()``. + + Args: + api_client: An initialised :class:`~kubernetes.client.ApiClient`. + name_a: Name of the baseline ResourceQuota. + namespace_a: Namespace of the baseline ResourceQuota. + name_b: Name of the target ResourceQuota. + namespace_b: Namespace of the target ResourceQuota. + include_unchanged: Propagated to :func:`get_resource_list_diff`. + + Returns: + A list of :class:`ResourceDiff` objects describing every resource + that differs between the two quotas. + + Raises: + kubernetes.client.exceptions.ApiException: On API errors (e.g. quota + not found, permission denied). + ValueError: If any quantity string cannot be parsed. + + Example:: + + from kubernetes import client, config + from kubernetes.utils.resource_quota import compare_resource_quotas + + config.load_kube_config() + api_client = client.ApiClient() + + diffs = compare_resource_quotas( + api_client, + name_a="baseline-quota", namespace_a="default", + name_b="team-quota", namespace_b="team-ns", + ) + for diff in diffs: + print(diff) + """ + v1 = CoreV1Api(api_client) + + quota_a: V1ResourceQuota = v1.read_namespaced_resource_quota( + name=name_a, namespace=namespace_a + ) + quota_b: V1ResourceQuota = v1.read_namespaced_resource_quota( + name=name_b, namespace=namespace_b + ) + + hard_a = (quota_a.spec and quota_a.spec.hard) or {} + hard_b = (quota_b.spec and quota_b.spec.hard) or {} + + return get_resource_list_diff(hard_a, hard_b, include_unchanged=include_unchanged)