diff --git a/pyproject.toml b/pyproject.toml index bbf204b..9b20be7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,10 @@ vm = [ "charmlibs-snap>=1.0.1; python_version >= '3.12'", "charmlibs-systemd>=1.0.0.post0; python_version >= '3.12'" ] +k8s = [ + "lightkube>=0.21.0; python_version >= '3.12'", + "lightkube-models>=1.28.1.4; python_version >= '3.12'", +] db-driver = [ "psycopg2>=2.9.10", ] diff --git a/single_kernel_postgresql/charms/k8s_charm.py b/single_kernel_postgresql/charms/k8s_charm.py index 772d210..6bff65d 100644 --- a/single_kernel_postgresql/charms/k8s_charm.py +++ b/single_kernel_postgresql/charms/k8s_charm.py @@ -52,7 +52,10 @@ def workload(self) -> BaseWorkload: BaseWorkload: The K8sWorkload instance for this charm """ return K8sWorkload( - charm_dir=self.charm_dir, container=self.unit.get_container(CONTAINER_NAME) + charm_dir=self.charm_dir, + container=self.unit.get_container(CONTAINER_NAME), + unit_name=self.unit.name, + namespace=self.model.name, ) @property diff --git a/single_kernel_postgresql/config/exceptions.py b/single_kernel_postgresql/config/exceptions.py index 2586653..7f6008e 100644 --- a/single_kernel_postgresql/config/exceptions.py +++ b/single_kernel_postgresql/config/exceptions.py @@ -73,3 +73,7 @@ class SwitchoverNotSyncError(SwitchoverFailedError): class UpdateSyncNodeCountError(Exception): """Raised when updating synchronous_node_count failed for some reason.""" + + +class DeployedWithoutTrustError(Exception): + """Raised when the K8s API denies access because the app wasn't deployed with --trust.""" diff --git a/single_kernel_postgresql/managers/config.py b/single_kernel_postgresql/managers/config.py index 08f204b..0774de1 100644 --- a/single_kernel_postgresql/managers/config.py +++ b/single_kernel_postgresql/managers/config.py @@ -65,6 +65,168 @@ def configure_patroni_on_unit(self): self.workload.paths.data, mode=POSTGRESQL_STORAGE_PERMISSIONS, exist_ok=True ) + def _calculate_max_worker_processes(self, cpu_cores: int) -> str | None: + """Calculate cpu_max_worker_processes configuration value.""" + if self.state.config.cpu_max_worker_processes == "auto": + # auto = minimum(8, 2 * vCores) + return str(min(8, 2 * cpu_cores)) + elif self.state.config.cpu_max_worker_processes is not None: + value = self.state.config.cpu_max_worker_processes + cap = 10 * cpu_cores + if value > cap: + raise ValueError( + f"cpu-max-worker-processes value {value} exceeds maximum allowed " + f"of {cap} (10 * vCores). Please set a value <= {cap}." + ) + return str(value) + return None + + def _validate_worker_config_value(self, param_name: str, value: int, cpu_cores: int) -> str: + """Shared validation logic for worker process parameters. + + Args: + param_name: the configuration parameter name (for error messages). + value: the integer value to validate. + cpu_cores: the number of available CPU cores. + + Returns: + String representation of the validated value. + + Raises: + ValueError: if value exceeds 10 * vCores. + """ + cap = 10 * cpu_cores + if value > cap: + raise ValueError( + f"{param_name} value {value} exceeds maximum allowed " + f"of {cap} (10 * vCores). Please set a value <= {cap}." + ) + return str(value) + + def _calculate_max_parallel_workers(self, base_max_workers: int, cpu_cores: int) -> str | None: + """Calculate cpu_max_parallel_workers configuration value.""" + if self.state.config.cpu_max_parallel_workers == "auto": + return str(base_max_workers) + elif self.state.config.cpu_max_parallel_workers is not None: + validated_value_str = self._validate_worker_config_value( + "cpu-max-parallel-workers", self.state.config.cpu_max_parallel_workers, cpu_cores + ) + # Apply the min constraint with base_max_workers + return str(min(int(validated_value_str), base_max_workers)) + return None + + def _calculate_max_parallel_maintenance_workers( + self, base_max_workers: int, cpu_cores: int + ) -> str | None: + """Calculate cpu_max_parallel_maintenance_workers configuration value.""" + if self.state.config.cpu_max_parallel_maintenance_workers == "auto": + return str(base_max_workers) + elif self.state.config.cpu_max_parallel_maintenance_workers is not None: + return self._validate_worker_config_value( + "cpu-max-parallel-maintenance-workers", + self.state.config.cpu_max_parallel_maintenance_workers, + cpu_cores, + ) + return None + + def _calculate_max_logical_replication_workers( + self, base_max_workers: int, cpu_cores: int + ) -> str | None: + """Calculate cpu_max_logical_replication_workers configuration value.""" + if self.state.config.cpu_max_logical_replication_workers == "auto": + return str(base_max_workers) + elif self.state.config.cpu_max_logical_replication_workers is not None: + return self._validate_worker_config_value( + "cpu-max-logical-replication-workers", + self.state.config.cpu_max_logical_replication_workers, + cpu_cores, + ) + return None + + def _calculate_max_sync_workers_per_subscription( + self, base_max_workers: int, cpu_cores: int + ) -> str | None: + """Calculate cpu_max_sync_workers_per_subscription configuration value.""" + if self.state.config.cpu_max_sync_workers_per_subscription == "auto": + return str(base_max_workers) + elif self.state.config.cpu_max_sync_workers_per_subscription is not None: + return self._validate_worker_config_value( + "cpu-max-sync-workers-per-subscription", + self.state.config.cpu_max_sync_workers_per_subscription, + cpu_cores, + ) + return None + + def _calculate_max_parallel_apply_workers_per_subscription( + self, base_max_workers: int, cpu_cores: int + ) -> str | None: + """Calculate cpu_max_parallel_apply_workers_per_subscription configuration value.""" + if self.state.config.cpu_max_parallel_apply_workers_per_subscription == "auto": + return str(base_max_workers) + elif self.state.config.cpu_max_parallel_apply_workers_per_subscription is not None: + return self._validate_worker_config_value( + "cpu-max-parallel-apply-workers-per-subscription", + self.state.config.cpu_max_parallel_apply_workers_per_subscription, + cpu_cores, + ) + return None + + def _calculate_worker_process_config(self, cpu_cores: int) -> dict[str, str]: + """Calculate worker process configuration values. + + Handles 'auto' values and capping logic for worker process parameters. + Returns a dictionary with the calculated values ready for PostgreSQL. + """ + result: dict[str, str] = {} + + # Calculate cpu_max_worker_processes (baseline for other worker configs) + cpu_max_worker_processes_value = self._calculate_max_worker_processes(cpu_cores) + if cpu_max_worker_processes_value is not None: + result["max_worker_processes"] = cpu_max_worker_processes_value + + # Get the effective cpu_max_worker_processes for dependent configs + # Use the calculated value, or fall back to PostgreSQL default (8) + base_max_workers = int(result.get("max_worker_processes", "8")) + + # Calculate other worker parameters + cpu_max_parallel_workers_value = self._calculate_max_parallel_workers( + base_max_workers, cpu_cores + ) + if cpu_max_parallel_workers_value is not None: + result["max_parallel_workers"] = cpu_max_parallel_workers_value + + cpu_max_parallel_maintenance_workers_value = ( + self._calculate_max_parallel_maintenance_workers(base_max_workers, cpu_cores) + ) + if cpu_max_parallel_maintenance_workers_value is not None: + result["max_parallel_maintenance_workers"] = cpu_max_parallel_maintenance_workers_value + + cpu_max_logical_replication_workers_value = ( + self._calculate_max_logical_replication_workers(base_max_workers, cpu_cores) + ) + if cpu_max_logical_replication_workers_value is not None: + result["max_logical_replication_workers"] = cpu_max_logical_replication_workers_value + + cpu_max_sync_workers_per_subscription_value = ( + self._calculate_max_sync_workers_per_subscription(base_max_workers, cpu_cores) + ) + if cpu_max_sync_workers_per_subscription_value is not None: + result["max_sync_workers_per_subscription"] = ( + cpu_max_sync_workers_per_subscription_value + ) + + cpu_max_parallel_apply_workers_per_subscription_value = ( + self._calculate_max_parallel_apply_workers_per_subscription( + base_max_workers, cpu_cores + ) + ) + if cpu_max_parallel_apply_workers_per_subscription_value is not None: + result["max_parallel_apply_workers_per_subscription"] = ( + cpu_max_parallel_apply_workers_per_subscription_value + ) + + return result + def _build_postgresql_parameters( self, postgresql_client: PostgreSQLClient ) -> dict[str, str] | None: @@ -73,17 +235,33 @@ def _build_postgresql_parameters( Returns: Dictionary of PostgreSQL parameters or None if base parameters couldn't be built. """ + cpu_cores, available_memory = self.workload.get_available_resources() + limit_memory = None if self.state.config.profile_limit_memory: limit_memory = self.state.config.profile_limit_memory * 10**6 # Build PostgreSQL parameters. pg_parameters = postgresql_client.build_postgresql_parameters( - self.state.model_config, self.workload.get_available_memory(), limit_memory + self.state.model_config, available_memory, limit_memory ) # Calculate and merge worker process configurations - # TODO: Add additional parameters + worker_configs = self._calculate_worker_process_config(cpu_cores) + + # Add cpu_wal_compression configuration (separate from worker processes) + if self.state.config.cpu_wal_compression is not None: + cpu_wal_compression = "on" if self.state.config.cpu_wal_compression else "off" + else: + # Use config.yaml default when unset (default: true) + cpu_wal_compression = "on" + + if pg_parameters is not None: + pg_parameters.update(worker_configs) + pg_parameters["wal_compression"] = cpu_wal_compression + else: + pg_parameters = dict(worker_configs) + pg_parameters["wal_compression"] = cpu_wal_compression return pg_parameters diff --git a/single_kernel_postgresql/workload/base.py b/single_kernel_postgresql/workload/base.py index c297ba1..e8366aa 100644 --- a/single_kernel_postgresql/workload/base.py +++ b/single_kernel_postgresql/workload/base.py @@ -273,3 +273,8 @@ def get_postgresql_version(self) -> str: def get_available_memory(self) -> int: """Returns the system available memory in bytes.""" pass + + @abstractmethod + def get_available_resources(self) -> tuple[int, int]: + """Returns the available (cpu_cores, memory_bytes) for the workload.""" + pass diff --git a/single_kernel_postgresql/workload/k8s.py b/single_kernel_postgresql/workload/k8s.py index 00c91a5..f735abf 100644 --- a/single_kernel_postgresql/workload/k8s.py +++ b/single_kernel_postgresql/workload/k8s.py @@ -12,14 +12,25 @@ from charmlibs import pathops from charmlibs.pathops import PathProtocol +from lightkube import Client +from lightkube.core.exceptions import ApiError +from lightkube.resources.core_v1 import Node, Pod from ops import Container, ModelError from ops.pebble import Plan, ServiceStatus -from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError +from single_kernel_postgresql.config.exceptions import ( + DeployedWithoutTrustError, + PostgreSQLFileOperationError, +) from single_kernel_postgresql.config.literals import ( DIR_PERMISSIONS_READONLY, K8S_POSTGRESQL_SERVICE_NAME, ) +from single_kernel_postgresql.utils import ( + any_cpu_to_cores, + any_memory_to_bytes, + unit_name_to_pod_name, +) from single_kernel_postgresql.workload.base import BaseWorkload from single_kernel_postgresql.workload.paths.base import Paths as BasePaths from single_kernel_postgresql.workload.paths.k8s import K8sPaths @@ -30,15 +41,20 @@ class K8sWorkload(BaseWorkload): """Kubernetes PostgreSQL Workload.""" - def __init__(self, charm_dir: Path, container: Container): + def __init__(self, charm_dir: Path, container: Container, *, unit_name: str, namespace: str): """Initialize workload. Args: charm_dir: the path to charm code. container: the Container instance. + unit_name: the Juju unit name (e.g. "postgresql-k8s/0"), used to resolve + this unit's own pod for K8s resource-limit lookups. + namespace: the Juju model name, i.e. the K8s namespace the pod lives in. """ super().__init__(charm_dir=charm_dir) self.container = container + self.unit_name = unit_name + self.namespace = namespace self._paths: BasePaths | None = None def install(self) -> None: @@ -225,3 +241,70 @@ def workload_present(self) -> bool: def get_available_memory(self) -> int: """Returns the system available memory in bytes.""" raise NotImplementedError + + def _get_node_name_for_pod(self) -> str: + """Return the node name for this unit's own pod.""" + client = Client() + pod = client.get(Pod, name=unit_name_to_pod_name(self.unit_name), namespace=self.namespace) + if pod.spec and pod.spec.nodeName: + return pod.spec.nodeName + raise RuntimeError("Pod doesn't exist") + + def get_resources_limits(self, container_name: str) -> dict: + """Return resources limits for a given container. + + Args: + container_name: name of the container to get resources limits for. + """ + client = Client() + pod = client.get(Pod, name=unit_name_to_pod_name(self.unit_name), namespace=self.namespace) + if pod.spec: + for container in pod.spec.containers: + if container.name == container_name and container.resources: + return container.resources.limits or {} + return {} + + def get_node_allocable_memory(self) -> int: + """Return the allocable memory in bytes for the current K8s node.""" + client = Client() + node = client.get(Node, name=self._get_node_name_for_pod(), namespace=self.namespace) # type: ignore + return any_memory_to_bytes(node.status.allocatable["memory"]) + + def get_node_cpu_cores(self) -> int: + """Return the number of CPU cores for the current K8s node.""" + client = Client() + node = client.get(Node, name=self._get_node_name_for_pod(), namespace=self.namespace) # type: ignore + return any_cpu_to_cores(node.status.allocatable["cpu"]) + + def get_available_resources(self) -> tuple[int, int]: + """Returns the available (cpu_cores, memory_bytes) for the workload. + + Raises: + DeployedWithoutTrustError: if the K8s API denies access (403), meaning the + app wasn't deployed with ``--trust``. + """ + try: + cpu_cores = self.get_node_cpu_cores() + allocable_memory = self.get_node_allocable_memory() + container_limits = self.get_resources_limits( + container_name=K8S_POSTGRESQL_SERVICE_NAME + ) + except ApiError as e: + if e.status.code == 403: + raise DeployedWithoutTrustError from e + raise + + if "cpu" in container_limits: + cpu_str = container_limits["cpu"] + constrained_cpu = any_cpu_to_cores(cpu_str) + if constrained_cpu < cpu_cores: + logger.debug(f"CPU constrained to {cpu_str} cores from resource limit") + cpu_cores = constrained_cpu + if "memory" in container_limits: + memory_str = container_limits["memory"] + constrained_memory = any_memory_to_bytes(memory_str) + if constrained_memory < allocable_memory: + logger.debug(f"Memory constrained to {memory_str} from resource limit") + allocable_memory = constrained_memory + + return cpu_cores, allocable_memory diff --git a/single_kernel_postgresql/workload/vm.py b/single_kernel_postgresql/workload/vm.py index a27a6b2..6d97ffa 100644 --- a/single_kernel_postgresql/workload/vm.py +++ b/single_kernel_postgresql/workload/vm.py @@ -4,6 +4,7 @@ """Machine Workload.""" import logging +import os import pathlib import platform import subprocess @@ -233,3 +234,7 @@ def get_available_memory(self) -> int: return int(line.split()[1]) * 1024 return 0 + + def get_available_resources(self) -> tuple[int, int]: + """Returns the available (cpu_cores, memory_bytes) for the workload.""" + return os.cpu_count() or 1, self.get_available_memory() diff --git a/tests/unit/test_config_manager.py b/tests/unit/test_config_manager.py index 024f6ea..9745eed 100644 --- a/tests/unit/test_config_manager.py +++ b/tests/unit/test_config_manager.py @@ -14,7 +14,11 @@ def config(substrate): mock_charm = Mock() mock_container = Mock() - workload = VMWorkload(".") if substrate == Substrates.VM else K8sWorkload(".", mock_container) + workload = ( + VMWorkload(".") + if substrate == Substrates.VM + else K8sWorkload(".", mock_container, unit_name="postgresql-k8s/0", namespace="test-model") + ) with patch( "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", return_value="16.6", @@ -229,3 +233,193 @@ def test_render_patroni_yml_file(substrate, config): sentinel.template_output, 0o600, ) + + +def _worker_config(**overrides) -> Mock: + """A Mock CharmConfig with all cpu_max_* fields at their pydantic "auto" default.""" + cfg = Mock() + cfg.cpu_max_worker_processes = "auto" + cfg.cpu_max_parallel_workers = "auto" + cfg.cpu_max_parallel_maintenance_workers = "auto" + cfg.cpu_max_logical_replication_workers = "auto" + cfg.cpu_max_sync_workers_per_subscription = "auto" + cfg.cpu_max_parallel_apply_workers_per_subscription = "auto" + for key, value in overrides.items(): + setattr(cfg, key, value) + return cfg + + +def test_calculate_max_worker_processes_auto_is_min_8_and_2x_cpu_cores(config): + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config() + assert config._calculate_max_worker_processes(cpu_cores=2) == "4" + assert config._calculate_max_worker_processes(cpu_cores=8) == "8" + + +def test_calculate_max_worker_processes_explicit_value_under_cap(config): + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config(cpu_max_worker_processes=6) + assert config._calculate_max_worker_processes(cpu_cores=4) == "6" + + +def test_calculate_max_worker_processes_explicit_value_over_cap_raises(config): + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config(cpu_max_worker_processes=41) + with pytest.raises(ValueError, match="cpu-max-worker-processes"): + config._calculate_max_worker_processes(cpu_cores=4) + + +def test_calculate_max_worker_processes_none_is_omitted(config): + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config(cpu_max_worker_processes=None) + assert config._calculate_max_worker_processes(cpu_cores=4) is None + + +def test_calculate_worker_process_config_auto_defaults_all_to_base_max_workers(config): + """With everything on 'auto', the dependent params default to the computed base.""" + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config() + # auto max_worker_processes for 4 cores = min(8, 2*4) = 8 + assert config._calculate_worker_process_config(cpu_cores=4) == { + "max_worker_processes": "8", + "max_parallel_workers": "8", + "max_parallel_maintenance_workers": "8", + "max_logical_replication_workers": "8", + "max_sync_workers_per_subscription": "8", + "max_parallel_apply_workers_per_subscription": "8", + } + + +def test_calculate_worker_process_config_parallel_workers_capped_by_base_max_workers(config): + """cpu_max_parallel_workers is min()-constrained against base_max_workers, not just the cap.""" + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config( + cpu_max_worker_processes=4, cpu_max_parallel_workers=20 + ) + # base_max_workers = 4 (explicit, under the 10*8=80 cap); cpu_max_parallel_workers=20 + # is under its own 10*8=80 cap, but min(20, 4) == 4. + result = config._calculate_worker_process_config(cpu_cores=8) + assert result["max_worker_processes"] == "4" + assert result["max_parallel_workers"] == "4" + + +def test_calculate_worker_process_config_dependent_param_over_cap_raises(config): + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config(cpu_max_parallel_maintenance_workers=41) + with pytest.raises(ValueError, match="cpu-max-parallel-maintenance-workers"): + config._calculate_worker_process_config(cpu_cores=4) + + +def test_calculate_worker_process_config_zero_cpu_count_is_zero(config): + """With 0 cores, auto max_worker_processes is min(8, 2*0) = 0, not the 8 fallback.""" + with patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config: + _config.return_value = _worker_config() + assert config._calculate_worker_process_config(cpu_cores=0)["max_worker_processes"] == "0" + + +def _build_config(**overrides) -> Mock: + """A Mock CharmConfig covering every field _build_postgresql_parameters reads.""" + cfg = _worker_config() + cfg.profile_limit_memory = None + cfg.cpu_wal_compression = None + for key, value in overrides.items(): + setattr(cfg, key, value) + return cfg + + +@pytest.fixture +def postgresql_client(): + client = Mock() + client.build_postgresql_parameters.return_value = {"shared_buffers": "128MB"} + return client + + +def test_build_postgresql_parameters_merges_worker_config_and_wal_compression_default( + config, postgresql_client +): + """cpu_wal_compression defaults to "on" when unset, matching the config.yaml default.""" + with ( + patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config, + patch.object(config.workload, "get_available_resources", return_value=(4, 8_000_000_000)), + ): + _config.return_value = _build_config() + result = config._build_postgresql_parameters(postgresql_client) + + assert result["shared_buffers"] == "128MB" + assert result["max_worker_processes"] == "8" + assert result["wal_compression"] == "on" + postgresql_client.build_postgresql_parameters.assert_called_once_with( + config.state.model_config, 8_000_000_000, None + ) + + +def test_build_postgresql_parameters_cpu_wal_compression_explicit_false(config, postgresql_client): + with ( + patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config, + patch.object(config.workload, "get_available_resources", return_value=(4, 8_000_000_000)), + ): + _config.return_value = _build_config(cpu_wal_compression=False) + result = config._build_postgresql_parameters(postgresql_client) + assert result["wal_compression"] == "off" + + +def test_build_postgresql_parameters_profile_limit_memory_converts_mb_to_bytes_and_caps( + config, postgresql_client +): + """profile_limit_memory is in MB (config.yaml unit); build_postgresql_parameters wants bytes.""" + with ( + patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config, + patch.object(config.workload, "get_available_resources", return_value=(4, 16_000_000_000)), + ): + _config.return_value = _build_config(profile_limit_memory=2_000) + config._build_postgresql_parameters(postgresql_client) + postgresql_client.build_postgresql_parameters.assert_called_once_with( + config.state.model_config, 16_000_000_000, 2_000_000_000 + ) + + +def test_build_postgresql_parameters_none_base_falls_back_to_worker_config_dict( + config, postgresql_client +): + """When build_postgresql_parameters returns None, the result is just the worker config.""" + postgresql_client.build_postgresql_parameters.return_value = None + with ( + patch( + "single_kernel_postgresql.core.state.CharmState.config", new_callable=PropertyMock + ) as _config, + patch.object(config.workload, "get_available_resources", return_value=(2, 4_000_000_000)), + ): + _config.return_value = _build_config() + result = config._build_postgresql_parameters(postgresql_client) + # auto max_worker_processes for 2 cores = min(8, 2*2) = 4 + assert result == { + "max_worker_processes": "4", + "max_parallel_workers": "4", + "max_parallel_maintenance_workers": "4", + "max_logical_replication_workers": "4", + "max_sync_workers_per_subscription": "4", + "max_parallel_apply_workers_per_subscription": "4", + "wal_compression": "on", + } diff --git a/tests/unit/test_patroni_manager.py b/tests/unit/test_patroni_manager.py index 5d11b99..592c633 100644 --- a/tests/unit/test_patroni_manager.py +++ b/tests/unit/test_patroni_manager.py @@ -49,7 +49,11 @@ def json(self): def patroni(substrate): mock_charm = Mock() mock_container = Mock() - workload = VMWorkload(".") if substrate == Substrates.VM else K8sWorkload(".", mock_container) + workload = ( + VMWorkload(".") + if substrate == Substrates.VM + else K8sWorkload(".", mock_container, unit_name="postgresql-k8s/0", namespace="test-model") + ) with patch( "single_kernel_postgresql.workload.base.BaseWorkload.get_postgresql_version", return_value="16.6", diff --git a/tests/unit/test_workload.py b/tests/unit/test_workload.py new file mode 100644 index 0000000..6ec76ae --- /dev/null +++ b/tests/unit/test_workload.py @@ -0,0 +1,137 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Tests for get_available_resources: substrate-specific resource discovery.""" + +from unittest.mock import Mock, mock_open, patch + +import pytest +from lightkube.core.exceptions import ApiError +from single_kernel_postgresql.config.exceptions import DeployedWithoutTrustError +from single_kernel_postgresql.workload.k8s import K8sWorkload +from single_kernel_postgresql.workload.vm import VMWorkload + + +def test_vm_get_available_resources_uses_cpu_count_and_available_memory(): + workload = VMWorkload(".") + with ( + patch("os.cpu_count", return_value=4), + patch.object(VMWorkload, "get_available_memory", return_value=8_000_000_000), + ): + assert workload.get_available_resources() == (4, 8_000_000_000) + + +def test_vm_get_available_resources_falls_back_to_one_cpu_when_undetectable(): + workload = VMWorkload(".") + with ( + patch("os.cpu_count", return_value=None), + patch.object(VMWorkload, "get_available_memory", return_value=1_000), + ): + assert workload.get_available_resources() == (1, 1_000) + + +def test_vm_get_available_memory_parses_memtotal_kb_to_bytes(): + """MemTotal is read in kB from /proc/meminfo and converted to bytes (* 1024).""" + workload = VMWorkload(".") + meminfo = ( + "SwapTotal: 0 kB\nMemTotal: 16089488 kB\nMemFree: 799284 kB\n" + ) + with patch("builtins.open", mock_open(read_data=meminfo)): + assert workload.get_available_memory() == 16089488 * 1024 + + +def test_vm_get_available_memory_returns_zero_when_memtotal_absent(): + workload = VMWorkload(".") + with patch("builtins.open", mock_open(read_data="")): + assert workload.get_available_memory() == 0 + + +def _node(cpu: str, memory: str) -> Mock: + node = Mock() + node.status.allocatable = {"cpu": cpu, "memory": memory} + return node + + +def _pod(node_name: str = "node-1", container_limits: dict | None = None) -> Mock: + pod = Mock() + pod.spec.nodeName = node_name + container = Mock() + container.name = "postgresql" + container.resources.limits = container_limits or {} + pod.spec.containers = [container] + return pod + + +@pytest.fixture +def k8s_workload() -> K8sWorkload: + return K8sWorkload( + ".", Mock(name="container"), unit_name="postgresql-k8s/0", namespace="test-model" + ) + + +def test_k8s_get_available_resources_reads_node_allocatable(k8s_workload): + """get_node_cpu_cores and get_node_allocable_memory each look up the pod, then its node.""" + client = Mock() + client.get.side_effect = [ + _pod(), + _node(cpu="4", memory="8Gi"), + _pod(), + _node("4", "8Gi"), + _pod(), + ] + with patch("single_kernel_postgresql.workload.k8s.Client", return_value=client): + assert k8s_workload.get_available_resources() == (4, 8 * 1024**3) + + +def test_k8s_get_available_resources_constrains_to_container_limits(k8s_workload): + client = Mock() + limits = {"cpu": "2", "memory": "1Gi"} + client.get.side_effect = [ + _pod(), + _node(cpu="4", memory="8Gi"), + _pod(), + _node(cpu="4", memory="8Gi"), + _pod(container_limits=limits), + ] + with patch("single_kernel_postgresql.workload.k8s.Client", return_value=client): + assert k8s_workload.get_available_resources() == (2, 1024**3) + + +def test_k8s_get_available_resources_ignores_container_limits_above_node_allocatable( + k8s_workload, +): + """A container limit looser than the node's own allocatable resources is not a constraint.""" + client = Mock() + limits = {"cpu": "16", "memory": "64Gi"} + client.get.side_effect = [ + _pod(), + _node(cpu="4", memory="8Gi"), + _pod(), + _node(cpu="4", memory="8Gi"), + _pod(container_limits=limits), + ] + with patch("single_kernel_postgresql.workload.k8s.Client", return_value=client): + assert k8s_workload.get_available_resources() == (4, 8 * 1024**3) + + +def test_k8s_get_available_resources_raises_trust_error_on_403(k8s_workload): + response = Mock(json=Mock(return_value={"code": 403, "message": "Forbidden"})) + error = ApiError(response=response) + client = Mock() + client.get.side_effect = error + with ( + patch("single_kernel_postgresql.workload.k8s.Client", return_value=client), + pytest.raises(DeployedWithoutTrustError), + ): + k8s_workload.get_available_resources() + + +def test_k8s_get_available_resources_reraises_non_403_api_errors(k8s_workload): + response = Mock(json=Mock(return_value={"code": 500, "message": "Internal error"})) + error = ApiError(response=response) + client = Mock() + client.get.side_effect = error + with ( + patch("single_kernel_postgresql.workload.k8s.Client", return_value=client), + pytest.raises(ApiError), + ): + k8s_workload.get_available_resources() diff --git a/uv.lock b/uv.lock index 91b9ded..f0e9931 100644 --- a/uv.lock +++ b/uv.lock @@ -22,8 +22,8 @@ name = "anyio" version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ @@ -44,7 +44,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "python_full_version >= '3.12' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -147,7 +147,7 @@ name = "charm-json" version = "0.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charm-api" }, + { name = "charm-api", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/11/a7adf8bffcc97d1e2f4dc4f8e6411a51871fe6931dabc34bbb86c582600f/charm_json-0.1.2.tar.gz", hash = "sha256:f91255858cd6721ec491d09450f37a0cdecf1addbfcfbd41f3e27c58dd95092e", size = 3208, upload-time = "2026-04-06T12:42:53.702Z" } wheels = [ @@ -159,14 +159,14 @@ name = "charm-refresh" version = "3.1.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charm-api" }, - { name = "charm-json" }, - { name = "httpx" }, - { name = "lightkube" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tomli" }, + { name = "charm-api", marker = "python_full_version >= '3.12'" }, + { name = "charm-json", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "lightkube", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "tomli", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/44/108b0623d344f024838456a7fd31b883637d6c3c936fa8a53167447fb8d3/charm_refresh-3.1.1.4.tar.gz", hash = "sha256:d33f66e5b65fd356a30bfbc634710e5538086199da93c30c86246370a66570be", size = 33278, upload-time = "2026-04-06T14:09:50.387Z" } wheels = [ @@ -178,9 +178,9 @@ name = "charmlibs-interfaces-tls-certificates" version = "1.8.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pydantic" }, + { name = "cryptography", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/b0/24342009af662f52f83d163852d43d53e9e0c3fb629feeeda5eeb739c03e/charmlibs_interfaces_tls_certificates-1.8.3.tar.gz", hash = "sha256:6bc35ee330681f2e0c58cf67e41133b144f3281f73d27293714c9ca918ee7cbc", size = 110247, upload-time = "2026-06-05T10:08:01.55Z" } wheels = [ @@ -192,7 +192,7 @@ name = "charmlibs-pathops" version = "1.3.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/1f/37cd5c74fd6adbb01f4947651af55b8ff8461eed034de3f6d018988022ab/charmlibs_pathops-1.3.0.post0.tar.gz", hash = "sha256:36ac11b0c7c55b71341a549a1803f5c8d61c2279204862b8d50736041b782f59", size = 22140, upload-time = "2026-06-16T05:39:49.177Z" } wheels = [ @@ -204,13 +204,13 @@ name = "charmlibs-rollingops" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "charmlibs-interfaces-tls-certificates" }, - { name = "charmlibs-pathops" }, - { name = "dpcharmlibs-interfaces" }, - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pydantic" }, - { name = "shortuuid" }, - { name = "tenacity", version = "9.1.4", source = { registry = "https://pypi.org/simple" } }, + { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, + { name = "charmlibs-pathops", marker = "python_full_version >= '3.12'" }, + { name = "dpcharmlibs-interfaces", marker = "python_full_version >= '3.12'" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "shortuuid", marker = "python_full_version >= '3.12'" }, + { name = "tenacity", version = "9.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6c/b2/4959afe13622b68faab2827c493b090f3e84a786c2d4a284803f55a18a76/charmlibs_rollingops-1.1.1.tar.gz", hash = "sha256:831457d2ef12117794f89a483ee9a5c347cb209a87dafe82907e9dede7d4641c", size = 91238, upload-time = "2026-05-27T15:10:05.592Z" } wheels = [ @@ -222,7 +222,7 @@ name = "charmlibs-snap" version = "1.0.1.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/33/5043f1dd01e485e1c855516e41694fae48f48e94dd52e6081e5b61eee649/charmlibs_snap-1.0.1.post0.tar.gz", hash = "sha256:1e8da752ee405aa981665da8c4772016236baf80d2d902b02ec8c1c640fb2844", size = 29393, upload-time = "2026-06-16T05:39:54.911Z" } wheels = [ @@ -495,7 +495,7 @@ name = "cryptography" version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -551,9 +551,9 @@ name = "data-platform-helpers" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pydantic" }, - { name = "rich" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/05/9cb4b743817ae3c9bff658d5ad56a2d28907d242c2a0e2308415273f7c98/data_platform_helpers-1.1.0.tar.gz", hash = "sha256:7539ef1c1047fec36ee8298aa4c75fe2bb36419544193b691805c12ca407a797", size = 42929, upload-time = "2026-06-17T14:18:32.435Z" } wheels = [ @@ -565,7 +565,7 @@ name = "deprecated" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt" }, + { name = "wrapt", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ @@ -577,8 +577,8 @@ name = "dpcharmlibs-interfaces" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pydantic" }, + { name = "ops", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/41/4cfa520d62c0aa53804599bf74506184eadc7c1e4803616383ec6bd3ab39/dpcharmlibs_interfaces-1.0.0.tar.gz", hash = "sha256:a177653019781a7a165be52cf4293134ee58912cd93686082cdd82da347a3bed", size = 203809, upload-time = "2026-03-24T17:38:47.136Z" } wheels = [ @@ -599,8 +599,8 @@ name = "h2" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, + { name = "hpack", marker = "python_full_version >= '3.12'" }, + { name = "hyperframe", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -621,8 +621,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "h11", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -634,10 +634,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "httpcore", marker = "python_full_version >= '3.12'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -646,7 +646,7 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2" }, + { name = "h2", marker = "python_full_version >= '3.12'" }, ] [[package]] @@ -654,10 +654,10 @@ name = "httpx-ws" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore" }, - { name = "httpx" }, - { name = "wsproto" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "httpcore", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "wsproto", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } wheels = [ @@ -690,7 +690,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -705,7 +705,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -726,7 +726,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -738,10 +738,10 @@ name = "lightkube" version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", extra = ["http2"] }, - { name = "httpx-ws" }, - { name = "lightkube-models" }, - { name = "pyyaml" }, + { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, + { name = "httpx-ws", marker = "python_full_version >= '3.12'" }, + { name = "lightkube-models", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/9329c34cab5fb5738ea54c2af1952f32f5ecedbed03779d1b82eb329ba8a/lightkube-0.21.0.tar.gz", hash = "sha256:720910200de4bd88e0d7b5d2edfb45a4f6be292798b8776d7b551ef5a5ccdc15", size = 36752, upload-time = "2026-05-13T08:26:06.108Z" } wheels = [ @@ -762,7 +762,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -882,8 +882,8 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "deprecated" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "deprecated", marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/8d/1f5a45fbcb9a7d87809d460f09dc3399e3fbd31d7f3e14888345e9d29951/opentelemetry_api-1.33.1.tar.gz", hash = "sha256:1c6055fc0a2d3f23a50c7e17e16ef75ad489345fd3df1f8b8af7c0bbf8a109e8", size = 65002, upload-time = "2025-05-16T18:52:41.146Z" } wheels = [ @@ -898,8 +898,8 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ @@ -915,7 +915,7 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", ] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ @@ -931,12 +931,12 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "opentelemetry-api", version = "1.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pyyaml" }, - { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "websocket-client", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/98/84789a5e15ad76e043301bbd70b4d39cffaa717ae6eecb662fd0dd0cc7af/ops-2.23.2.tar.gz", hash = "sha256:a69b0c5bc65ebd91720fc96459e81b969df851f3a6c9c855ee73de7004205987", size = 528074, upload-time = "2026-02-11T03:58:15.565Z" } @@ -953,9 +953,9 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", ] dependencies = [ - { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml" }, - { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "websocket-client", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/60/ad398d889fd03b1b4f950fad54ad4d0cf7a81fde21f9866a8139c8f03684/ops-3.7.1.tar.gz", hash = "sha256:1765bf6d1cff376ea27608542e183b055c89f2c5f54bca602072bcc817195abc", size = 582424, upload-time = "2026-05-28T04:13:43.906Z" } wheels = [ @@ -997,6 +997,10 @@ db-driver = [ { name = "psycopg2", version = "2.9.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "psycopg2", version = "2.9.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] +k8s = [ + { name = "lightkube", marker = "python_full_version >= '3.12'" }, + { name = "lightkube-models", marker = "python_full_version >= '3.12'" }, +] postgresql = [ { name = "charm-refresh", marker = "python_full_version >= '3.12'" }, { name = "charmlibs-interfaces-tls-certificates", marker = "python_full_version >= '3.12'" }, @@ -1040,6 +1044,8 @@ requires-dist = [ { name = "data-platform-helpers", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=0.1.7" }, { name = "httpx", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, { name = "jinja2", marker = "python_full_version >= '3.12' and extra == 'postgresql'", specifier = ">=3.1.6" }, + { name = "lightkube", marker = "python_full_version >= '3.12' and extra == 'k8s'", specifier = ">=0.21.0" }, + { name = "lightkube-models", marker = "python_full_version >= '3.12' and extra == 'k8s'", specifier = ">=1.28.1.4" }, { name = "ops", specifier = ">=2.0.0" }, { name = "psutil", marker = "python_full_version >= '3.12' and extra == 'vm'", specifier = ">=7.2.2" }, { name = "psycopg2", marker = "extra == 'db-driver'", specifier = ">=2.9.10" }, @@ -1049,7 +1055,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "tomli", marker = "python_full_version >= '3.12' and extra == 'postgresql'" }, ] -provides-extras = ["postgresql", "vm", "db-driver"] +provides-extras = ["postgresql", "vm", "k8s", "db-driver"] [package.metadata.requires-dev] format = [{ name = "ruff", marker = "python_full_version >= '3.12'", specifier = "==0.15.20" }] @@ -1143,10 +1149,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version >= '3.12'" }, + { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -1158,7 +1164,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -1303,11 +1309,11 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "iniconfig", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pluggy", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1399,10 +1405,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.12'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "urllib3", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -1414,8 +1420,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -1586,7 +1592,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -1747,7 +1753,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, + { name = "h11", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [