Skip to content

feat: Add volume to docker client and volume permission testing #241

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions tesseract_core/sdk/docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ def run(
image: str,
command: list_[str],
volumes: dict | None = None,
user: str | None = None,
device_requests: list_[int | str] | None = None,
detach: bool = False,
remove: bool = False,
Expand All @@ -519,6 +520,7 @@ def run(
image: The image name or id to run the command in.
command: The command to run in the container.
volumes: A dict of volumes to mount in the container.
user: String of user information to run command as in the format "uid:(optional)gid".
device_requests: A list of device requests for the container.
detach: If True, run the container in detached mode. Detach must be set to
True if we wish to retrieve the container id of the running container,
Expand Down Expand Up @@ -556,6 +558,9 @@ def run(
)
optional_args.extend(volume_args)

if user:
optional_args.extend(["-u", user])

if device_requests:
gpus_str = ",".join(device_requests)
optional_args.extend(["--gpus", f'"device={gpus_str}"'])
Expand Down Expand Up @@ -774,6 +779,116 @@ def _update_projects(include_stopped: bool = False) -> dict[str, list_[str]]:
return project_container_map


@dataclass
class Volume:
"""Volume class to wrap Docker volumes."""

name: str
attrs: dict

@classmethod
def from_dict(cls, json_dict: dict) -> "Volume":
"""Create an Image object from a json dictionary.

Params:
json_dict: The json dictionary to create the object from.

Returns:
The created volume object.
"""
return cls(
name=json_dict.get("Name", None),
attrs=json_dict,
)

def remove(self, force: bool = False) -> None:
"""Remove a Docker volume.

Params:
force: If True, force the removal of the volume.
"""
docker = _get_executable("docker")
try:
_ = subprocess.run(
[*docker, "volume", "rm", "--force" if force else "", self.name],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as ex:
raise NotFound(f"Error removing volume {self.name}: {ex}") from ex


class Volumes:
"""Volume class to wrap Docker volumes."""

@staticmethod
def create(name: str) -> Volume:
"""Create a Docker volume.

Params:
name: The name of the volume to create.

Returns:
The created volume object.
"""
docker = _get_executable("docker")
try:
_ = subprocess.run(
[*docker, "volume", "create", "--name", name],
check=True,
capture_output=True,
text=True,
)
return Volumes.get(name)
except subprocess.CalledProcessError as ex:
raise NotFound(f"Error creating volume {name}: {ex}") from ex

@staticmethod
def get(name: str) -> Volume:
"""Get a Docker volume.

Params:
name: The name of the volume to get.

Returns:
The volume object.
"""
docker = _get_executable("docker")
try:
result = subprocess.run(
[*docker, "volume", "inspect", name],
check=True,
capture_output=True,
text=True,
)
json_dict = json.loads(result.stdout)
except subprocess.CalledProcessError as ex:
raise NotFound(f"Volume {name} not found: {ex}") from ex
if not json_dict:
raise NotFound(f"Volume {name} not found.")
return Volume.from_dict(json_dict[0])

@staticmethod
def list() -> list[str]:
"""List all Docker volumes.

Returns:
List of volume names.
"""
docker = _get_executable("docker")
try:
result = subprocess.run(
[*docker, "volume", "ls", "-q"],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as ex:
raise APIError(f"Error listing volumes: {ex}") from ex
return result.stdout.strip().split("\n")


class DockerException(Exception):
"""Base class for Docker CLI exceptions."""

Expand Down Expand Up @@ -847,6 +962,7 @@ def __init__(self) -> None:
self.containers = Containers()
self.images = Images()
self.compose = Compose()
self.volumes = Volumes()

@staticmethod
def info() -> tuple:
Expand Down
14 changes: 13 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def docker_cleanup(docker_client, request):
def _docker_cleanup(docker_client, request):
"""Clean up all tesseracts created by the tests."""
# Shared object to track what objects need to be cleaned up in each test
context = {"images": [], "project_ids": [], "containers": []}
context = {"images": [], "project_ids": [], "containers": [], "volumes": []}

def pprint_exc(e: BaseException) -> str:
"""Pretty print exception."""
Expand Down Expand Up @@ -268,6 +268,18 @@ def cleanup_func():
except Exception as e:
failures.append(f"Failed to remove image {image}: {pprint_exc(e)}")

# Remove volumes
for volume in context["volumes"]:
try:
if isinstance(volume, str):
volume_obj = docker_client.volumes.get(volume)
else:
volume_obj = volume

volume_obj.remove(force=True)
except Exception as e:
failures.append(f"Failed to remove volume {volume}: {pprint_exc(e)}")

if failures:
raise RuntimeError(
"Failed to clean up some Docker objects during test teardown:\n"
Expand Down
87 changes: 87 additions & 0 deletions tests/endtoend_tests/test_docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from tesseract_core.sdk.docker_client import (
APIError,
ContainerError,
ImageNotFound,
build_docker_image,
)
Expand Down Expand Up @@ -47,6 +48,21 @@ def docker_client_built_image_name(
pass


@pytest.fixture(scope="module")
def docker_volume(docker_client):
# Create the Docker volume
volume = docker_client.volumes.create(name="docker_client_test_volume")
try:
yield volume
finally:
pass
# try:
# volume.remove()
# except NotFound:
# # already removed
# pass


def test_get_image(docker_client, docker_client_built_image_name, docker_py_client):
"""Test image retrieval."""

Expand Down Expand Up @@ -396,3 +412,74 @@ def test_compose_error(docker_client, tmp_path, docker_client_built_image_name):
docker_client.compose.up(str(compose_file), "docker_client_compose_test")
# Check that the container's logs were printed to stderr
assert "Failed to start Tesseract container" in str(e.value)


def test_volume_permissions(
docker_client, docker_client_built_image_name, docker_volume, docker_cleanup
):
# Set up root only directory
def run_tesseract_with_volume(cmd: str, user: str = "root:root", volume: str = ""):
if not volume:
volume = {docker_volume.name: {"bind": "/bar", "mode": "rw"}}
return docker_client.containers.run(
docker_client_built_image_name,
[cmd],
remove=True,
user=user,
volumes=volume,
)

cmd = "mkdir -p /bar && echo hello > /bar/hello.txt && chmod 700 /bar"
_ = run_tesseract_with_volume(cmd)

# Try to read the file as UID 1000
read_cmd = "cat /bar/hello.txt"
with pytest.raises(ContainerError) as e:
_ = run_tesseract_with_volume(read_cmd, user="1000:1000")
assert "Permission denied" in str(e)

# Try to write to the folder as UID 1000
write_cmd = "echo hello > /bar/hello_1000.txt && cat /bar/hello_1000.txt"
with pytest.raises(ContainerError) as e:
_ = run_tesseract_with_volume(write_cmd, user="1000:1000")
assert "Permission denied" in str(e)

# Grant permission to the file
cmd = "chmod 777 /bar"
_ = run_tesseract_with_volume(cmd)

# Try to read the file as UID 1000 again
stdout = run_tesseract_with_volume(read_cmd, user="1000:1000")
assert stdout == b"hello\n"

# Try to write to the folder as UID 1000 again
stdout = run_tesseract_with_volume(write_cmd, user="1000:1000")
assert stdout == b"hello\n"

# Try to copy a file from a volume with permissions 700 to a volume with permission 777
volume_777 = docker_client.volumes.create(name="docker_client_test_volume_777")
docker_cleanup["volumes"].append(volume_777)
volume_args = {
docker_volume.name: {"bind": "/from", "mode": "rw"},
volume_777.name: {"bind": "/to", "mode": "rw"},
}
cmd = "chmod 700 /from && cp /from/hello.txt /to/hello.txt && cat /to/hello.txt"
stdout = run_tesseract_with_volume(cmd, volume=volume_args)
assert stdout == b"hello\n"

# Try to access files as UID1000
cmd = "cat /to/hello.txt"
stdout = run_tesseract_with_volume(cmd, user="1000:1000", volume=volume_args)
assert stdout == b"hello\n"

with pytest.raises(ContainerError) as e:
run_tesseract_with_volume(
"cat /from/hello_777.txt", user="1000:1000", volume=volume_args
)
assert "Permission denied" in str(e)

cmd = (
"adduser -D testuser && chmod 777 /from && su - testuser && cat /from/hello.txt"
)
stdout = run_tesseract_with_volume(cmd, volume=volume_args)
assert stdout == b"hello\n"
Loading