Skip to content

Commit 5e25e91

Browse files
author
Andrei Neagu
committed
moved container utils
1 parent c7a0af8 commit 5e25e91

File tree

2 files changed

+157
-0
lines changed

2 files changed

+157
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import asyncio
2+
import logging
3+
from collections.abc import Sequence
4+
from typing import Any, Final
5+
6+
from aiodocker import Docker, DockerError
7+
from aiodocker.execs import Exec
8+
from aiodocker.stream import Stream
9+
from common_library.errors_classes import OsparcErrorMixin
10+
from pydantic import NonNegativeFloat
11+
12+
13+
class BaseContainerUtilsError(OsparcErrorMixin, Exception):
14+
pass
15+
16+
17+
class ContainerExecContainerNotFoundError(BaseContainerUtilsError):
18+
msg_template = "Container '{container_name}' was not found"
19+
20+
21+
class ContainerExecTimeoutError(BaseContainerUtilsError):
22+
msg_template = "Timed out after {timeout} while executing: '{command}'"
23+
24+
25+
class ContainerExecCommandFailedError(BaseContainerUtilsError):
26+
msg_template = (
27+
"Command '{command}' exited with code '{exit_code}'"
28+
"and output: '{command_result}'"
29+
)
30+
31+
32+
_HTTP_404_NOT_FOUND: Final[int] = 404
33+
34+
_logger = logging.getLogger(__name__)
35+
36+
37+
async def _execute_command(container_name: str, command: str | Sequence[str]) -> str:
38+
async with Docker() as docker:
39+
container = await docker.containers.get(container_name)
40+
41+
# Start the command inside the container
42+
exec_instance: Exec = await container.exec(
43+
cmd=command, stdout=True, stderr=True, tty=False
44+
)
45+
46+
# Start the execution
47+
stream: Stream = exec_instance.start(detach=False)
48+
49+
command_result: str = ""
50+
async with stream:
51+
while stream_message := await stream.read_out():
52+
command_result += stream_message.data.decode()
53+
54+
inspect_result: dict[str, Any] = await exec_instance.inspect()
55+
exit_code: int | None = inspect_result.get("ExitCode", None)
56+
if exit_code != 0:
57+
raise ContainerExecCommandFailedError(
58+
command=command, exit_code=exit_code, command_result=command_result
59+
)
60+
61+
_logger.debug("Command result:\n$ '%s'\n%s", command, command_result)
62+
return command_result
63+
64+
65+
async def run_command_in_container(
66+
container_name: str,
67+
*,
68+
command: str | Sequence[str],
69+
timeout: NonNegativeFloat = 1.0,
70+
):
71+
"""
72+
Runs `command` in target container and returns the command's output if
73+
command's exit code is 0.
74+
75+
Arguments:
76+
container_name -- name of the container in which to run the command
77+
command -- string or sequence of strings to run as command
78+
79+
Keyword Arguments:
80+
timeout -- max time for the command to return a result in (default: {1.0})
81+
82+
Raises:
83+
ContainerExecTimeoutError: command execution did not finish in time
84+
ContainerExecContainerNotFoundError: target container is not present
85+
ContainerExecCommandFailedError: command finished with not 0 exit code
86+
DockerError: propagates error from docker engine
87+
88+
Returns:
89+
stdout + stderr produced by the command is returned
90+
"""
91+
try:
92+
return await asyncio.wait_for(
93+
_execute_command(container_name, command), timeout
94+
)
95+
except DockerError as e:
96+
if e.status == _HTTP_404_NOT_FOUND:
97+
raise ContainerExecContainerNotFoundError(
98+
container_name=container_name
99+
) from e
100+
raise
101+
except TimeoutError as e:
102+
raise ContainerExecTimeoutError(timeout=timeout, command=command) from e
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# pylint: disable=redefined-outer-name
2+
3+
import contextlib
4+
from collections.abc import AsyncIterable
5+
6+
import aiodocker
7+
import pytest
8+
from servicelib.container_utils import (
9+
ContainerExecCommandFailedError,
10+
ContainerExecContainerNotFoundError,
11+
ContainerExecTimeoutError,
12+
run_command_in_container,
13+
)
14+
15+
16+
@pytest.fixture
17+
async def running_container_name() -> AsyncIterable[str]:
18+
async with aiodocker.Docker() as client:
19+
container = await client.containers.run(
20+
config={
21+
"Image": "alpine:latest",
22+
"Cmd": ["/bin/ash", "-c", "sleep 10000"],
23+
}
24+
)
25+
container_inspect = await container.show()
26+
27+
yield container_inspect["Name"][1:]
28+
29+
with contextlib.suppress(aiodocker.DockerError):
30+
await container.kill()
31+
await container.delete()
32+
33+
34+
async def test_run_command_in_container_container_not_found():
35+
with pytest.raises(ContainerExecContainerNotFoundError):
36+
await run_command_in_container("missing_container", command="")
37+
38+
39+
async def test_run_command_in_container_command_timed_out(running_container_name: str):
40+
with pytest.raises(ContainerExecTimeoutError):
41+
await run_command_in_container(
42+
running_container_name, command="sleep 10", timeout=0.1
43+
)
44+
45+
46+
async def test_run_command_in_container_none_zero_exit_code(
47+
running_container_name: str,
48+
):
49+
with pytest.raises(ContainerExecCommandFailedError):
50+
await run_command_in_container(running_container_name, command="exit 1")
51+
52+
53+
async def test_run_command_in_container(running_container_name: str):
54+
result = await run_command_in_container(running_container_name, command="ls -lah")
55+
assert len(result) > 0

0 commit comments

Comments
 (0)