testcontainers-understudy 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Testcontainers module for understudy, the local AWS emulator.
|
|
2
|
+
|
|
3
|
+
``UnderstudyContainer`` publishes port 4566 on 127.0.0.1 only, waits for
|
|
4
|
+
``GET /_understudy/v1/health`` to answer 200 with ``"ready": true``, labels
|
|
5
|
+
every container understudy starts with the session's Ryuk filter label, and
|
|
6
|
+
stops with a 10 s grace so understudy removes its own containers first
|
|
7
|
+
(testcontainers.md 8).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.metadata
|
|
13
|
+
import ipaddress
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import stat
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from docker.errors import NotFound
|
|
27
|
+
from docker.transport import UnixHTTPAdapter
|
|
28
|
+
from docker.types import Mount
|
|
29
|
+
from testcontainers.core.config import testcontainers_config
|
|
30
|
+
from testcontainers.core.container import DockerContainer
|
|
31
|
+
from testcontainers.core.docker_client import get_docker_host
|
|
32
|
+
from testcontainers.core.exceptions import ContainerStartException
|
|
33
|
+
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
|
|
34
|
+
from testcontainers.core.wait_strategies import HttpWaitStrategy
|
|
35
|
+
from testcontainers.core.waiting_utils import WaitStrategyTarget
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DEFAULT_IMAGE",
|
|
39
|
+
"UnderstudyAdminError",
|
|
40
|
+
"UnderstudyConfigError",
|
|
41
|
+
"UnderstudyContainer",
|
|
42
|
+
"UnderstudyDockerError",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# pyproject.toml is the one place the version is written (scripts/bump-version).
|
|
47
|
+
__version__ = importlib.metadata.version("testcontainers-understudy")
|
|
48
|
+
|
|
49
|
+
# The release image of the understudy version this package is released
|
|
50
|
+
# with, in lockstep (testcontainers.md 8.8).
|
|
51
|
+
DEFAULT_IMAGE = f"ghcr.io/sustinbebustin/understudy:{__version__}"
|
|
52
|
+
|
|
53
|
+
# understudy's listener in the container (testcontainers.md 3.2).
|
|
54
|
+
_API_PORT = 4566
|
|
55
|
+
_API_PORT_KEY = f"{_API_PORT}/tcp"
|
|
56
|
+
_ADMIN_PREFIX = "/_understudy/v1"
|
|
57
|
+
# The wait's bound, though understudy is ready in milliseconds: it bounds only
|
|
58
|
+
# image pulls and failures. tc-py's own default is 120 s (testcontainers.md 8.3).
|
|
59
|
+
_STARTUP_TIMEOUT_SECONDS = 60
|
|
60
|
+
_POLL_INTERVAL_SECONDS = 0.1
|
|
61
|
+
# tc-py's stop is a force remove with no drain; understudy needs the grace to
|
|
62
|
+
# remove its own containers (testcontainers.md 8.6, finding 4).
|
|
63
|
+
_STOP_GRACE_SECONDS = 10
|
|
64
|
+
# How long an admin call may take beyond the time it asks understudy to wait.
|
|
65
|
+
_ADMIN_CALL_SECONDS = 30.0
|
|
66
|
+
_LOOPBACK = ipaddress.ip_address("127.0.0.1")
|
|
67
|
+
_SOCKET_TARGET = "/var/run/docker.sock"
|
|
68
|
+
_SNAPSHOT_PATH = "/snapshot/archive"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UnderstudyConfigError(ValueError):
|
|
72
|
+
"""A module setting refused before any container is created (testcontainers.md 9.5)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UnderstudyDockerError(RuntimeError):
|
|
76
|
+
"""A mounted Docker socket understudy reports it cannot use after start (testcontainers.md 8.5)."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, status: str, reason: str) -> None:
|
|
79
|
+
self.status = status
|
|
80
|
+
self.reason = reason
|
|
81
|
+
super().__init__(
|
|
82
|
+
f"understudy: the Docker socket is mounted but understudy reports Docker {status}: {reason}; "
|
|
83
|
+
"Lambda and DynamoDB will not run. Check the socket group (with_docker_socket(gid=...)) "
|
|
84
|
+
"or run without with_docker_socket()."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UnderstudyAdminError(RuntimeError):
|
|
89
|
+
"""An admin API call understudy answered outside 2xx, with its error body."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, status: int, code: str, message: str, hint: str = "") -> None:
|
|
92
|
+
self.status = status
|
|
93
|
+
self.code = code
|
|
94
|
+
self.message = message
|
|
95
|
+
self.hint = hint
|
|
96
|
+
text = f"understudy: admin API answered {status} {code}: {message}"
|
|
97
|
+
super().__init__(f"{text}; {hint}" if hint else text)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class _DockerSocket:
|
|
102
|
+
"""A with_docker_socket request: the group to add, or None to read the local socket's."""
|
|
103
|
+
|
|
104
|
+
gid: int | None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class _SocketMount:
|
|
109
|
+
"""A resolved with_docker_socket request: the daemon-side socket path and its numeric group."""
|
|
110
|
+
|
|
111
|
+
source: str
|
|
112
|
+
gid: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class UnderstudyContainer(DockerContainer):
|
|
116
|
+
"""understudy in a container, configured by the with_* methods below.
|
|
117
|
+
|
|
118
|
+
``start()`` refuses, before creating anything, publishing beyond loopback
|
|
119
|
+
without ``with_admin_token()`` and a Docker socket whose group it cannot
|
|
120
|
+
read; both raise UnderstudyConfigError. Argument shapes are checked when
|
|
121
|
+
each option is called, so option order never changes the outcome
|
|
122
|
+
(testcontainers.md 9.5).
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, image: str = DEFAULT_IMAGE, **kwargs: Any) -> None:
|
|
126
|
+
super().__init__(image=image, **kwargs)
|
|
127
|
+
self._token: str | None = None
|
|
128
|
+
self._published_on: ipaddress.IPv4Address | ipaddress.IPv6Address = _LOOPBACK
|
|
129
|
+
self._socket: _DockerSocket | None = None
|
|
130
|
+
# Set by start() once the socket request is resolved, for _configure().
|
|
131
|
+
self._mount: _SocketMount | None = None
|
|
132
|
+
|
|
133
|
+
def with_docker_socket(self, gid: int | None = None) -> UnderstudyContainer:
|
|
134
|
+
"""Mount the daemon's Docker socket at /var/run/docker.sock and add its numeric group.
|
|
135
|
+
|
|
136
|
+
Lambda and DynamoDB Local need it (testcontainers.md 8.5). Without
|
|
137
|
+
``gid``, start() reads the group of the local unix socket the Docker
|
|
138
|
+
client uses, and refuses any other daemon. A group name cannot be
|
|
139
|
+
given: the image has no /etc/group to resolve it. A later call
|
|
140
|
+
without ``gid`` keeps a group given earlier. After start, a socket
|
|
141
|
+
understudy does not report enabled raises UnderstudyDockerError.
|
|
142
|
+
"""
|
|
143
|
+
if gid is not None and (isinstance(gid, bool) or not isinstance(gid, int) or gid < 0):
|
|
144
|
+
msg = f"understudy: the Docker socket group must be a non-negative integer group ID, not {gid!r}."
|
|
145
|
+
raise UnderstudyConfigError(msg)
|
|
146
|
+
if gid is not None or self._socket is None:
|
|
147
|
+
self._socket = _DockerSocket(gid)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def with_clock(self, *, frozen_at: datetime | None = None, scale: float | None = None) -> UnderstudyContainer:
|
|
151
|
+
"""Freeze the emulator clock at ``frozen_at``, or scale it by ``scale``.
|
|
152
|
+
|
|
153
|
+
Sets UNDERSTUDY_CLOCK=frozen@<RFC 3339> and UNDERSTUDY_CLOCK_SCALE
|
|
154
|
+
(testcontainers.md 8.2). understudy checks the scale's range and
|
|
155
|
+
refuses a frozen clock with a scale itself; start() then raises with
|
|
156
|
+
the container's log (testcontainers.md 9.5).
|
|
157
|
+
"""
|
|
158
|
+
if frozen_at is not None and frozen_at.utcoffset() is None:
|
|
159
|
+
msg = (
|
|
160
|
+
"understudy: frozen_at must be a timezone-aware datetime, "
|
|
161
|
+
"such as datetime(2026, 1, 1, tzinfo=timezone.utc)."
|
|
162
|
+
)
|
|
163
|
+
raise UnderstudyConfigError(msg)
|
|
164
|
+
if frozen_at is not None:
|
|
165
|
+
self.with_env("UNDERSTUDY_CLOCK", "frozen@" + frozen_at.isoformat())
|
|
166
|
+
if scale is not None:
|
|
167
|
+
self.with_env("UNDERSTUDY_CLOCK_SCALE", str(scale))
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def with_seed(self, seed: int) -> UnderstudyContainer:
|
|
171
|
+
"""Set UNDERSTUDY_SEED; understudy checks its range itself (testcontainers.md 9.5)."""
|
|
172
|
+
self.with_env("UNDERSTUDY_SEED", str(seed))
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def with_sigv4_verify(self) -> UnderstudyContainer:
|
|
176
|
+
"""Set UNDERSTUDY_SIGV4=verify, so understudy checks every request signature."""
|
|
177
|
+
self.with_env("UNDERSTUDY_SIGV4", "verify")
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def with_snapshot(self, path: str | os.PathLike[str]) -> UnderstudyContainer:
|
|
181
|
+
"""Load the snapshot archive at ``path`` at start.
|
|
182
|
+
|
|
183
|
+
The file is copied to /snapshot/archive in the container, mode 0644,
|
|
184
|
+
and UNDERSTUDY_LOAD_SNAPSHOT names it (testcontainers.md 8.2). A
|
|
185
|
+
missing file raises FileNotFoundError here, before any container.
|
|
186
|
+
"""
|
|
187
|
+
archive = Path(path).absolute()
|
|
188
|
+
if stat.S_ISDIR(archive.stat().st_mode):
|
|
189
|
+
msg = (
|
|
190
|
+
f"understudy: with_snapshot({os.fspath(path)!r}) names a directory; "
|
|
191
|
+
"pass the archive file, such as one POST /_understudy/v1/snapshots answered with."
|
|
192
|
+
)
|
|
193
|
+
raise UnderstudyConfigError(msg)
|
|
194
|
+
self._transferable_specs = [spec for spec in self._transferable_specs if spec[1] != _SNAPSHOT_PATH]
|
|
195
|
+
self.with_copy_into_container(archive, _SNAPSHOT_PATH, 0o644)
|
|
196
|
+
self.with_env("UNDERSTUDY_LOAD_SNAPSHOT", _SNAPSHOT_PATH)
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def with_admin_token(self) -> UnderstudyContainer:
|
|
200
|
+
"""Guard the admin API with a random token the wait and the helpers send.
|
|
201
|
+
|
|
202
|
+
Sets UNDERSTUDY_ADMIN_TOKEN to 64 hex characters from 32 random bytes
|
|
203
|
+
and UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API=true, which understudy
|
|
204
|
+
requires beside the token; health then answers 401 without it
|
|
205
|
+
(testcontainers.md 8.2, 3.3).
|
|
206
|
+
"""
|
|
207
|
+
self._token = secrets.token_hex(32)
|
|
208
|
+
self.with_env("UNDERSTUDY_ADMIN_TOKEN", self._token)
|
|
209
|
+
self.with_env("UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API", "true")
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def with_published_on(self, addr: str) -> UnderstudyContainer:
|
|
213
|
+
"""Publish port 4566 on ``addr`` instead of 127.0.0.1.
|
|
214
|
+
|
|
215
|
+
An address beyond loopback, 0.0.0.0 and :: included, needs
|
|
216
|
+
with_admin_token(), or start() refuses it (testcontainers.md 8.4).
|
|
217
|
+
Any other loopback address is refused here: the endpoint and the
|
|
218
|
+
wait dial the daemon's host, 127.0.0.1 for a local daemon, so they
|
|
219
|
+
could never reach it (testcontainers.md 8.2, 9.5).
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
published_on = ipaddress.ip_address(addr)
|
|
223
|
+
except ValueError:
|
|
224
|
+
msg = f"understudy: with_published_on({addr!r}) is not an IP address; pass one such as 127.0.0.1."
|
|
225
|
+
raise UnderstudyConfigError(msg) from None
|
|
226
|
+
if published_on.is_loopback and published_on != _LOOPBACK:
|
|
227
|
+
msg = (
|
|
228
|
+
f"understudy: with_published_on({addr!r}) is a loopback address the endpoint never dials: the "
|
|
229
|
+
"endpoint and the readiness wait reach port 4566 at the Docker host's address, 127.0.0.1 for a "
|
|
230
|
+
"local daemon; publish on 127.0.0.1, or on 0.0.0.0 with with_admin_token()."
|
|
231
|
+
)
|
|
232
|
+
raise UnderstudyConfigError(msg)
|
|
233
|
+
self._published_on = published_on
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def start(self) -> UnderstudyContainer:
|
|
237
|
+
"""Start understudy and return once it reports ready (testcontainers.md 8.3).
|
|
238
|
+
|
|
239
|
+
A container that exits first raises ContainerStartException with its
|
|
240
|
+
log. With the Docker socket, a socket understudy does not report
|
|
241
|
+
enabled raises UnderstudyDockerError. Whatever fails once the
|
|
242
|
+
container exists, start() stops and removes it before raising, so an
|
|
243
|
+
explicit start() outside a with block leaves nothing behind.
|
|
244
|
+
"""
|
|
245
|
+
self._check_exposure()
|
|
246
|
+
self._mount = self._resolve_socket()
|
|
247
|
+
try:
|
|
248
|
+
super().start()
|
|
249
|
+
if self._mount is not None:
|
|
250
|
+
self._check_docker()
|
|
251
|
+
except BaseException:
|
|
252
|
+
self.stop()
|
|
253
|
+
raise
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def stop(self, force: bool = True, delete_volume: bool = True) -> None:
|
|
257
|
+
"""Stop with a 10 s grace, then remove the container and its volumes.
|
|
258
|
+
|
|
259
|
+
The grace lets understudy remove the containers it started before it
|
|
260
|
+
exits (testcontainers.md 8.6). A container already gone counts as
|
|
261
|
+
removed.
|
|
262
|
+
"""
|
|
263
|
+
container = self._container
|
|
264
|
+
if container is not None:
|
|
265
|
+
try:
|
|
266
|
+
container.stop(timeout=_STOP_GRACE_SECONDS)
|
|
267
|
+
container.remove(force=force, v=delete_volume)
|
|
268
|
+
except NotFound:
|
|
269
|
+
pass
|
|
270
|
+
self._container = None
|
|
271
|
+
self.get_docker_client().client.close()
|
|
272
|
+
|
|
273
|
+
def get_endpoint(self) -> str:
|
|
274
|
+
"""``http://<host>:<mapped 4566>``, with localhost as 127.0.0.1 (testcontainers.md 8.2)."""
|
|
275
|
+
return _endpoint_url(self.get_container_host_ip(), self.get_exposed_port(_API_PORT))
|
|
276
|
+
|
|
277
|
+
def get_env(self) -> dict[str, str]:
|
|
278
|
+
"""The environment that points an SDK at the container, as understudytest.Env (testcontainers.md 3.8)."""
|
|
279
|
+
return {
|
|
280
|
+
"AWS_ENDPOINT_URL": self.get_endpoint(),
|
|
281
|
+
"AWS_ACCESS_KEY_ID": "test",
|
|
282
|
+
"AWS_SECRET_ACCESS_KEY": "test",
|
|
283
|
+
"AWS_REGION": "us-east-1",
|
|
284
|
+
"AWS_DEFAULT_REGION": "us-east-1",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def settle(self, timeout: float) -> None:
|
|
288
|
+
"""Wait up to ``timeout`` seconds for understudy's tracked work to finish.
|
|
289
|
+
|
|
290
|
+
Sent as whole milliseconds to GET /_understudy/v1/settle
|
|
291
|
+
(testcontainers.md 3.7); understudy answers 412 condition_not_met on
|
|
292
|
+
timeout (admin.ConditionNotMet in admin/errors.go).
|
|
293
|
+
"""
|
|
294
|
+
self._call("GET", f"/settle?timeout={int(timeout * 1000)}ms", timeout=max(timeout, 0) + _ADMIN_CALL_SECONDS)
|
|
295
|
+
|
|
296
|
+
def advance_clock(self, by: timedelta) -> None:
|
|
297
|
+
"""Move the emulator clock forward by ``by``, sent in whole microseconds (testcontainers.md 3.7)."""
|
|
298
|
+
self._call("POST", "/clock", {"op": "advance", "by": f"{by // timedelta(microseconds=1)}us"})
|
|
299
|
+
|
|
300
|
+
def reset(self) -> None:
|
|
301
|
+
"""Drop all service state, keeping the clock and the fault rules (testcontainers.md 3.7)."""
|
|
302
|
+
self._call("POST", "/reset", {})
|
|
303
|
+
|
|
304
|
+
def _configure(self) -> None:
|
|
305
|
+
# The module owns port 4566's binding; a bare "4566" key from ports=
|
|
306
|
+
# or with_exposed_ports would otherwise publish it on every interface.
|
|
307
|
+
self.ports.pop(str(_API_PORT), None)
|
|
308
|
+
# SAFETY: docker-py binds a (host_ip, host_port) tuple as HostIp and
|
|
309
|
+
# HostPort, and tc-py passes self.ports to it unchanged, but annotates
|
|
310
|
+
# the values as Optional[int] (testcontainers.md 5.3, 8.4).
|
|
311
|
+
self.ports[_API_PORT_KEY] = (str(self._published_on), 0) # type: ignore[assignment]
|
|
312
|
+
self._wait_strategy = _ReadyWait(self._token)
|
|
313
|
+
# The session's Ryuk filter label goes on every container understudy
|
|
314
|
+
# starts, so Ryuk reaps them too; a value the user set wins
|
|
315
|
+
# (testcontainers.md 8.6, 5.4, 9.3).
|
|
316
|
+
if "UNDERSTUDY_DOCKER_LABELS" not in self.env:
|
|
317
|
+
self.env["UNDERSTUDY_DOCKER_LABELS"] = json.dumps({LABEL_SESSION_ID: SESSION_ID})
|
|
318
|
+
if self._mount is not None:
|
|
319
|
+
self._kwargs = {**self._kwargs, **self._mount_kwargs(self._mount)}
|
|
320
|
+
|
|
321
|
+
def _mount_kwargs(self, mount: _SocketMount) -> dict[str, list[Any]]:
|
|
322
|
+
"""The user's mounts and groups with the socket's appended, never replaced.
|
|
323
|
+
|
|
324
|
+
A bind mount, unlike a volume binding, fails on a missing source
|
|
325
|
+
instead of creating a root-owned directory (testcontainers.md 3.4).
|
|
326
|
+
"""
|
|
327
|
+
socket = Mount(target=_SOCKET_TARGET, source=mount.source, type="bind")
|
|
328
|
+
mounts = list(self._kwargs.get("mounts") or [])
|
|
329
|
+
if socket not in mounts:
|
|
330
|
+
mounts.append(socket)
|
|
331
|
+
groups = list(self._kwargs.get("group_add") or [])
|
|
332
|
+
if str(mount.gid) not in [str(g) for g in groups]:
|
|
333
|
+
groups.append(str(mount.gid))
|
|
334
|
+
return {"mounts": mounts, "group_add": groups}
|
|
335
|
+
|
|
336
|
+
def _check_exposure(self) -> None:
|
|
337
|
+
"""Refuse publishing beyond loopback without the token (testcontainers.md 8.4, 9.5)."""
|
|
338
|
+
if not self._published_on.is_loopback and self._token is None:
|
|
339
|
+
msg = (
|
|
340
|
+
f"understudy: publishing port 4566 on {self._published_on} exposes the admin API, which has no "
|
|
341
|
+
"token in the container; add with_admin_token() or publish on 127.0.0.1."
|
|
342
|
+
)
|
|
343
|
+
raise UnderstudyConfigError(msg)
|
|
344
|
+
|
|
345
|
+
def _resolve_socket(self) -> _SocketMount | None:
|
|
346
|
+
"""The socket to mount and its group, or None without with_docker_socket (testcontainers.md 8.5).
|
|
347
|
+
|
|
348
|
+
The source is the path tc-py mounts into Ryuk. The group is the one
|
|
349
|
+
given, else 0 on Docker Desktop, whose VM socket is root:root 0660,
|
|
350
|
+
else the st_gid of the local unix socket the client uses.
|
|
351
|
+
"""
|
|
352
|
+
if self._socket is None:
|
|
353
|
+
return None
|
|
354
|
+
gid = self._socket.gid
|
|
355
|
+
if gid is None:
|
|
356
|
+
gid = self._local_socket_gid()
|
|
357
|
+
return _SocketMount(source=testcontainers_config.ryuk_docker_socket, gid=gid)
|
|
358
|
+
|
|
359
|
+
def _local_socket_gid(self) -> int:
|
|
360
|
+
api = self.get_docker_client().client.api
|
|
361
|
+
adapter = api.get_adapter(api.base_url)
|
|
362
|
+
if not isinstance(adapter, UnixHTTPAdapter):
|
|
363
|
+
daemon = get_docker_host() or api.base_url
|
|
364
|
+
msg = (
|
|
365
|
+
f"understudy: cannot read the group of the Docker socket on {daemon}, which is not a local unix "
|
|
366
|
+
"socket; pass with_docker_socket(gid=<gid>), the numeric group owning /var/run/docker.sock on the "
|
|
367
|
+
"daemon's host."
|
|
368
|
+
)
|
|
369
|
+
raise UnderstudyConfigError(msg)
|
|
370
|
+
# Docker Desktop mounts its VM's socket, not the host's (TC-01).
|
|
371
|
+
if "Docker Desktop" in self.get_docker_client().client.info().get("OperatingSystem", ""):
|
|
372
|
+
return 0
|
|
373
|
+
try:
|
|
374
|
+
return os.stat(adapter.socket_path).st_gid
|
|
375
|
+
except OSError as e:
|
|
376
|
+
msg = (
|
|
377
|
+
f"understudy: cannot read the group of the Docker socket {adapter.socket_path}: {e.strerror}; "
|
|
378
|
+
"pass with_docker_socket(gid=<gid>), the numeric group owning the socket."
|
|
379
|
+
)
|
|
380
|
+
raise UnderstudyConfigError(msg) from e
|
|
381
|
+
|
|
382
|
+
def _check_docker(self) -> None:
|
|
383
|
+
"""Raise unless understudy reports Docker enabled: a mounted socket never fails silently.
|
|
384
|
+
|
|
385
|
+
(testcontainers.md 8.5, 9.7)
|
|
386
|
+
"""
|
|
387
|
+
status, reason = _docker_state(self._call("GET", "/health"))
|
|
388
|
+
if status != "enabled":
|
|
389
|
+
raise UnderstudyDockerError(status, reason)
|
|
390
|
+
|
|
391
|
+
def _call(
|
|
392
|
+
self, method: str, path: str, body: dict[str, str] | None = None, timeout: float = _ADMIN_CALL_SECONDS
|
|
393
|
+
) -> object:
|
|
394
|
+
"""One admin API call, with the token when set; the decoded JSON answer, or None when empty."""
|
|
395
|
+
data = None if body is None else json.dumps(body).encode()
|
|
396
|
+
req = urllib.request.Request(self.get_endpoint() + _ADMIN_PREFIX + path, data=data, method=method)
|
|
397
|
+
if data is not None:
|
|
398
|
+
req.add_header("Content-Type", "application/json")
|
|
399
|
+
if self._token is not None:
|
|
400
|
+
req.add_header("Authorization", f"Bearer {self._token}")
|
|
401
|
+
try:
|
|
402
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
403
|
+
raw = resp.read()
|
|
404
|
+
except urllib.error.HTTPError as e:
|
|
405
|
+
raise _admin_error(e.code, e.read()) from None
|
|
406
|
+
return json.loads(raw) if raw else None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class _ReadyWait(HttpWaitStrategy):
|
|
410
|
+
"""GET /_understudy/v1/health until it answers 200 with "ready": true (testcontainers.md 8.3).
|
|
411
|
+
|
|
412
|
+
Unlike HttpWaitStrategy, it checks the container on every poll, so an
|
|
413
|
+
understudy that refuses its settings and exits fails the start at once,
|
|
414
|
+
with its log, instead of after the timeout (testcontainers.md 5.2, 9.5).
|
|
415
|
+
Never the image HEALTHCHECK, an open port or a sleep (testcontainers.md 9.7).
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
def __init__(self, token: str | None) -> None:
|
|
419
|
+
super().__init__(_API_PORT, _ADMIN_PREFIX + "/health")
|
|
420
|
+
self.for_response_predicate(_reports_ready)
|
|
421
|
+
self.with_poll_interval(_POLL_INTERVAL_SECONDS)
|
|
422
|
+
self.with_startup_timeout(_STARTUP_TIMEOUT_SECONDS)
|
|
423
|
+
if token is not None:
|
|
424
|
+
self.with_header("Authorization", f"Bearer {token}")
|
|
425
|
+
|
|
426
|
+
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
|
|
427
|
+
deadline = time.monotonic() + self._startup_timeout
|
|
428
|
+
headers = self._setup_headers()
|
|
429
|
+
url: str | None = None
|
|
430
|
+
while True:
|
|
431
|
+
_raise_if_exited(container)
|
|
432
|
+
if url is None:
|
|
433
|
+
url = self._health_url(container)
|
|
434
|
+
if url is not None and self._try_http_request(url, headers, None):
|
|
435
|
+
return
|
|
436
|
+
if time.monotonic() >= deadline:
|
|
437
|
+
msg = (
|
|
438
|
+
f"understudy: not ready within {self._startup_timeout:g} s: GET {url or self._path} never "
|
|
439
|
+
'answered 200 with "ready": true. start() removes the container; tc-py attaches its log to '
|
|
440
|
+
"this error as a note (Python 3.11 and later)."
|
|
441
|
+
)
|
|
442
|
+
raise TimeoutError(msg)
|
|
443
|
+
time.sleep(self._poll_interval)
|
|
444
|
+
|
|
445
|
+
def _health_url(self, container: WaitStrategyTarget) -> str | None:
|
|
446
|
+
"""The health URL, or None while the port mapping is not readable yet."""
|
|
447
|
+
try:
|
|
448
|
+
host = container.get_container_host_ip()
|
|
449
|
+
port = container.get_exposed_port(self._port)
|
|
450
|
+
except (TimeoutError, ConnectionError):
|
|
451
|
+
# The container left the running state between the exit check and
|
|
452
|
+
# the port lookup; the next poll's exit check reports it.
|
|
453
|
+
return None
|
|
454
|
+
return _endpoint_url(host, port) + self._path
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _raise_if_exited(container: WaitStrategyTarget) -> None:
|
|
458
|
+
"""Raise ContainerStartException, with the container's log, once it is no longer running."""
|
|
459
|
+
wrapped = container.get_wrapped_container()
|
|
460
|
+
wrapped.reload()
|
|
461
|
+
if wrapped.status in ("created", "running"):
|
|
462
|
+
return
|
|
463
|
+
code = wrapped.attrs.get("State", {}).get("ExitCode", "unknown")
|
|
464
|
+
log = wrapped.logs().decode(errors="replace")
|
|
465
|
+
msg = (
|
|
466
|
+
f"understudy: the container stopped ({wrapped.status}, exit code {code}) before it reported ready; "
|
|
467
|
+
f"understudy refused its settings or could not start. Its log:\n{log}"
|
|
468
|
+
)
|
|
469
|
+
raise ContainerStartException(msg)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _reports_ready(body: str) -> bool:
|
|
473
|
+
try:
|
|
474
|
+
health = json.loads(body)
|
|
475
|
+
except ValueError:
|
|
476
|
+
return False
|
|
477
|
+
return isinstance(health, dict) and health.get("ready") is True
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _docker_state(health: object) -> tuple[str, str]:
|
|
481
|
+
"""Docker's status and reason in a health answer (testcontainers.md 3.1)."""
|
|
482
|
+
docker = health.get("docker") if isinstance(health, dict) else None
|
|
483
|
+
if not isinstance(docker, dict):
|
|
484
|
+
return "unreported", "the health answer has no docker field"
|
|
485
|
+
return str(docker.get("status", "")), str(docker.get("reason", ""))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _endpoint_url(host: str, port: int) -> str:
|
|
489
|
+
"""Join a daemon host, as tc-py reports it, and a port; an IPv6 host is bracketed."""
|
|
490
|
+
host = host.removeprefix("[").removesuffix("]")
|
|
491
|
+
if host == "localhost":
|
|
492
|
+
# SDKs address S3 path style only at an IP endpoint (testcontainers.md 8.2).
|
|
493
|
+
host = str(_LOOPBACK)
|
|
494
|
+
if ":" in host:
|
|
495
|
+
host = f"[{host}]"
|
|
496
|
+
return f"http://{host}:{port}"
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _admin_error(status: int, raw: bytes) -> UnderstudyAdminError:
|
|
500
|
+
"""The error for a non-2xx admin answer: its envelope's fields, or the body text when it has none.
|
|
501
|
+
|
|
502
|
+
The envelope is {"error": {"code", "message", "hint"}} (ARCHITECTURE.md 17).
|
|
503
|
+
"""
|
|
504
|
+
text = raw.decode(errors="replace")
|
|
505
|
+
try:
|
|
506
|
+
doc = json.loads(text)
|
|
507
|
+
except ValueError:
|
|
508
|
+
doc = None
|
|
509
|
+
err = doc.get("error") if isinstance(doc, dict) else None
|
|
510
|
+
if isinstance(err, dict) and isinstance(err.get("code"), str) and isinstance(err.get("message"), str):
|
|
511
|
+
hint = err.get("hint")
|
|
512
|
+
return UnderstudyAdminError(status, err["code"], err["message"], hint if isinstance(hint, str) else "")
|
|
513
|
+
return UnderstudyAdminError(status, "", text)
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: testcontainers-understudy
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Testcontainers module for understudy, the local AWS emulator
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Dist: testcontainers>=4.15,<5
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Project-URL: Homepage, https://github.com/sustinbebustin/understudy/tree/main/testcontainers/python
|
|
9
|
+
Project-URL: Source, https://github.com/sustinbebustin/understudy
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# testcontainers-understudy
|
|
13
|
+
|
|
14
|
+
Runs [understudy](https://github.com/sustinbebustin/understudy), the local AWS emulator, as a [Testcontainers for Python](https://testcontainers-python.readthedocs.io/) module.
|
|
15
|
+
|
|
16
|
+
It is released with understudy: version `X.Y.Z` of this package defaults to the image `ghcr.io/sustinbebustin/understudy:X.Y.Z`. Nothing is published yet, so build the image with `just image`, pass `understudy:dev`, and install the package from a clone, here at `../understudy`:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv add --dev ../understudy/testcontainers/python
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It needs Python 3.10 or later and `testcontainers>=4.15,<5`.
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import boto3
|
|
28
|
+
import pytest
|
|
29
|
+
from testcontainers_understudy import UnderstudyContainer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(scope="session")
|
|
33
|
+
def understudy():
|
|
34
|
+
with UnderstudyContainer("understudy:dev") as u:
|
|
35
|
+
yield u
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(autouse=True)
|
|
39
|
+
def aws_env(understudy, monkeypatch):
|
|
40
|
+
for name, value in understudy.get_env().items(): # AWS_ENDPOINT_URL, test credentials, us-east-1
|
|
41
|
+
monkeypatch.setenv(name, value)
|
|
42
|
+
yield
|
|
43
|
+
understudy.reset()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_orders_queue():
|
|
47
|
+
sqs = boto3.client("sqs")
|
|
48
|
+
url = sqs.create_queue(QueueName="orders")["QueueUrl"]
|
|
49
|
+
sqs.send_message(QueueUrl=url, MessageBody="order-1")
|
|
50
|
+
assert [m["Body"] for m in sqs.receive_message(QueueUrl=url)["Messages"]] == ["order-1"]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`start()` publishes port 4566 on `127.0.0.1` only and returns once `GET /_understudy/v1/health` answers 200 with `"ready": true`; if understudy exits first, it raises with understudy's log. A `start()` that raises once the container exists has already stopped and removed it. `stop()`, and leaving the `with` block, stops understudy with a 10-second grace period, which lets it remove the Lambda and DynamoDB Local containers it started, and every such container carries the session's label, so Ryuk removes them too.
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
| Method | Effect |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `with_docker_socket()` | mounts the Docker socket and adds its group (0 on Docker Desktop, whose VM owns the socket), for Lambda and DynamoDB Local; `start()` raises `UnderstudyDockerError` unless understudy then reports Docker enabled |
|
|
60
|
+
| `with_docker_socket(gid=n)` | the same, with the group given, for a daemon that is not a local unix socket |
|
|
61
|
+
| `with_clock(frozen_at=dt, scale=f)` | the emulator clock; `frozen_at` must be timezone-aware |
|
|
62
|
+
| `with_seed(n)` | repeatable generated identifiers |
|
|
63
|
+
| `with_sigv4_verify()` | verify request signatures |
|
|
64
|
+
| `with_snapshot(path)` | load a snapshot archive at start |
|
|
65
|
+
| `with_admin_token()` | a random admin token, sent by the wait and the helpers |
|
|
66
|
+
| `with_published_on(addr)` | publish on another address; beyond loopback only with `with_admin_token()`; a loopback address other than `127.0.0.1` is refused, since the endpoint never dials it |
|
|
67
|
+
|
|
68
|
+
`with_env` sets any other `UNDERSTUDY_*` setting. `settle(timeout)`, `advance_clock(by)` and `reset()` wrap the common admin calls and raise `UnderstudyAdminError` on an error answer. Misconfigurations raise `UnderstudyConfigError`, before any container is created. The exception is a missing snapshot archive: `with_snapshot` raises `FileNotFoundError` for it.
|
|
69
|
+
|
|
70
|
+
The [Testcontainers guide](https://github.com/sustinbebustin/understudy/blob/main/docs/guide/testcontainers-modules.md) covers Lambda, Terraform and cleanup in more detail.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
just image
|
|
76
|
+
testcontainers/python/run.sh # uv sync, ruff check and format check, sealed pytest against understudy:dev
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`UNDERSTUDY_IMAGE` selects another image; extra arguments go to pytest.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
testcontainers_understudy/__init__.py,sha256=SnDo-rXrU_VCaP2MTSXzaFWcO88Tcof9xlaOfPgi8ps,23278
|
|
2
|
+
testcontainers_understudy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
testcontainers_understudy-0.1.2.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
4
|
+
testcontainers_understudy-0.1.2.dist-info/METADATA,sha256=PbWVmWbROsydebFoX-ELcmoScCub9lnsg79yO0Zksss,4004
|
|
5
|
+
testcontainers_understudy-0.1.2.dist-info/RECORD,,
|