|
1 | | -# Copyright 2025 Canonical Ltd. |
| 1 | +# Copyright 2022 Canonical Ltd. |
2 | 2 | # 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)) |
0 commit comments