Skip to content

Commit d07e50a

Browse files
feat: reusable containers
adresses testcontainers#109
1 parent e93bc29 commit d07e50a

File tree

4 files changed

+159
-7
lines changed

4 files changed

+159
-7
lines changed

core/testcontainers/core/config.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ def read_tc_properties() -> dict[str, str]:
3939
return settings
4040

4141

42-
_WARNINGS = {"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566"}
42+
_WARNINGS = {
43+
"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566",
44+
"tc_properties_get_tc_host": "this method has moved to property 'tc_properties_tc_host'",
45+
}
4346

4447

4548
@dataclass
@@ -73,8 +76,19 @@ def docker_auth_config(self, value: str):
7376
self._docker_auth_config = value
7477

7578
def tc_properties_get_tc_host(self) -> Union[str, None]:
79+
if "tc_properties_get_tc_host" in _WARNINGS:
80+
warning(_WARNINGS.pop("tc_properties_get_tc_host"))
7681
return self.tc_properties.get("tc.host")
7782

83+
@property
84+
def tc_properties_tc_host(self) -> Union[str, None]:
85+
return self.tc_properties.get("tc.host")
86+
87+
@property
88+
def tc_properties_testcontainers_reuse_enable(self) -> bool:
89+
enabled = self.tc_properties.get("testcontainers.reuse.enable")
90+
return enabled == "true"
91+
7892
@property
7993
def timeout(self):
8094
return self.max_tries * self.sleep_time

core/testcontainers/core/container.py

