Skip to content

Commit f43f4f8

Browse files
committed
feat(config): port PostgreSQL parameter building + resource introspection
Internalizes PostgreSQL parameter calculation (worker-process auto/cap rules, wal_compression, memory-limit conversion) into the library so ConfigManager owns the full config-build flow instead of stopping at a TODO. Resource discovery (cpu_cores, memory_bytes) moves onto the workload rather than a substrate-specific manager: get_available_memory already lives there, ConfigManager already holds self.workload, and K8sManager doesn't exist yet when ConfigManager is constructed in abstract_charm - so the workload is the only place both substrates can share a single no-branch call site. K8sWorkload gains required unit_name/namespace constructor args so its lightkube Pod/Node lookups can resolve "my own pod" without depending on a charm object reference, mirroring what the K8s charm does today via self.unit.name/self.model.name. lightkube becomes a real (not merely transitive) dependency via a new k8s pyproject extra, matching the version pins the K8s test charm already uses. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent 22d4f17 commit f43f4f8

11 files changed

Lines changed: 688 additions & 90 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ vm = [
4040
"charmlibs-snap>=1.0.1; python_version >= '3.12'",
4141
"charmlibs-systemd>=1.0.0.post0; python_version >= '3.12'"
4242
]
43+
k8s = [
44+
"lightkube>=0.21.0; python_version >= '3.12'",
45+
"lightkube-models>=1.28.1.4; python_version >= '3.12'",
46+
]
4347
db-driver = [
4448
"psycopg2>=2.9.10",
4549
]

single_kernel_postgresql/charms/k8s_charm.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ def workload(self) -> BaseWorkload:
5252
BaseWorkload: The K8sWorkload instance for this charm
5353
"""
5454
return K8sWorkload(
55-
charm_dir=self.charm_dir, container=self.unit.get_container(CONTAINER_NAME)
55+
charm_dir=self.charm_dir,
56+
container=self.unit.get_container(CONTAINER_NAME),
57+
unit_name=self.unit.name,
58+
namespace=self.model.name,
5659
)
5760

5861
@property

single_kernel_postgresql/config/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,7 @@ class SwitchoverNotSyncError(SwitchoverFailedError):
7373

7474
class UpdateSyncNodeCountError(Exception):
7575
"""Raised when updating synchronous_node_count failed for some reason."""
76+
77+
78+
class DeployedWithoutTrustError(Exception):
79+
"""Raised when the K8s API denies access because the app wasn't deployed with --trust."""

single_kernel_postgresql/managers/config.py

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,168 @@ def configure_patroni_on_unit(self):
6565
self.workload.paths.data, mode=POSTGRESQL_STORAGE_PERMISSIONS, exist_ok=True
6666
)
6767

