Skip to content

Commit d91f321

Browse files
committed
🔎 Add shell and log probe
1 parent 226d1d3 commit d91f321

13 files changed

Lines changed: 396 additions & 37 deletions

File tree

‎src/kloudkit/testshed/docker/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from kloudkit.testshed.docker.container import Container
44
from kloudkit.testshed.docker.probes.http_probe import HttpProbe
5+
from kloudkit.testshed.docker.probes.log_probe import LogProbe
6+
from kloudkit.testshed.docker.probes.probe import Probe
7+
from kloudkit.testshed.docker.probes.shell_probe import ShellProbe
58
from kloudkit.testshed.docker.volumes.inline_volume import InlineVolume
69
from kloudkit.testshed.docker.volumes.remote_volume import RemoteVolume
710

@@ -12,5 +15,8 @@
1215
"DockerException",
1316
"HttpProbe",
1417
"InlineVolume",
18+
"LogProbe",
19+
"Probe",
1520
"RemoteVolume",
21+
"ShellProbe",
1622
)

‎src/kloudkit/testshed/docker/factory.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from kloudkit.testshed.core.state import ShedState
22
from kloudkit.testshed.docker.container import Container
3-
from kloudkit.testshed.docker.probes.http_probe import HttpProbe
3+
from kloudkit.testshed.docker.probes.probe import Probe
44
from kloudkit.testshed.docker.probes.readiness_check import ReadinessCheck
55
from kloudkit.testshed.docker.runtime.cleanup import Cleanup
66
from kloudkit.testshed.docker.volumes.volume_manager import VolumeManager
@@ -23,7 +23,7 @@ def build(
2323
image: str,
2424
*,
2525
detach: bool = True,
26-
probe: HttpProbe | None = None,
26+
probe: Probe | None = None,
2727
container_class: type[Container] | None = None,
2828
test_name: str | None = None,
2929
**kwargs,

‎src/kloudkit/testshed/docker/probes/http_probe.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
from dataclasses import asdict, dataclass, replace
2-
from typing import Self
2+
from typing import TYPE_CHECKING, Self
3+
4+
from kloudkit.testshed.docker.probes.probe import Probe
5+
6+
7+
if TYPE_CHECKING:
8+
from kloudkit.testshed.docker.container import Container
39

410

511
@dataclass(slots=True)
6-
class HttpProbe:
12+
class HttpProbe(Probe):
713
host: str = "http://localhost"
814
port: int | None = None
915
endpoint: str | None = None
1016
command: str = "curl"
11-
timeout: float = 30.0
1217

1318
@property
1419
def url(self) -> str:
@@ -19,6 +24,17 @@ def url(self) -> str:
1924

2025
return "".join((self.host, port, endpoint))
2126

27+
def check(self, container: "Container") -> None:
28+
"""Single probe attempt. Raise on failure."""
29+
30+
container.execute([*self.command.split(" "), self.url], raises=True)
31+
32+
@property
33+
def failure_message(self) -> str:
34+
"""Message shown on timeout."""
35+
36+
return f"URL [{self.url}] was not reachable within {self.timeout}s"
37+
2238
def merge(self, other: "HttpProbe", *, ignore_none: bool = True) -> Self:
2339
"""Merge two Probes."""
2440

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import re
2+
from dataclasses import dataclass
3+
from typing import TYPE_CHECKING
4+
5+
from kloudkit.testshed.docker.probes.probe import Probe
6+
7+
8+
if TYPE_CHECKING:
9+
from kloudkit.testshed.docker.container import Container
10+
11+
12+
@dataclass(slots=True)
13+
class LogProbe(Probe):
14+
pattern: str = ""
15+
16+
def check(self, container: "Container") -> None:
17+
"""Single probe attempt. Raise on failure."""
18+
19+
logs = container.logs()
20+
if not re.search(self.pattern, logs):
21+
raise ValueError(f"Pattern {self.pattern!r} not found in logs")
22+
23+
@property
24+
def failure_message(self) -> str:
25+
"""Message shown on timeout."""
26+
27+
return (
28+
f"Pattern {self.pattern!r} not found"
29+
f" in container logs within {self.timeout}s"
30+
)
31+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass
3+
from typing import TYPE_CHECKING
4+
5+
6+
if TYPE_CHECKING:
7+
from kloudkit.testshed.docker.container import Container
8+
9+
10+
@dataclass(slots=True)
11+
class Probe(ABC):
12+
timeout: float = 30.0
13+
14+
@abstractmethod
15+
def check(self, container: "Container") -> None:
16+
"""Single probe attempt. Raise on failure."""
17+
18+
@property
19+
@abstractmethod
20+
def failure_message(self) -> str:
21+
"""Message shown on timeout."""
22+
23+
@classmethod
24+
def resolve(
25+
cls,
26+
default: "Probe | None",
27+
user: "Probe | None",
28+
port: int | None = None,
29+
) -> "Probe | None":
30+
"""Resolve a final probe from default + user override + port."""
31+
from kloudkit.testshed.docker.probes.http_probe import HttpProbe
32+
33+
probe = default
34+
35+
if port is not None and isinstance(probe, HttpProbe):
36+
probe = probe.merge(HttpProbe(port=port))
37+
38+
if user is ...:
39+
return probe
40+
41+
if isinstance(probe, HttpProbe) and isinstance(user, HttpProbe):
42+
return probe.merge(user)
43+
44+
return user
Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,35 @@
11
import contextlib
22
import time
33

4-
from python_on_whales.exceptions import DockerException
5-
64
from kloudkit.testshed.docker.container import Container
7-
from kloudkit.testshed.docker.probes.http_probe import HttpProbe
5+
from kloudkit.testshed.docker.probes.probe import Probe
86

97
import pytest
108

119

1210
class ReadinessCheck:
1311
def __init__(
14-
self, container: Container, probe: HttpProbe, *, container_logs=None
12+
self, container: Container, probe: Probe, *, container_logs=None
1513
):
1614
self._container: Container = container
17-
self._probe: HttpProbe = probe
15+
self._probe: Probe = probe
1816
self._container_logs = container_logs
1917

20-
@property
21-
def command(self) -> list[str]:
22-
"""Full probe test command."""
23-
24-
return [*self._probe.command.split(" "), self._probe.url]
25-
2618
def wait(self) -> None:
27-
"""Wait until a container responds on the given endpoint."""
19+
"""Wait until a container passes the probe check."""
2820

2921
deadline = time.time() + self._probe.timeout
3022

31-
failure_message = (
32-
f"URL [{self._probe.url}] was not reachable within {self._probe.timeout}s"
33-
)
34-
3523
while time.time() < deadline:
3624
try:
37-
self._container.execute(self.command, raises=True)
25+
self._probe.check(self._container)
3826

3927
return
40-
except DockerException:
28+
except Exception:
4129
time.sleep(0.1)
4230

4331
if self._container_logs:
4432
with contextlib.suppress(Exception):
4533
self._container_logs(self._container.logs())
4634

47-
pytest.fail(failure_message, pytrace=False)
35+
pytest.fail(self._probe.failure_message, pytrace=False)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from dataclasses import dataclass
2+
from typing import TYPE_CHECKING
3+
4+
from kloudkit.testshed.docker.probes.probe import Probe
5+
6+
7+
if TYPE_CHECKING:
8+
from kloudkit.testshed.docker.container import Container
9+
10+
11+
@dataclass(slots=True)
12+
class ShellProbe(Probe):
13+
command: str | list[str] = ""
14+
15+
def check(self, container: "Container") -> None:
16+
"""Single probe attempt. Raise on failure."""
17+
18+
cmd = self.command if isinstance(self.command, list) else self.command.split()
19+
container.execute(cmd, raises=True)
20+
21+
@property
22+
def failure_message(self) -> str:
23+
"""Message shown on timeout."""
24+
25+
return f"Command {self.command!r} did not succeed within {self.timeout}s"

‎src/kloudkit/testshed/fixtures/shed.py‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from kloudkit.testshed.core.state import ShedState
22
from kloudkit.testshed.docker.container_config import ContainerConfig
3-
from kloudkit.testshed.docker.probes.http_probe import HttpProbe
3+
from kloudkit.testshed.docker.probes.probe import Probe
44

55
import pytest
66

@@ -42,19 +42,16 @@ def shed_factory(shed_tag, docker_sidecar, shed_container_defaults):
4242
"""Callable factory for spinning up containers with configurable defaults."""
4343

4444
def _wrapper(**kwargs):
45-
port = kwargs.pop("port", None)
46-
user_probe = kwargs.pop("probe", ...)
47-
48-
probe = shed_container_defaults.get("probe")
49-
50-
if port is not None and probe:
51-
probe = probe.merge(HttpProbe(port=port))
52-
53-
if user_probe is not ...:
54-
probe = probe.merge(user_probe) if (probe and user_probe) else user_probe
45+
probe = Probe.resolve(
46+
default=shed_container_defaults.get("probe"),
47+
user=kwargs.pop("probe", ...),
48+
port=kwargs.pop("port", None),
49+
)
5550

5651
merged_config = {**shed_container_defaults, **kwargs}
57-
if probe:
52+
merged_config.pop("probe", None)
53+
54+
if probe is not None:
5855
merged_config["probe"] = probe
5956

6057
return docker_sidecar(image=shed_tag, **merged_config)

‎tests/integration/test_docker_sidecar.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from kloudkit.testshed.docker import Container, HttpProbe
1+
from kloudkit.testshed.docker import Container, HttpProbe, LogProbe, ShellProbe
22

33

44
def test_container_creation(docker_sidecar):
@@ -34,3 +34,24 @@ def test_nginx_with_probe(docker_sidecar):
3434

3535
assert isinstance(container, Container)
3636
assert "nginx" in container.execute(["curl", "-s", "http://localhost:80"])
37+
38+
39+
def test_nginx_with_log_probe(docker_sidecar):
40+
container = docker_sidecar(
41+
image="nginx:1.27-alpine",
42+
probe=LogProbe(pattern=r"start worker process", timeout=5.0),
43+
)
44+
45+
assert isinstance(container, Container)
46+
assert container.state.running
47+
48+
49+
def test_alpine_with_shell_probe(docker_sidecar):
50+
container = docker_sidecar(
51+
image="alpine:latest",
52+
command=["sh", "-c", "sleep 1 && touch /tmp/ready && sleep 30"],
53+
probe=ShellProbe(command="test -f /tmp/ready", timeout=5.0),
54+
)
55+
56+
assert isinstance(container, Container)
57+
assert container.execute(["cat", "/tmp/ready"]) == ""

‎tests/unit/docker/probes/test_http_probe.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from unittest.mock import MagicMock
2+
13
from kloudkit.testshed.docker.probes.http_probe import HttpProbe
4+
from kloudkit.testshed.docker.probes.probe import Probe
25

36
import pytest
47

@@ -96,3 +99,37 @@ def test_merge_empty_probe(ignore_none):
9699
else:
97100
assert merged.host == "http://localhost"
98101
assert merged.port is None
102+
103+
104+
def test_inherits_from_probe():
105+
probe = HttpProbe()
106+
107+
assert isinstance(probe, Probe)
108+
109+
110+
def test_check_executes_command():
111+
container = MagicMock()
112+
probe = HttpProbe(port=8080, endpoint="/health")
113+
114+
probe.check(container)
115+
116+
container.execute.assert_called_once_with(
117+
["curl", "http://localhost:8080/health"], raises=True
118+
)
119+
120+
121+
def test_check_raises_on_failure():
122+
container = MagicMock()
123+
container.execute.side_effect = RuntimeError("connection refused")
124+
probe = HttpProbe(port=8080)
125+
126+
with pytest.raises(RuntimeError, match="connection refused"):
127+
probe.check(container)
128+
129+
130+
def test_failure_message():
131+
probe = HttpProbe(port=8080, endpoint="/health", timeout=15.0)
132+
133+
assert probe.failure_message == (
134+
"URL [http://localhost:8080/health] was not reachable within 15.0s"
135+
)

0 commit comments

Comments
 (0)