|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | | -import os |
4 | | -import shlex |
5 | | -import shutil |
6 | | -import socket |
7 | | -import subprocess # nosec B404 - test helpers shell out only to local tooling |
8 | | -import time |
9 | | -import uuid |
10 | | -from collections.abc import Iterator |
11 | | -from contextlib import contextmanager |
12 | 3 | from pathlib import Path |
13 | 4 |
|
14 | | -REPO_ROOT = Path(__file__).resolve().parents[1] |
15 | | - |
16 | | - |
17 | | -def run_command( |
18 | | - command: list[str], |
19 | | - *, |
20 | | - cwd: Path | None = None, |
21 | | - env: dict[str, str] | None = None, |
22 | | - check: bool = True, |
23 | | - capture_output: bool = True, |
24 | | -) -> subprocess.CompletedProcess[str]: |
25 | | - return subprocess.run( # nosec B603 - tests execute trusted local commands only |
26 | | - command, |
27 | | - cwd=cwd or REPO_ROOT, |
28 | | - env=env, |
29 | | - check=check, |
30 | | - text=True, |
31 | | - capture_output=capture_output, |
32 | | - ) |
33 | | - |
34 | | - |
35 | | -def docker_available() -> bool: |
36 | | - if shutil.which("docker") is None: |
37 | | - return False |
38 | | - |
39 | | - result = run_command(["docker", "info"], check=False) |
40 | | - return result.returncode == 0 |
41 | | - |
42 | | - |
43 | | -def docker_image_exists(image_tag: str) -> bool: |
44 | | - result = run_command(["docker", "image", "inspect", image_tag], check=False) |
45 | | - return result.returncode == 0 |
46 | | - |
47 | | - |
48 | | -def ensure_pytest_image( |
49 | | - image_tag: str, |
50 | | - *, |
51 | | - dockerfile: str = "Dockerfile", |
52 | | - prebuilt_env: str = "AIO_PYTEST_USE_PREBUILT_IMAGE", |
53 | | -) -> None: |
54 | | - if os.environ.get(prebuilt_env) == "true": |
55 | | - if not docker_image_exists(image_tag): |
56 | | - raise AssertionError( |
57 | | - f"Expected prebuilt pytest image {image_tag} to be loaded before the test run." |
58 | | - ) |
59 | | - return |
60 | | - |
61 | | - command = ["docker", "build", "--platform", "linux/amd64", "-t", image_tag] |
62 | | - if dockerfile != "Dockerfile": |
63 | | - command.extend(["-f", dockerfile]) |
64 | | - command.append(".") |
65 | | - run_command(command) |
66 | | - |
67 | | - |
68 | | -def reserve_host_port() -> int: |
69 | | - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: |
70 | | - sock.bind(("127.0.0.1", 0)) |
71 | | - sock.listen(1) |
72 | | - return sock.getsockname()[1] |
73 | | - |
74 | | - |
75 | | -def create_docker_volume(prefix: str) -> str: |
76 | | - volume_name = f"{prefix}-{uuid.uuid4().hex[:10]}" |
77 | | - run_command(["docker", "volume", "create", volume_name]) |
78 | | - return volume_name |
79 | | - |
80 | | - |
81 | | -def remove_docker_volume(volume_name: str) -> None: |
82 | | - run_command(["docker", "volume", "rm", "-f", volume_name], check=False) |
83 | | - |
84 | | - |
85 | | -@contextmanager |
86 | | -def docker_volume(prefix: str) -> Iterator[str]: |
87 | | - volume_name = create_docker_volume(prefix) |
88 | | - try: |
89 | | - yield volume_name |
90 | | - finally: |
91 | | - remove_docker_volume(volume_name) |
92 | | - |
93 | | - |
94 | | -def docker_exec( |
95 | | - container_name: str, command: str, *, check: bool = True |
96 | | -) -> subprocess.CompletedProcess[str]: |
97 | | - return run_command( |
98 | | - ["docker", "exec", container_name, "sh", "-lc", command], check=check |
99 | | - ) |
100 | | - |
101 | | - |
102 | | -def container_path_exists(container_name: str, path: str) -> bool: |
103 | | - return ( |
104 | | - docker_exec( |
105 | | - container_name, f"test -e {shlex.quote(path)}", check=False |
106 | | - ).returncode |
107 | | - == 0 |
108 | | - ) |
109 | | - |
110 | | - |
111 | | -def read_container_file(container_name: str, path: str) -> str: |
112 | | - return docker_exec(container_name, f"cat {shlex.quote(path)}").stdout |
113 | | - |
| 5 | +from aio_fleet.app_testing import * # noqa: F403 |
| 6 | +from aio_fleet.app_testing import configure_repo_root |
114 | 7 |
|
115 | | -def container_file_size(container_name: str, path: str) -> int: |
116 | | - return int( |
117 | | - docker_exec(container_name, f"wc -c < {shlex.quote(path)}").stdout.strip() |
118 | | - ) |
119 | | - |
120 | | - |
121 | | -class DockerRuntime: |
122 | | - def __init__(self, image_tag: str) -> None: |
123 | | - self.image_tag = image_tag |
124 | | - |
125 | | - def build(self) -> None: |
126 | | - ensure_pytest_image(self.image_tag) |
127 | | - |
128 | | - def inspect_state(self, name: str, field: str) -> str: |
129 | | - result = run_command( |
130 | | - ["docker", "inspect", "-f", f"{{{{.{field}}}}}", name], |
131 | | - check=False, |
132 | | - ) |
133 | | - return result.stdout.strip() if result.returncode == 0 else "" |
134 | | - |
135 | | - def logs(self, name: str) -> str: |
136 | | - result = run_command(["docker", "logs", name], check=False) |
137 | | - return result.stdout + result.stderr |
138 | | - |
139 | | - def remove(self, name: str) -> None: |
140 | | - run_command(["docker", "rm", "-f", name], check=False) |
141 | | - |
142 | | - @contextmanager |
143 | | - def container( |
144 | | - self, |
145 | | - *, |
146 | | - env_overrides: dict[str, str] | None = None, |
147 | | - ) -> Iterator["ContainerHandle"]: |
148 | | - suffix = uuid.uuid4().hex[:10] |
149 | | - name = f"aio-template-pytest-{suffix}" |
150 | | - http_port = reserve_host_port() |
151 | | - config_volume = create_docker_volume(f"{name}-config") |
152 | | - data_volume = create_docker_volume(f"{name}-data") |
153 | | - try: |
154 | | - command = [ |
155 | | - "docker", |
156 | | - "run", |
157 | | - "-d", |
158 | | - "--platform", |
159 | | - "linux/amd64", |
160 | | - "--name", |
161 | | - name, |
162 | | - "-p", |
163 | | - f"{http_port}:8080", |
164 | | - "-v", |
165 | | - f"{config_volume}:/config", |
166 | | - "-v", |
167 | | - f"{data_volume}:/data", |
168 | | - ] |
169 | | - |
170 | | - if env_overrides: |
171 | | - for key, value in env_overrides.items(): |
172 | | - command.extend(["-e", f"{key}={value}"]) |
173 | | - |
174 | | - command.append(self.image_tag) |
175 | | - run_command(command) |
176 | | - handle = ContainerHandle( |
177 | | - runtime=self, |
178 | | - name=name, |
179 | | - http_port=http_port, |
180 | | - config_volume=config_volume, |
181 | | - data_volume=data_volume, |
182 | | - ) |
183 | | - try: |
184 | | - yield handle |
185 | | - finally: |
186 | | - self.remove(name) |
187 | | - finally: |
188 | | - remove_docker_volume(config_volume) |
189 | | - remove_docker_volume(data_volume) |
190 | | - |
191 | | - |
192 | | -class ContainerHandle: |
193 | | - def __init__( |
194 | | - self, |
195 | | - *, |
196 | | - runtime: DockerRuntime, |
197 | | - name: str, |
198 | | - http_port: int, |
199 | | - config_volume: str, |
200 | | - data_volume: str, |
201 | | - ) -> None: |
202 | | - self.runtime = runtime |
203 | | - self.name = name |
204 | | - self.http_port = http_port |
205 | | - self.config_volume = config_volume |
206 | | - self.data_volume = data_volume |
207 | | - |
208 | | - def logs(self) -> str: |
209 | | - return self.runtime.logs(self.name) |
210 | | - |
211 | | - def exec( |
212 | | - self, command: str, *, check: bool = True |
213 | | - ) -> subprocess.CompletedProcess[str]: |
214 | | - return docker_exec(self.name, command, check=check) |
215 | | - |
216 | | - def restart(self) -> None: |
217 | | - run_command(["docker", "restart", self.name]) |
218 | | - |
219 | | - def is_running(self) -> bool: |
220 | | - return self.runtime.inspect_state(self.name, "State.Status") == "running" |
221 | | - |
222 | | - def path_exists(self, path: str) -> bool: |
223 | | - return container_path_exists(self.name, path) |
224 | | - |
225 | | - def read_text(self, path: str) -> str: |
226 | | - return read_container_file(self.name, path) |
227 | | - |
228 | | - def file_size(self, path: str) -> int: |
229 | | - return container_file_size(self.name, path) |
230 | | - |
231 | | - def wait_for_http(self, *, path: str = "/health", timeout: int = 180) -> None: |
232 | | - deadline = time.time() + timeout |
233 | | - url = f"http://127.0.0.1:{self.http_port}{path}" |
234 | | - |
235 | | - while time.time() < deadline: |
236 | | - if not self.is_running(): |
237 | | - raise AssertionError( |
238 | | - f"{self.name} stopped before HTTP became healthy.\nLogs:\n{self.logs()}" |
239 | | - ) |
240 | | - |
241 | | - result = run_command(["curl", "-fsS", url], check=False) |
242 | | - if result.returncode == 0: |
243 | | - return |
244 | | - time.sleep(2) |
245 | | - |
246 | | - raise AssertionError( |
247 | | - f"{self.name} did not become healthy.\nLogs:\n{self.logs()}" |
248 | | - ) |
249 | | - |
250 | | - |
251 | | -def pytest_env(base_env: dict[str, str] | None = None) -> dict[str, str]: |
252 | | - env = dict(os.environ if base_env is None else base_env) |
253 | | - env.setdefault("PYTHONUNBUFFERED", "1") |
254 | | - return env |
| 8 | +REPO_ROOT = Path(__file__).resolve().parents[1] |
| 9 | +configure_repo_root(REPO_ROOT) |
0 commit comments