+54-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import contextlib
2+
import hashlib
3+
import logging
24
from platform import system
35
from socket import socket
46
from typing import TYPE_CHECKING, Optional
@@ -49,6 +51,7 @@ def __init__(
4951
self._name = None
5052
self._network: Optional[Network] = None
5153
self._network_aliases: Optional[list[str]] = None
54+
self._reuse: bool = False
5255
self._kwargs = kwargs
5356

5457
def with_env(self, key: str, value: str) -> Self:
@@ -76,6 +79,10 @@ def with_kwargs(self, **kwargs) -> Self:
7679
self._kwargs = kwargs
7780
return self
7881

82+
def with_reuse(self, reuse=True) -> Self:
83+
self._reuse = reuse
84+
return self
85+
7986
def maybe_emulate_amd64(self) -> Self:
8087
if is_arm():
8188
return self.with_kwargs(platform="linux/amd64")
@@ -86,8 +93,49 @@ def start(self) -> Self:
8693
logger.debug("Creating Ryuk container")
8794
Reaper.get_instance()
8895
logger.info("Pulling image %s", self.image)
89-
docker_client = self.get_docker_client()
9096
self._configure()
97+
98+
# container hash consisting of run arguments
99+
args = (
100+
self.image,
101+
self._command,
102+
self.env,
103+
self.ports,
104+
self._name,
105+
self.volumes,
106+
str(tuple(sorted(self._kwargs.items()))),
107+
)
108+
hash_ = hashlib.sha256(bytes(str(args), encoding="utf-8")).hexdigest()
109+
110+
# TODO: check also if ryuk is disabled
111+
if self._reuse and not c.tc_properties_testcontainers_reuse_enable:
112+
logging.warning(
113+
"Reuse was requested (`with_reuse`) but the environment does not "
114+
+ "support the reuse of containers. To enable container reuse, add "
115+
+ "the property 'testcontainers.reuse.enable=true' to a file at "
116+
+ "~/.testcontainers.properties (you may need to create it)."
117+
)
118+
119+
if self._reuse and c.tc_properties_testcontainers_reuse_enable:
120+
docker_client = self.get_docker_client()
121+
container = docker_client.find_container_by_hash(hash_)
122+
if container:
123+
if container.status != "running":
124+
container.start()
125+
logger.info("Existing container started: %s", container.id)
126+
logger.info("Container is already running: %s", container.id)
127+
self._container = container
128+
else:
129+
self._start(hash_)
130+
else:
131+
self._start(hash_)
132+
133+
if self._network:
134+
self._network.connect(self._container.id, self._network_aliases)
135+
return self
136+
137+
def _start(self, hash_):
138+
docker_client = self.get_docker_client()
91139
self._container = docker_client.run(
92140
self.image,
93141
command=self._command,
@@ -96,16 +144,17 @@ def start(self) -> Self:
96144
ports=self.ports,
97145
name=self._name,
98146
volumes=self.volumes,
147+
labels={"hash": hash_},
99148
**self._kwargs,
100149
)
101150
logger.info("Container started: %s", self._container.short_id)
102-
if self._network:
103-
self._network.connect(self._container.id, self._network_aliases)
104-
return self
105151

106152
def stop(self, force=True, delete_volume=True) -> None:
107153
if self._container:
108-
self._container.remove(force=force, v=delete_volume)
154+
if self._reuse and c.tc_properties_testcontainers_reuse_enable:
155+
self._container.stop()
156+
else:
157+
self._container.remove(force=force, v=delete_volume)
109158
self.get_docker_client().client.close()
110159

111160
def __enter__(self) -> Self:

core/testcontainers/core/docker_client.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,15 @@ def client_networks_create(self, name: str, param: dict):
215215
labels = create_labels("", param.get("labels"))
216216
return self.client.networks.create(name, **{**param, "labels": labels})
217217

218+
def find_container_by_hash(self, hash_: str) -> Container | None:
219+
for container in self.client.containers.list(all=True):
220+
if container.labels.get("hash", None) == hash_:
221+
return container
222+
return None
223+
218224

219225
def get_docker_host() -> Optional[str]:
220-
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
226+
return c.tc_properties_tc_host or os.getenv("DOCKER_HOST")
221227

222228

223229
def get_docker_auth_config() -> Optional[str]:
+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from time import sleep
2+
3+
from docker.models.containers import Container
4+
5+
from testcontainers.core.config import testcontainers_config
6+
from testcontainers.core.container import DockerContainer
7+
from testcontainers.core.docker_client import DockerClient
8+
from testcontainers.core.waiting_utils import wait_for_logs
9+
from testcontainers.core.container import Reaper
10+
11+
12+
def test_docker_container_reuse_default():
13+
with DockerContainer("hello-world") as container:
14+
assert container._reuse == False
15+
id = container._container.id
16+
wait_for_logs(container, "Hello from Docker!")
17+
containers = DockerClient().client.containers.list(all=True)
18+
assert id not in [container.id for container in containers]
19+
20+
21+
def test_docker_container_with_reuse_reuse_disabled():
22+
with DockerContainer("hello-world").with_reuse() as container:
23+
assert container._reuse == True
24+
id = container._container.id
25+
wait_for_logs(container, "Hello from Docker!")
26+
containers = DockerClient().client.containers.list(all=True)
27+
assert id not in [container.id for container in containers]
28+
29+
30+
def test_docker_container_with_reuse_reuse_enabled_ryuk_enabled(monkeypatch):
31+
# Make sure Ryuk cleanup is not active from previous test runs
32+
Reaper.delete_instance()
33+
tc_properties_mock = testcontainers_config.tc_properties | {"testcontainers.reuse.enable": "true"}
34+
monkeypatch.setattr(testcontainers_config, "tc_properties", tc_properties_mock)
35+
monkeypatch.setattr(testcontainers_config, "ryuk_reconnection_timeout", "0.1s")
36+
37+
with DockerContainer("hello-world").with_reuse() as container:
38+
id = container._container.id
39+
wait_for_logs(container, "Hello from Docker!")
40+
41+
Reaper._socket.close()
42+
# Sleep until Ryuk reaps all dangling containers
43+
sleep(0.6)
44+
45+
containers = DockerClient().client.containers.list(all=True)
46+
assert id not in [container.id for container in containers]
47+
48+
# Cleanup Ryuk class fields after manual Ryuk shutdown
49+
Reaper.delete_instance()
50+
51+
52+
def test_docker_container_with_reuse_reuse_enabled_ryuk_disabled(monkeypatch):
53+
# Make sure Ryuk cleanup is not active from previous test runs
54+
Reaper.delete_instance()
55+
tc_properties_mock = testcontainers_config.tc_properties | {"testcontainers.reuse.enable": "true"}
56+
monkeypatch.setattr(testcontainers_config, "tc_properties", tc_properties_mock)
57+
monkeypatch.setattr(testcontainers_config, "ryuk_disabled", True)
58+
with DockerContainer("hello-world").with_reuse() as container:
59+
assert container._reuse == True
60+
id = container._container.id
61+
wait_for_logs(container, "Hello from Docker!")
62+
containers = DockerClient().client.containers.list(all=True)
63+
assert id in [container.id for container in containers]
64+
# Cleanup after keeping container alive (with_reuse)
65+
container._container.remove(force=True)
66+
67+
68+
def test_docker_container_labels_hash():
69+
expected_hash = "91fde3c09244e1d3ec6f18a225b9261396b9a1cb0f6365b39b9795782817c128"
70+
with DockerContainer("hello-world").with_reuse() as container:
71+
assert container._container.labels["hash"] == expected_hash
72+
73+
74+
def test_docker_client_find_container_by_hash_not_existing():
75+
with DockerContainer("hello-world"):
76+
assert DockerClient().find_container_by_hash("foo") == None
77+
78+
79+
def test_docker_client_find_container_by_hash_existing():
80+
with DockerContainer("hello-world").with_reuse() as container:
81+
hash_ = container._container.labels["hash"]
82+
found_container = DockerClient().find_container_by_hash(hash_)
83+
assert isinstance(found_container, Container)

0 commit comments

Comments
 (0)