Skip to content

Commit c8937e3

Browse files
authored
fix: Bug in volume error handling (#272)
#### Relevant issue or PR n/a #### Description of changes Ensure error is raised correctly if a passed volume path does not exist. #### Testing done manual
1 parent 25cab15 commit c8937e3

File tree

4 files changed

+116
-33
lines changed

4 files changed

+116
-33
lines changed

tesseract_core/sdk/engine.py

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
Container,
3939
ContainerError,
4040
Image,
41+
NotFound,
4142
build_docker_image,
4243
is_podman,
4344
)
@@ -721,9 +722,12 @@ def serve(
721722
compose_file.flush()
722723

723724
project_name = f"tesseract-{_id_generator()}"
724-
if not docker_client.compose.up(compose_file.name, project_name):
725-
raise RuntimeError("Cannot serve Tesseracts")
726-
return project_name
725+
try:
726+
docker_client.compose.up(compose_file.name, project_name)
727+
except APIError as exc:
728+
raise RuntimeError("Cannot serve Tesseracts") from exc
729+
730+
return project_name
727731

728732

729733
def _create_docker_compose_template(
@@ -802,27 +806,23 @@ def _create_docker_compose_template(
802806

803807
services.append(service)
804808

805-
docker_volumes = {} # Dictionary of volume names mapped to whether or not they already exist
806-
if volumes:
807-
for volume in volumes:
808-
source = volume.split(":")[0]
809-
# Check if source exists to determine if specified volume is a docker volume
810-
if not Path(source).exists():
811-
# Check if volume exists
812-
if not docker_client.volumes.get(source):
813-
if "/" not in source:
814-
docker_volumes[source] = False
815-
else:
816-
raise ValueError(
817-
f"Volume/Path {source} does not already exist, "
818-
"and new volume cannot be created due to '/' in name."
819-
)
820-
else:
821-
# Docker volume is external
822-
docker_volumes[source] = True
809+
external_volume_map = {}
810+
for source in parsed_volumes:
811+
# Check if the volume is a local bind-mount
812+
if _is_local_volume(source):
813+
continue
814+
815+
# Check if the volume is an existing Docker volume
816+
try:
817+
docker_client.volumes.get(source)
818+
except NotFound:
819+
external_volume_map[source] = False
820+
else:
821+
# If the volume exists, it is an external Docker volume
822+
external_volume_map[source] = True
823823

824824
template = ENV.get_template("docker-compose.yml")
825-
return template.render(services=services, docker_volumes=docker_volumes)
825+
return template.render(services=services, named_volumes=external_volume_map)
826826

827827

828828
def _id_generator(
@@ -832,6 +832,11 @@ def _id_generator(
832832
return "".join(random.choice(chars) for _ in range(size))
833833

834834

835+
def _is_local_volume(volume: str) -> bool:
836+
"""Check if a volume is a local path."""
837+
return "/" in volume or "." in volume
838+
839+
835840
def _parse_volumes(options: list[str]) -> dict[str, dict[str, str]]:
836841
"""Parses volume mount strings to dict accepted by docker SDK.
837842
@@ -852,8 +857,12 @@ def _parse_option(option: str):
852857
"(must be `/path/to/source:/path/totarget:(ro|rw)`)",
853858
)
854859

855-
is_local_mount = "/" in source or Path(source).exists()
856-
if is_local_mount:
860+
if _is_local_volume(source):
861+
if not Path(source).exists():
862+
raise RuntimeError(
863+
f"Source path {source} does not exist, "
864+
"please provide a valid local path."
865+
)
857866
# Docker doesn't like paths like ".", so we convert to absolute path here
858867
source = str(Path(source).resolve())
859868
return source, {"bind": target, "mode": mode}

tesseract_core/sdk/templates/docker-compose.yml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,11 @@ services:
4242
{% endif %}
4343
{% endfor %}
4444

45-
46-
{%- if docker_volumes %}
45+
{%- if named_volumes %}
4746
volumes:
48-
{%- for name, external in docker_volumes.items() %}
47+
{%- for name, external in named_volumes.items() %}
4948
{{ name }}:
50-
{%- if external %}
51-
external: true
52-
{% endif %}
49+
external: {{ external }}
5350
{% endfor %}
5451
{% endif %}
5552

tests/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ def wait(self, **kwargs: Any):
334334
"""Mock wait method for Container."""
335335
return {"StatusCode": 0, "Error": None}
336336

337+
@property
338+
def name(self):
339+
"""Mock name property for Container."""
340+
return json.dumps(self.return_args)
341+
337342
@property
338343
def attrs(self):
339344
"""Mock attrs method for Container."""
@@ -363,6 +368,26 @@ def info() -> tuple:
363368
"""Mock info method for DockerClient."""
364369
return "", ""
365370

371+
class volumes:
372+
"""Mock of CLIDockerClient.volumes."""
373+
374+
@staticmethod
375+
def create(name: str) -> Any:
376+
"""Mock of CLIDockerClient.volumes.create."""
377+
return {"Name": name}
378+
379+
@staticmethod
380+
def get(name: str) -> Any:
381+
"""Mock of CLIDockerClient.volumes.get."""
382+
if "/" in name:
383+
raise NotFound(f"Volume {name} not found")
384+
return {"Name": name}
385+
386+
@staticmethod
387+
def list() -> list[Any]:
388+
"""Mock of CLIDockerClient.volumes.list."""
389+
return [{"Name": "test_volume"}]
390+
366391
class images:
367392
"""Mock of CLIDockerClient.images."""
368393

@@ -451,4 +476,12 @@ def exists(project_id: str) -> bool:
451476
tesseract_core.sdk.docker_client, "CLIDockerClient", MockedDocker
452477
)
453478

479+
def hacked_get(url, *args, **kwargs):
480+
if url.endswith("/health"):
481+
# Simulate a successful health check
482+
return type("Response", (), {"status_code": 200, "json": lambda: {}})()
483+
raise NotImplementedError(f"Mocked get request to {url} not implemented")
484+
485+
engine.requests.get = hacked_get
486+
454487
yield mock_instance

tests/sdk_tests/test_engine.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,11 @@ def test_run_tesseract_file_input(mocked_docker, tmpdir):
182182
"foobar",
183183
"apply",
184184
[f"@{infile}", "--output-path", str(outdir)],
185-
volumes=["/path/on/host:/path/in/container:ro"],
185+
volumes=[f"{tmpdir}:/path/in/container:ro"],
186186
)
187187
res = json.loads(res)
188-
assert res["volumes"].keys() == {str(infile), str(outdir), "/path/on/host"}
189-
assert res["volumes"]["/path/on/host"] == {
188+
assert res["volumes"].keys() == {str(infile), str(outdir), f"{tmpdir}"}
189+
assert res["volumes"][f"{tmpdir}"] == {
190190
"mode": "ro",
191191
"bind": "/path/in/container",
192192
}
@@ -286,6 +286,50 @@ def test_serve_tesseracts(mocked_docker):
286286
assert project_name_multi_tesseract
287287

288288

289+
@pytest.mark.parametrize("no_compose", [True, False])
290+
def test_serve_tesseract_volumes(mocked_docker, tmpdir, no_compose):
291+
"""Test running a tesseract with volumes."""
292+
# Test with a single volume
293+
res = engine.serve(
294+
["foobar"],
295+
volumes=[f"{tmpdir}:/path/in/container:ro"],
296+
no_compose=no_compose,
297+
)
298+
299+
if no_compose:
300+
# Currently no good way to test return value of serve with no_compose=True
301+
# since it returns a project ID.
302+
res = json.loads(res)
303+
assert res["volumes"].keys() == {f"{tmpdir}"}
304+
assert res["volumes"][f"{tmpdir}"] == {
305+
"mode": "ro",
306+
"bind": "/path/in/container",
307+
}
308+
309+
# Test with a named volume
310+
res = engine.serve(
311+
["foobar"],
312+
volumes=["my_named_volume:/path/in/container:ro"],
313+
no_compose=no_compose,
314+
)
315+
316+
if no_compose:
317+
res = json.loads(res)
318+
assert res["volumes"].keys() == {"my_named_volume"}
319+
assert res["volumes"]["my_named_volume"] == {
320+
"mode": "ro",
321+
"bind": "/path/in/container",
322+
}
323+
324+
with pytest.raises(RuntimeError):
325+
# Test with a volume that does not exist
326+
engine.serve(
327+
["foobar"],
328+
volumes=["/non/existent/path:/path/in/container:ro"],
329+
no_compose=no_compose,
330+
)
331+
332+
289333
def test_needs_docker(mocked_docker, monkeypatch):
290334
@engine.needs_docker
291335
def run_something_with_docker():

0 commit comments

Comments
 (0)