68+
def _calculate_max_worker_processes(self, cpu_cores: int) -> str | None:
69+
"""Calculate cpu_max_worker_processes configuration value."""
70+
if self.state.config.cpu_max_worker_processes == "auto":
71+
# auto = minimum(8, 2 * vCores)
72+
return str(min(8, 2 * cpu_cores))
73+
elif self.state.config.cpu_max_worker_processes is not None:
74+
value = self.state.config.cpu_max_worker_processes
75+
cap = 10 * cpu_cores
76+
if value > cap:
77+
raise ValueError(
78+
f"cpu-max-worker-processes value {value} exceeds maximum allowed "
79+
f"of {cap} (10 * vCores). Please set a value <= {cap}."
80+
)
81+
return str(value)
82+
return None
83+
84+
def _validate_worker_config_value(self, param_name: str, value: int, cpu_cores: int) -> str:
85+
"""Shared validation logic for worker process parameters.
86+
87+
Args:
88+
param_name: the configuration parameter name (for error messages).
89+
value: the integer value to validate.
90+
cpu_cores: the number of available CPU cores.
91+
92+
Returns:
93+
String representation of the validated value.
94+
95+
Raises:
96+
ValueError: if value exceeds 10 * vCores.
97+
"""
98+
cap = 10 * cpu_cores
99+
if value > cap:
100+
raise ValueError(
101+
f"{param_name} value {value} exceeds maximum allowed "
102+
f"of {cap} (10 * vCores). Please set a value <= {cap}."
103+
)
104+
return str(value)
105+
106+
def _calculate_max_parallel_workers(self, base_max_workers: int, cpu_cores: int) -> str | None:
107+
"""Calculate cpu_max_parallel_workers configuration value."""
108+
if self.state.config.cpu_max_parallel_workers == "auto":
109+
return str(base_max_workers)
110+
elif self.state.config.cpu_max_parallel_workers is not None:
111+
validated_value_str = self._validate_worker_config_value(
112+
"cpu-max-parallel-workers", self.state.config.cpu_max_parallel_workers, cpu_cores
113+
)
114+
# Apply the min constraint with base_max_workers
115+
return str(min(int(validated_value_str), base_max_workers))
116+
return None
117+
118+
def _calculate_max_parallel_maintenance_workers(
119+
self, base_max_workers: int, cpu_cores: int
120+
) -> str | None:
121+
"""Calculate cpu_max_parallel_maintenance_workers configuration value."""
122+
if self.state.config.cpu_max_parallel_maintenance_workers == "auto":
123+
return str(base_max_workers)
124+
elif self.state.config.cpu_max_parallel_maintenance_workers is not None:
125+
return self._validate_worker_config_value(
126+
"cpu-max-parallel-maintenance-workers",
127+
self.state.config.cpu_max_parallel_maintenance_workers,
128+
cpu_cores,
129+
)
130+
return None
131+
132+
def _calculate_max_logical_replication_workers(
133+
self, base_max_workers: int, cpu_cores: int
134+
) -> str | None:
135+
"""Calculate cpu_max_logical_replication_workers configuration value."""
136+
if self.state.config.cpu_max_logical_replication_workers == "auto":
137+
return str(base_max_workers)
138+
elif self.state.config.cpu_max_logical_replication_workers is not None:
139+
return self._validate_worker_config_value(
140+
"cpu-max-logical-replication-workers",
141+
self.state.config.cpu_max_logical_replication_workers,
142+
cpu_cores,
143+
)
144+
return None
145+
146+
def _calculate_max_sync_workers_per_subscription(
147+
self, base_max_workers: int, cpu_cores: int
148+
) -> str | None:
149+
"""Calculate cpu_max_sync_workers_per_subscription configuration value."""
150+
if self.state.config.cpu_max_sync_workers_per_subscription == "auto":
151+
return str(base_max_workers)
152+
elif self.state.config.cpu_max_sync_workers_per_subscription is not None:
153+
return self._validate_worker_config_value(
154+
"cpu-max-sync-workers-per-subscription",
155+
self.state.config.cpu_max_sync_workers_per_subscription,
156+
cpu_cores,
157+
)
158+
return None
159+
160+
def _calculate_max_parallel_apply_workers_per_subscription(
161+
self, base_max_workers: int, cpu_cores: int
162+
) -> str | None:
163+
"""Calculate cpu_max_parallel_apply_workers_per_subscription configuration value."""
164+
if self.state.config.cpu_max_parallel_apply_workers_per_subscription == "auto":
165+
return str(base_max_workers)
166+
elif self.state.config.cpu_max_parallel_apply_workers_per_subscription is not None:
167+
return self._validate_worker_config_value(
168+
"cpu-max-parallel-apply-workers-per-subscription",
169+
self.state.config.cpu_max_parallel_apply_workers_per_subscription,
170+
cpu_cores,
171+
)
172+
return None
173+
174+
def _calculate_worker_process_config(self, cpu_cores: int) -> dict[str, str]:
175+
"""Calculate worker process configuration values.
176+
177+
Handles 'auto' values and capping logic for worker process parameters.
178+
Returns a dictionary with the calculated values ready for PostgreSQL.
179+
"""
180+
result: dict[str, str] = {}
181+
182+
# Calculate cpu_max_worker_processes (baseline for other worker configs)
183+
cpu_max_worker_processes_value = self._calculate_max_worker_processes(cpu_cores)
184+
if cpu_max_worker_processes_value is not None:
185+
result["max_worker_processes"] = cpu_max_worker_processes_value
186+
187+
# Get the effective cpu_max_worker_processes for dependent configs
188+
# Use the calculated value, or fall back to PostgreSQL default (8)
189+
base_max_workers = int(result.get("max_worker_processes", "8"))
190+
191+
# Calculate other worker parameters
192+
cpu_max_parallel_workers_value = self._calculate_max_parallel_workers(
193+
base_max_workers, cpu_cores
194+
)
195+
if cpu_max_parallel_workers_value is not None:
196+
result["max_parallel_workers"] = cpu_max_parallel_workers_value
197+
198+
cpu_max_parallel_maintenance_workers_value = (
199+
self._calculate_max_parallel_maintenance_workers(base_max_workers, cpu_cores)
200+
)
201+
if cpu_max_parallel_maintenance_workers_value is not None:
202+
result["max_parallel_maintenance_workers"] = cpu_max_parallel_maintenance_workers_value
203+
204+
cpu_max_logical_replication_workers_value = (
205+
self._calculate_max_logical_replication_workers(base_max_workers, cpu_cores)
206+
)
207+
if cpu_max_logical_replication_workers_value is not None:
208+
result["max_logical_replication_workers"] = cpu_max_logical_replication_workers_value
209+
210+
cpu_max_sync_workers_per_subscription_value = (
211+
self._calculate_max_sync_workers_per_subscription(base_max_workers, cpu_cores)
212+
)
213+
if cpu_max_sync_workers_per_subscription_value is not None:
214+
result["max_sync_workers_per_subscription"] = (
215+
cpu_max_sync_workers_per_subscription_value
216+
)
217+
218+
cpu_max_parallel_apply_workers_per_subscription_value = (
219+
self._calculate_max_parallel_apply_workers_per_subscription(
220+
base_max_workers, cpu_cores
221+
)
222+
)
223+
if cpu_max_parallel_apply_workers_per_subscription_value is not None:
224+
result["max_parallel_apply_workers_per_subscription"] = (
225+
cpu_max_parallel_apply_workers_per_subscription_value
226+
)
227+
228+
return result
229+
68230
def _build_postgresql_parameters(
69231
self, postgresql_client: PostgreSQLClient
70232
) -> dict[str, str] | None:
@@ -73,17 +235,33 @@ def _build_postgresql_parameters(
73235
Returns:
74236
Dictionary of PostgreSQL parameters or None if base parameters couldn't be built.
75237
"""
238+
cpu_cores, available_memory = self.workload.get_available_resources()
239+
76240
limit_memory = None
77241
if self.state.config.profile_limit_memory:
78242
limit_memory = self.state.config.profile_limit_memory * 10**6
79243

80244
# Build PostgreSQL parameters.
81245
pg_parameters = postgresql_client.build_postgresql_parameters(
82-
self.state.model_config, self.workload.get_available_memory(), limit_memory
246+
self.state.model_config, available_memory, limit_memory
83247
)
84248

85249
# Calculate and merge worker process configurations
86-
# TODO: Add additional parameters
250+
worker_configs = self._calculate_worker_process_config(cpu_cores)
251+
252+
# Add cpu_wal_compression configuration (separate from worker processes)
253+
if self.state.config.cpu_wal_compression is not None:
254+
cpu_wal_compression = "on" if self.state.config.cpu_wal_compression else "off"
255+
else:
256+
# Use config.yaml default when unset (default: true)
257+
cpu_wal_compression = "on"
258+
259+
if pg_parameters is not None:
260+
pg_parameters.update(worker_configs)
261+
pg_parameters["wal_compression"] = cpu_wal_compression
262+
else:
263+
pg_parameters = dict(worker_configs)
264+
pg_parameters["wal_compression"] = cpu_wal_compression
87265

88266
return pg_parameters
89267

single_kernel_postgresql/workload/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,3 +273,8 @@ def get_postgresql_version(self) -> str:
273273
def get_available_memory(self) -> int:
274274
"""Returns the system available memory in bytes."""
275275
pass
276+
277+
@abstractmethod
278+
def get_available_resources(self) -> tuple[int, int]:
279+
"""Returns the available (cpu_cores, memory_bytes) for the workload."""
280+
pass

single_kernel_postgresql/workload/k8s.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,25 @@
1212

1313
from charmlibs import pathops
1414
from charmlibs.pathops import PathProtocol
15+
from lightkube import Client
16+
from lightkube.core.exceptions import ApiError
17+
from lightkube.resources.core_v1 import Node, Pod
1518
from ops import Container, ModelError
1619
from ops.pebble import Plan, ServiceStatus
1720

18-
from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError
21+
from single_kernel_postgresql.config.exceptions import (
22+
DeployedWithoutTrustError,
23+
PostgreSQLFileOperationError,
24+
)
1925
from single_kernel_postgresql.config.literals import (
2026
DIR_PERMISSIONS_READONLY,
2127
K8S_POSTGRESQL_SERVICE_NAME,
2228
)
29+
from single_kernel_postgresql.utils import (
30+
any_cpu_to_cores,
31+
any_memory_to_bytes,
32+
unit_name_to_pod_name,
33+
)
2334
from single_kernel_postgresql.workload.base import BaseWorkload
2435
from single_kernel_postgresql.workload.paths.base import Paths as BasePaths
2536
from single_kernel_postgresql.workload.paths.k8s import K8sPaths
@@ -30,15 +41,20 @@
3041
class K8sWorkload(BaseWorkload):
3142
"""Kubernetes PostgreSQL Workload."""
3243

33-
def __init__(self, charm_dir: Path, container: Container):
44+
def __init__(self, charm_dir: Path, container: Container, *, unit_name: str, namespace: str):
3445
"""Initialize workload.
3546
3647
Args:
3748
charm_dir: the path to charm code.
3849
container: the Container instance.
50+
unit_name: the Juju unit name (e.g. "postgresql-k8s/0"), used to resolve
51+
this unit's own pod for K8s resource-limit lookups.
52+
namespace: the Juju model name, i.e. the K8s namespace the pod lives in.
3953
"""
4054
super().__init__(charm_dir=charm_dir)
4155
self.container = container
56+
self.unit_name = unit_name
57+
self.namespace = namespace
4258
self._paths: BasePaths | None = None
4359

4460
def install(self) -> None:
@@ -225,3 +241,70 @@ def workload_present(self) -> bool:
225241
def get_available_memory(self) -> int:
226242
"""Returns the system available memory in bytes."""
227243
raise NotImplementedError
244+
245+
def _get_node_name_for_pod(self) -> str:
246+
"""Return the node name for this unit's own pod."""
247+
client = Client()
248+
pod = client.get(Pod, name=unit_name_to_pod_name(self.unit_name), namespace=self.namespace)
249+
if pod.spec and pod.spec.nodeName:
250+
return pod.spec.nodeName
251+
raise RuntimeError("Pod doesn't exist")
252+
253+
def get_resources_limits(self, container_name: str) -> dict:
254+
"""Return resources limits for a given container.
255+
256+
Args:
257+
container_name: name of the container to get resources limits for.
258+
"""
259+
client = Client()
260+
pod = client.get(Pod, name=unit_name_to_pod_name(self.unit_name), namespace=self.namespace)
261+
if pod.spec:
262+
for container in pod.spec.containers:
263+
if container.name == container_name and container.resources:
264+
return container.resources.limits or {}
265+
return {}
266+
267+
def get_node_allocable_memory(self) -> int:
268+
"""Return the allocable memory in bytes for the current K8s node."""
269+
client = Client()
270+
node = client.get(Node, name=self._get_node_name_for_pod(), namespace=self.namespace) # type: ignore
271+
return any_memory_to_bytes(node.status.allocatable["memory"])
272+
273+
def get_node_cpu_cores(self) -> int:
274+
"""Return the number of CPU cores for the current K8s node."""
275+
client = Client()
276+
node = client.get(Node, name=self._get_node_name_for_pod(), namespace=self.namespace) # type: ignore
277+
return any_cpu_to_cores(node.status.allocatable["cpu"])
278+
279+
def get_available_resources(self) -> tuple[int, int]:
280+
"""Returns the available (cpu_cores, memory_bytes) for the workload.
281+
282+
Raises:
283+
DeployedWithoutTrustError: if the K8s API denies access (403), meaning the
284+
app wasn't deployed with ``--trust``.
285+
"""
286+
try:
287+
cpu_cores = self.get_node_cpu_cores()
288+
allocable_memory = self.get_node_allocable_memory()
289+
container_limits = self.get_resources_limits(
290+
container_name=K8S_POSTGRESQL_SERVICE_NAME
291+
)
292+
except ApiError as e:
293+
if e.status.code == 403:
294+
raise DeployedWithoutTrustError from e
295+
raise
296+
297+
if "cpu" in container_limits:
298+
cpu_str = container_limits["cpu"]
299+
constrained_cpu = any_cpu_to_cores(cpu_str)
300+
if constrained_cpu < cpu_cores:
301+
logger.debug(f"CPU constrained to {cpu_str} cores from resource limit")
302+
cpu_cores = constrained_cpu
303+
if "memory" in container_limits:
304+
memory_str = container_limits["memory"]
305+
constrained_memory = any_memory_to_bytes(memory_str)
306+
if constrained_memory < allocable_memory:
307+
logger.debug(f"Memory constrained to {memory_str} from resource limit")
308+
allocable_memory = constrained_memory
309+
310+
return cpu_cores, allocable_memory

single_kernel_postgresql/workload/vm.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""Machine Workload."""
55

66
import logging
7+
import os
78
import pathlib
89
import platform
910
import subprocess
@@ -233,3 +234,7 @@ def get_available_memory(self) -> int:
233234
return int(line.split()[1]) * 1024
234235

235236
return 0
237+
238+
def get_available_resources(self) -> tuple[int, int]:
239+
"""Returns the available (cpu_cores, memory_bytes) for the workload."""
240+
return os.cpu_count() or 1, self.get_available_memory()

0 commit comments

Comments
 (0)