Skip to content

Commit cc7b1c0

Browse files
authored
[MISC] Move utils to single kernel (#129)
* Move utils to single kernel * Converge render file * Handle missing user
1 parent 4023d49 commit cc7b1c0

6 files changed

Lines changed: 388 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[project]
55
name = "postgresql-charms-single-kernel"
66
description = "Shared and reusable code for PostgreSQL-related charms"
7-
version = "16.1.12"
7+
version = "16.2.1"
88
readme = "README.md"
99
license = {file = "LICENSE"}
1010
authors = [
@@ -22,6 +22,9 @@ dependencies = [
2222
"tenacity>=9.0.0",
2323
]
2424

25+
[project.optional-dependencies]
26+
postgresql = ["httpx; python_version >= '3.12'"]
27+
2528
[build-system]
2629
requires = ["uv_build>=0.11.0,<0.12.0"]
2730
build-backend = "uv_build"

single_kernel_postgresql/config/literals.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
USER = "operator"
2323
SYSTEM_USERS = [MONITORING_USER, REPLICATION_USER, REWIND_USER, USER]
2424

25+
API_REQUEST_TIMEOUT = 5
26+
2527

2628
class Substrates(str, Enum):
2729
"""Possible substrates."""
Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,197 @@
1-
# Copyright 2025 Canonical Ltd.
1+
# Copyright 2022 Canonical Ltd.
22
# See LICENSE file for licensing details.
3-
"""Utils and helpers for PostgreSQL charms."""
3+
4+
"""A collection of utility functions that are used in the charm."""
5+
6+
import os
7+
import pwd
8+
import re
9+
import secrets
10+
import string
11+
from asyncio import as_completed, create_task, run, wait
12+
from contextlib import suppress
13+
from ssl import CERT_NONE, create_default_context
14+
from typing import Any
15+
16+
from httpx import AsyncClient, BasicAuth, HTTPError
17+
18+
from ..config.literals import API_REQUEST_TIMEOUT, Substrates
19+
20+
21+
def new_password() -> str:
22+
"""Generate a random password string.
23+
24+
Returns:
25+
A random password string.
26+
"""
27+
choices = string.ascii_letters + string.digits
28+
password = "".join([secrets.choice(choices) for _ in range(16)])
29+
return password
30+
31+
32+
def split_mem(mem_str) -> tuple:
33+
"""Split a memory string into a number and a unit.
34+
35+
Args:
36+
mem_str: a string representing a memory value, e.g. "1Gi"
37+
"""
38+
pattern = r"^(\d+)(\w+)$"
39+
parts = re.match(pattern, mem_str)
40+
if parts:
41+
return parts.groups()
42+
return None, "No unit found"
43+
44+
45+
def any_memory_to_bytes(mem_str) -> int:
46+
"""Convert a memory string to bytes.
47+
48+
Args:
49+
mem_str: a string representing a memory value, e.g. "1Gi"
50+
"""
51+
units = {
52+
"KI": 1024,
53+
"K": 10**3,
54+
"MI": 1048576,
55+
"M": 10**6,
56+
"GI": 1073741824,
57+
"G": 10**9,
58+
"TI": 1099511627776,
59+
"T": 10**12,
60+
}
61+
try:
62+
num = int(mem_str)
63+
return num
64+
except ValueError as e:
65+
memory, unit = split_mem(mem_str)
66+
unit = unit.upper()
67+
if unit not in units:
68+
raise ValueError(f"Invalid memory definition in '{mem_str}'") from e
69+
70+
num = int(memory)
71+
return int(num * units[unit])
72+
73+
74+
def any_cpu_to_cores(cpu_str) -> int:
75+
"""Convert a CPU string to cores.
76+
77+
Args:
78+
cpu_str: a string representing a CPU value, as integer or millis
79+
"""
80+
if cpu_str.endswith("m"):
81+
# convert millis to cores, undercommited
82+
return int(cpu_str[:-1]) // 1000
83+
return int(cpu_str)
84+
85+
86+
def label2name(label: str) -> str:
87+
"""Convert a unit label (with `-`) to a unit name (with `/`).
88+
89+
Args:
90+
label: The label to convert.
91+
92+
Returns:
93+
The converted name.
94+
"""
95+
return "/".join(label.rsplit("-", 1))
96+
97+
98+
def render_file(
99+
substrate: Substrates, path: str, content: str, mode: int, change_owner: bool = True
100+
) -> None:
101+
"""Write a content rendered from a template to a file.
102+
103+
Args:
104+
substrate: Charm substrate.
105+
path: the path to the file.
106+
content: the data to be written to the file.
107+
mode: access permission mask applied to the
108+
file using chmod (e.g. 0o640).
109+
change_owner: whether to change the file owner
110+
to the _daemon_ user.
111+
"""
112+
# TODO: keep this method to use it also for generating replication configuration files and
113+
# move it to an utils / helpers file.
114+
# Write the content to the file.
115+
with open(path, "w+") as file:
116+
file.write(content)
117+
# Ensure correct permissions are set on the file.
118+
os.chmod(path, mode)
119+
if change_owner:
120+
_change_owner(substrate, path)
121+
122+
123+
def create_directory(substrate: Substrates, path: str, mode: int) -> None:
124+
"""Creates a directory.
125+
126+
Args:
127+
substrate: Charm substrate.
128+
path: the path of the directory that should be created.
129+
mode: access permission mask applied to the
130+
directory using chmod (e.g. 0o640).
131+
"""
132+
os.makedirs(path, mode=mode, exist_ok=True)
133+
# Ensure correct permissions are set on the directory.
134+
os.chmod(path, mode)
135+
_change_owner(substrate, path)
136+
137+
138+
def _change_owner(substrate: Substrates, path: str) -> None:
139+
"""Change the ownership of a file or a directory to the postgres user.
140+
141+
Args:
142+
substrate: Charm substrate.
143+
path: path to a file or directory.
144+
"""
145+
try:
146+
# Get the uid/gid for the _daemon_ user.
147+
user_database = (
148+
pwd.getpwnam("_daemon_") if substrate == Substrates.VM else pwd.getpwnam("postgres")
149+
)
150+
# Set the correct ownership for the file or directory.
151+
os.chown(path, uid=user_database.pw_uid, gid=user_database.pw_gid)
152+
except KeyError:
153+
# Ignore non existing user error when it wasn't created yet.
154+
pass
155+
156+
157+
async def _httpx_get_request(
158+
url: str, cafile: str, auth: BasicAuth | None = None, verify: bool = True
159+
) -> dict[str, Any] | None:
160+
ssl_ctx = create_default_context()
161+
if verify:
162+
with suppress(FileNotFoundError):
163+
ssl_ctx.load_verify_locations(cafile=cafile)
164+
else:
165+
ssl_ctx.check_hostname = False
166+
ssl_ctx.verify_mode = CERT_NONE
167+
async with AsyncClient(auth=auth, timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx) as client:
168+
try:
169+
return (await client.get(url)).raise_for_status().json()
170+
except (HTTPError, ValueError):
171+
return None
172+
173+
174+
async def _async_get_request(
175+
uri: str, endpoints: list[str], cafile: str, auth: BasicAuth | None, verify: bool = True
176+
) -> dict[str, Any] | None:
177+
tasks = [
178+
create_task(_httpx_get_request(f"https://{ip}:8008{uri}", cafile, auth, verify))
179+
for ip in endpoints
180+
]
181+
for task in as_completed(tasks):
182+
if result := await task:
183+
for task in tasks:
184+
task.cancel()
185+
await wait(tasks)
186+
return result
187+
188+
189+
def parallel_patroni_get_request(
190+
uri: str,
191+
endpoints: list[str],
192+
cafile: str,
193+
auth: BasicAuth | None = None,
194+
verify: bool = True,
195+
) -> dict[str, Any] | None:
196+
"""Call all possible patroni endpoints in parallel."""
197+
return run(_async_get_request(uri, endpoints, cafile, auth, verify))

tests/unit/test_utils.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright 2021 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
import re
5+
from unittest.mock import mock_open, patch
6+
7+
from single_kernel_postgresql.config.literals import Substrates
8+
from single_kernel_postgresql.utils import (
9+
any_cpu_to_cores,
10+
any_memory_to_bytes,
11+
create_directory,
12+
label2name,
13+
new_password,
14+
render_file,
15+
)
16+
17+
18+
def test_any_memory_to_bytes():
19+
assert any_memory_to_bytes(1024) == 1024
20+
21+
assert any_memory_to_bytes("1KI") == 1024
22+
23+
try:
24+
any_memory_to_bytes("KI")
25+
assert False
26+
except ValueError as e:
27+
assert str(e) == "Invalid memory definition in 'KI'"
28+
29+
30+
def test_label2name():
31+
assert label2name("postgresql-k8s-1") == "postgresql-k8s/1"
32+
33+
34+
def test_any_cpu_to_cores():
35+
assert any_cpu_to_cores("12") == 12
36+
assert any_cpu_to_cores("1000m") == 1
37+
38+
39+
def test_new_password():
40+
# Test the password generation twice in order to check if we get different passwords and
41+
# that they meet the required criteria.
42+
first_password = new_password()
43+
assert len(first_password) == 16
44+
assert re.fullmatch("[a-zA-Z0-9\b]{16}$", first_password) is not None
45+
46+
second_password = new_password()
47+
assert re.fullmatch("[a-zA-Z0-9\b]{16}$", second_password) is not None
48+
assert second_password != first_password
49+
50+
51+
def test_render_file():
52+
with (
53+
patch("os.chmod") as _chmod,
54+
patch("os.chown") as _chown,
55+
patch("pwd.getpwnam") as _pwnam,
56+
patch("tempfile.NamedTemporaryFile") as _temp_file,
57+
):
58+
# Set a mocked temporary filename.
59+
filename = "/tmp/temporaryfilename"
60+
_temp_file.return_value.name = filename
61+
# Setup a mock for the `open` method.
62+
mock = mock_open()
63+
# Patch the `open` method with our mock.
64+
with patch("builtins.open", mock, create=True):
65+
# Set the uid/gid return values for lookup of 'postgres' user.
66+
_pwnam.return_value.pw_uid = 35
67+
_pwnam.return_value.pw_gid = 35
68+
# Call the method using a temporary configuration file.
69+
render_file(Substrates.VM, filename, "rendered-content", 0o640)
70+
71+
# Check the rendered file is opened with "w+" mode.
72+
assert mock.call_args_list[0][0] == (filename, "w+")
73+
# Ensure that the correct user is lookup up.
74+
_pwnam.assert_called_with("_daemon_")
75+
# Ensure the file is chmod'd correctly.
76+
_chmod.assert_called_with(filename, 0o640)
77+
# Ensure the file is chown'd correctly.
78+
_chown.assert_called_with(filename, uid=35, gid=35)
79+
80+
# Test when it's requested to not change the file owner.
81+
mock.reset_mock()
82+
_pwnam.reset_mock()
83+
_chmod.reset_mock()
84+
_chown.reset_mock()
85+
with patch("builtins.open", mock, create=True):
86+
render_file(Substrates.VM, filename, "rendered-content", 0o640, change_owner=False)
87+
_pwnam.assert_not_called()
88+
_chmod.assert_called_once_with(filename, 0o640)
89+
_chown.assert_not_called()
90+
91+
92+
def test_create_directory():
93+
with (
94+
patch("os.chmod") as _chmod,
95+
patch("os.chown") as _chown,
96+
patch("os.makedirs") as _makedirs,
97+
patch("pwd.getpwnam") as _pwnam,
98+
):
99+
_pwnam.return_value.pw_uid = 35
100+
_pwnam.return_value.pw_gid = 35
101+
102+
create_directory(Substrates.K8S, "test", 0o640)
103+
104+
_makedirs.assert_called_once_with("test", mode=0o640, exist_ok=True)
105+
_chmod.assert_called_once_with("test", 0o640)
106+
_chown.assert_called_once_with("test", uid=35, gid=35)
107+
_pwnam.assert_called_with("postgres")

tox.ini

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ allowlist_externals =
2222
[testenv:format]
2323
description = Apply coding style standards to code
2424
commands_pre =
25-
uv sync --active --group format
25+
uv sync --active --group format --all-extras
2626
commands =
2727
uv run --active ruff check --fix {[vars]all_path}
2828
uv run --active ruff format {[vars]all_path}
@@ -31,7 +31,7 @@ commands =
3131
[testenv:lint]
3232
description = Check code against coding style standards
3333
commands_pre =
34-
uv sync --active --group lint --group format
34+
uv sync --active --group lint --group format --all-extras
3535
commands =
3636
uv lock --check
3737
uv run --active codespell "{tox_root}" --skip "{tox_root}/.git" --skip "{tox_root}/.tox" \
@@ -45,7 +45,7 @@ commands =
4545
[testenv:unit]
4646
description = Run unit tests
4747
commands_pre =
48-
uv sync --active --group unit
48+
uv sync --active --group unit --all-extras
4949
commands =
5050
uv run --active coverage run --source={[vars]src_path} \
5151
-m pytest -v --tb native -s {posargs} {[vars]tests_path}/unit

0 commit comments

Comments
 (0)