diff --git a/python/gvtest/__init__.py b/python/gvtest/__init__.py index b3f2c3c..2e9ab83 100644 --- a/python/gvtest/__init__.py +++ b/python/gvtest/__init__.py @@ -6,6 +6,7 @@ from gvtest.runner import Runner from gvtest.targets import Target from gvtest.config import ConfigLoader + from gvtest.container import ContainerConfig """ from __future__ import annotations diff --git a/python/gvtest/config.py b/python/gvtest/config.py index 5578d59..dfb8baa 100644 --- a/python/gvtest/config.py +++ b/python/gvtest/config.py @@ -151,7 +151,7 @@ def validate_config(self, config: Dict, config_file: Path) -> None: return # Empty config is valid # Check for unknown keys - known_keys = {'python_paths', 'targets'} + known_keys = {'python_paths', 'targets', 'container'} unknown_keys = set(config.keys()) - known_keys if unknown_keys: logger.warning( @@ -175,6 +175,19 @@ def validate_config(self, config: Dict, config_file: Path) -> None: f"expected a string, got {type(path).__name__}" ) + # Validate container if present + if 'container' in config: + container = config['container'] + if not isinstance(container, dict): + raise RuntimeError( + f"Invalid 'container' in {config_file}: " + f"expected a mapping, got {type(container).__name__}" + ) + if 'image' not in container: + raise RuntimeError( + f"'container' in {config_file} requires 'image'" + ) + # Validate targets if present if 'targets' in config: targets = config['targets'] @@ -324,6 +337,57 @@ def get_targets(self) -> Dict[str, Dict]: self.config_files = self.discover_configs() return self.resolve_targets(self.config_files) + def resolve_container( + self, config_files: List[Path] + ) -> Optional[Dict]: + """ + Resolve container configuration from the hierarchy. + + The most specific (leaf-most) config that defines a + ``container`` section wins. Container configs are NOT + merged across levels — the leaf definition fully + overrides any parent. + + Args: + config_files: Config files in root → leaf order. + + Returns: + Container config dict, or None if no config + defines a container section. + """ + result: Optional[Dict] = None + + for config_file in config_files: + try: + config = self.load_config(config_file) + self.validate_config(config, config_file) + + if 'container' in config: + result = config['container'] + logger.debug( + f"Container config from " + f"{config_file}: {result}" + ) + except Exception as e: + logger.error( + f"Error processing {config_file}: {e}" + ) + raise + + return result + + def get_container(self) -> Optional[Dict]: + """ + Discover and resolve container config from + gvtest.yaml hierarchy. + + Returns: + Container config dict, or None. + """ + if not self.config_files: + self.config_files = self.discover_configs() + return self.resolve_container(self.config_files) + def merge_configs(self, config_files: List[Path]) -> List[str]: """ Load and merge all config files, collecting python_paths. @@ -425,6 +489,25 @@ def load_and_apply(self) -> int: return len(sys.path) - initial_len +def get_container_for_dir(directory: str) -> Optional[Dict]: + """ + Get container configuration for a specific directory. + + Discovers gvtest.yaml files from the specified directory + up to the filesystem root and returns the most specific + container config found. + + Args: + directory: Directory to start config discovery from. + + Returns: + Container config dict, or None. + """ + loader = ConfigLoader(directory) + loader.config_files = loader.discover_configs() + return loader.resolve_container(loader.config_files) + + def get_python_paths_for_dir(directory: str) -> List[str]: """ Get python paths for a specific directory without modifying sys.path. diff --git a/python/gvtest/container.py b/python/gvtest/container.py new file mode 100644 index 0000000..04b5c5c --- /dev/null +++ b/python/gvtest/container.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2025 ETH Zurich, University of Bologna +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Container execution backend for gvtest. + +Wraps shell commands in ``docker run`` (or ``podman run``) +invocations so that tests execute inside a container while +operating on the host filesystem via transparent bind mounts. + +The container sees the same absolute paths as the host, so +build artifacts, test outputs, and logs are directly +accessible from either side. +""" + +from __future__ import annotations + +import logging +import os +import shutil +from typing import Any + +logger = logging.getLogger(__name__) + + +class ContainerConfig: + """Immutable container execution configuration. + + Attributes: + image: Container image name (e.g. + ``ghcr.io/pulp-platform/deeploy:devel``). + runtime: Container runtime command + (``docker`` or ``podman``). Default: ``docker``. + volumes: Extra bind-mount mappings + ``{host_path: container_path}``. + The test working directory is always mounted + transparently (same path) and does not need + to be listed here. + env: Extra environment variables passed to the + container. + options: Additional flags forwarded verbatim to + ``docker run`` (e.g. ``['--gpus', 'all']``). + setup: Optional shell snippet executed inside the + container before the actual command + (e.g. ``pip install -e .``). + workdir: Override the working directory inside the + container. When *None* the test's own ``path`` + is used (which is already bind-mounted + transparently). + """ + + def __init__( + self, + image: str, + runtime: str = 'docker', + volumes: dict[str, str] | None = None, + env: dict[str, str] | None = None, + options: list[str] | None = None, + setup: str | None = None, + workdir: str | None = None, + ) -> None: + self.image: str = image + self.runtime: str = runtime + self.volumes: dict[str, str] = dict( + volumes or {} + ) + self.env: dict[str, str] = dict(env or {}) + self.options: list[str] = list(options or []) + self.setup: str | None = setup + self.workdir: str | None = workdir + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ContainerConfig: + """Create a ContainerConfig from a parsed YAML/dict. + + Expected keys (all optional except ``image``): + + .. code-block:: yaml + + container: + image: ghcr.io/org/image:tag + runtime: docker # or podman + volumes: + /host/path: /container/path + env: + KEY: value + options: + - --gpus + - all + setup: pip install -e . + workdir: /app + """ + if not isinstance(data, dict): + raise ValueError( + f"container config must be a mapping, " + f"got {type(data).__name__}" + ) + image = data.get('image') + if not image: + raise ValueError( + "container config requires 'image'" + ) + return cls( + image=image, + runtime=data.get('runtime', 'docker'), + volumes=data.get('volumes'), + env=data.get('env'), + options=data.get('options'), + setup=data.get('setup'), + workdir=data.get('workdir'), + ) + + def build_run_cmd( + self, + inner_cmd: str, + cwd: str | None = None, + extra_env: dict[str, str] | None = None, + extra_volumes: dict[str, str] | None = None, + ) -> list[str]: + """Build a full ``docker run`` command list. + + Args: + inner_cmd: The shell command to run inside + the container. + cwd: Working directory on the host. Mounted + transparently and used as ``-w``. + extra_env: Per-invocation env vars (merged + with config-level env). + extra_volumes: Per-invocation extra mounts. + + Returns: + Command list suitable for ``subprocess.Popen``. + """ + cmd: list[str] = [self.runtime, 'run', '--rm'] + + # ── Transparent mount of cwd ─────────────────── + effective_cwd = cwd or os.getcwd() + effective_cwd = os.path.realpath(effective_cwd) + mounted_paths: set[str] = set() + + cmd += ['-v', f'{effective_cwd}:{effective_cwd}'] + mounted_paths.add(effective_cwd) + + # ── Explicit volumes ─────────────────────────── + all_volumes = dict(self.volumes) + if extra_volumes: + all_volumes.update(extra_volumes) + + for host_path, container_path in all_volumes.items(): + host_real = os.path.realpath(host_path) + # Skip if already covered by the cwd mount + if host_real == effective_cwd: + continue + # Skip if it's a sub-path of cwd (already visible) + if host_real.startswith(effective_cwd + '/'): + continue + if host_real not in mounted_paths: + cmd += [ + '-v', + f'{host_real}:{container_path}' + ] + mounted_paths.add(host_real) + + # ── Working directory ────────────────────────── + workdir = self.workdir or effective_cwd + cmd += ['-w', workdir] + + # ── Environment variables ────────────────────── + all_env = dict(self.env) + if extra_env: + all_env.update(extra_env) + for key, value in all_env.items(): + cmd += ['-e', f'{key}={value}'] + + # ── Extra options ────────────────────────────── + cmd += self.options + + # ── Image ────────────────────────────────────── + cmd.append(self.image) + + # ── Inner command ────────────────────────────── + if self.setup: + full_cmd = f'{self.setup} && {inner_cmd}' + else: + full_cmd = inner_cmd + + cmd += ['bash', '-c', full_cmd] + + return cmd + + def validate(self) -> None: + """Check that the container runtime is available. + + Raises: + RuntimeError: If the runtime binary is not + found in ``$PATH``. + """ + if shutil.which(self.runtime) is None: + raise RuntimeError( + f"Container runtime '{self.runtime}' " + f"not found in PATH" + ) + + def __repr__(self) -> str: + return ( + f"ContainerConfig(image={self.image!r}, " + f"runtime={self.runtime!r})" + ) diff --git a/python/gvtest/pytest_integration.py b/python/gvtest/pytest_integration.py index ce5aea3..392da62 100644 --- a/python/gvtest/pytest_integration.py +++ b/python/gvtest/pytest_integration.py @@ -39,6 +39,7 @@ from rich.console import Console +from gvtest.container import ContainerConfig from gvtest.tests import TestCommon, TestRun _console = Console(highlight=False) @@ -58,28 +59,50 @@ def _build_pytest_cmd( def discover_pytest_tests( - path: str, pytest_exe: str = 'pytest' + path: str, pytest_exe: str = 'pytest', + container: ContainerConfig | None = None, ) -> tuple[list[str], str]: """Run pytest --collect-only to discover test node IDs. Args: path: Directory or file to discover tests in. pytest_exe: Pytest executable name/path. + container: Optional container config. When set, + discovery runs inside the container. Returns: Tuple of (node_ids, resolved_exe). The resolved_exe may differ from pytest_exe if a fallback was used. """ - cmd = _build_pytest_cmd( + pytest_args = _build_pytest_cmd( pytest_exe, ['--collect-only', '-q', path] ) + cwd = os.path.dirname(path) or '.' + + if container is not None: + # Run discovery inside the container + inner_cmd = ' '.join(pytest_args) + cmd = container.build_run_cmd( + inner_cmd=inner_cmd, cwd=cwd + ) + use_shell = False + else: + cmd = pytest_args + use_shell = False + try: result = subprocess.run( cmd, - capture_output=True, text=True, timeout=60, - cwd=os.path.dirname(path) or '.' + capture_output=True, text=True, timeout=120, + cwd=cwd ) except FileNotFoundError: + if container is not None: + logger.error( + f"Container runtime not found for " + f"pytest discovery" + ) + return [], pytest_exe # Try fallback: use current Python interpreter if pytest_exe == 'pytest': fallback = f'{sys.executable} -m pytest' @@ -88,7 +111,7 @@ def discover_pytest_tests( f"'{fallback}'" ) return discover_pytest_tests( - path, fallback + path, fallback, container ) logger.error( f"pytest executable '{pytest_exe}' not found" @@ -120,6 +143,7 @@ def __init__( self, test: TestCommon, target: Any | None ) -> None: super().__init__(test, target) + self._result_set: threading.Event = threading.Event() def run(self) -> None: """Not used — results are set directly by the @@ -134,6 +158,7 @@ def set_result( self.status = status self.output = output self.duration = duration + self._result_set.set() class PytestTest(TestCommon): @@ -185,11 +210,22 @@ def get_full_name(self) -> str | None: return f'{parent_name}:{self.name}' return self.name + def _get_container(self) -> ContainerConfig | None: + """Return the container config from the parent + testset, if any.""" + if self.parent is not None and hasattr( + self.parent, 'get_container' + ): + return self.parent.get_container() + return None + def discover(self) -> None: """Discover pytest tests and create PytestTest entries.""" + container = self._get_container() node_ids, resolved_exe = discover_pytest_tests( - self.pytest_path, self.pytest_exe + self.pytest_path, self.pytest_exe, + container=container, ) # Use the resolved executable (may be fallback) self.pytest_exe = resolved_exe @@ -296,9 +332,22 @@ def _run_batch( # Collect node IDs node_ids = [t.node_id for t in active_tests] - # Create temp file for JUnit XML + # Get container config + container = self._get_container() + + # Create temp file for JUnit XML. + # When using a container, the file must be at a + # path visible inside the container. We use the + # test's working directory (which is bind-mounted + # transparently) so both sides see the same path. + xml_dir = ( + os.path.realpath(self.path) + if container is not None + else None + ) xml_fd, xml_path = tempfile.mkstemp( - suffix='.xml', prefix='gvtest_pytest_' + suffix='.xml', prefix='gvtest_pytest_', + dir=xml_dir, ) os.close(xml_fd) @@ -338,16 +387,30 @@ def _run_batch( start_time = datetime.now() - # Run pytest - full_cmd = ( + # Run pytest — either inside container or + # directly on the host + inner_cmd = ( f'{sourceme_prefix}' f'{" ".join(cmd)}' ) - logger.debug(f"Running pytest batch: {full_cmd}") + logger.debug( + f"Running pytest batch: {inner_cmd}" + ) + + if container is not None: + exec_cmd = container.build_run_cmd( + inner_cmd=inner_cmd, + cwd=self.path, + extra_env=run0.envvars, + ) + use_shell = False + else: + exec_cmd = ['bash', '-c', inner_cmd] + use_shell = False try: result = subprocess.run( - ['bash', '-c', full_cmd], + exec_cmd, capture_output=True, text=True, env=env, cwd=self.path, timeout=self.runner.max_timeout diff --git a/python/gvtest/runner.py b/python/gvtest/runner.py index 1533d64..3b602f8 100644 --- a/python/gvtest/runner.py +++ b/python/gvtest/runner.py @@ -54,7 +54,7 @@ from rich.align import Align from pathlib import Path -from gvtest.config import get_python_paths_for_dir, ConfigLoader +from gvtest.config import get_python_paths_for_dir, get_container_for_dir, ConfigLoader from gvtest.targets import Target from gvtest.stats import TestsetStats from gvtest.testset_impl import TestsetImpl @@ -514,6 +514,16 @@ def import_testset( # testset_build() must run while python_paths are still in sys.path, # since it may import modules from configured paths testset: TestsetImpl = TestsetImpl(self, target, parent, path=os.path.dirname(file)) + + # Apply container config from gvtest.yaml if present + # and the testset doesn't already have one set + container_dict = get_container_for_dir(testset_dir) + if container_dict is not None: + from gvtest.container import ContainerConfig + testset.container = ContainerConfig.from_dict( + container_dict + ) + module.testset_build(testset) except FileNotFoundError as exc: raise RuntimeError('Unable to open test configuration file: ' + file) diff --git a/python/gvtest/tests.py b/python/gvtest/tests.py index d847c76..b3bfcaf 100644 --- a/python/gvtest/tests.py +++ b/python/gvtest/tests.py @@ -257,6 +257,16 @@ def print_end_message(self) -> None: else: _console.print(msg) + def _get_container(self): + """Return the container config from the test's + parent testset, if any.""" + parent = self.test.parent + if parent is not None and hasattr( + parent, 'get_container' + ): + return parent.get_container() + return None + def __exec_process(self, command: str, envvars: dict[str, str] | None = None) -> int: self.lock.acquire() if self.timeout_reached: @@ -267,15 +277,31 @@ def __exec_process(self, command: str, envvars: dict[str, str] | None = None) -> if envvars is not None: env.update(envvars) + # Check if this test should run inside a container + container = self._get_container() + if container is not None: + # Build docker run command — the container + # sees the same filesystem paths as the host + docker_cmd = container.build_run_cmd( + inner_cmd=command, + cwd=self.test.path, + extra_env=envvars, + ) + shell_cmd = docker_cmd + use_shell = False + else: + shell_cmd = command + use_shell = True + # Use pipes + start_new_session for isolation. # The new session prevents child processes from # corrupting the parent terminal (SDL2/ncurses), # and os.killpg() can kill the whole group. proc: subprocess.Popen[bytes] = subprocess.Popen( - command, stdout=subprocess.PIPE, + shell_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, - shell=True, cwd=self.test.path, env=env, + shell=use_shell, cwd=self.test.path, env=env, start_new_session=True ) diff --git a/python/gvtest/testset_impl.py b/python/gvtest/testset_impl.py index 7b96647..be04376 100644 --- a/python/gvtest/testset_impl.py +++ b/python/gvtest/testset_impl.py @@ -28,6 +28,7 @@ from rich.table import Table import gvtest.testsuite as testsuite +from gvtest.container import ContainerConfig from gvtest.targets import Target from gvtest.pytest_integration import PytestTestset from gvtest.tests import ( @@ -50,6 +51,7 @@ def __init__( self.parent: TestsetImpl | None = parent self.path: str | None = path self.target: Any | None = target + self.container: ContainerConfig | None = None def get_target(self) -> Any | None: return self.target @@ -66,6 +68,67 @@ def get_platform(self) -> str | None: def set_name(self, name: str) -> None: self.name = name + def set_container( + self, + image: str | None = None, + runtime: str = 'docker', + volumes: dict[str, str] | None = None, + env: dict[str, str] | None = None, + options: list[str] | None = None, + setup: str | None = None, + workdir: str | None = None, + config: dict[str, Any] | None = None, + ) -> None: + """Set a container configuration for this testset. + + All tests in this testset (and nested testsets + unless they override) will execute inside the + specified container. + + Can be called with individual parameters:: + + testset.set_container( + image='ghcr.io/org/image:tag', + setup='pip install -e .', + ) + + Or with a dict (e.g. from gvtest.yaml):: + + testset.set_container( + config={'image': '...', 'setup': '...'} + ) + """ + if config is not None: + self.container = ContainerConfig.from_dict( + config + ) + elif image is not None: + self.container = ContainerConfig( + image=image, + runtime=runtime, + volumes=volumes, + env=env, + options=options, + setup=setup, + workdir=workdir, + ) + else: + raise ValueError( + "set_container requires 'image' or 'config'" + ) + + def get_container(self) -> ContainerConfig | None: + """Return the effective container config. + + Walks up the testset hierarchy: the nearest + ancestor (including self) with a container wins. + """ + if self.container is not None: + return self.container + if self.parent is not None: + return self.parent.get_container() + return None + def get_full_name(self) -> str | None: if self.parent is not None: parent_name: str | None = self.parent.get_full_name() diff --git a/python/gvtest/testsuite.py b/python/gvtest/testsuite.py index abd5e43..8322b36 100644 --- a/python/gvtest/testsuite.py +++ b/python/gvtest/testsuite.py @@ -108,6 +108,12 @@ def get_platform(self) -> str | None: pass @abc.abstractmethod def get_path(self) -> str | None: pass + @abc.abstractmethod + def set_container(self, **kwargs: Any) -> None: pass + + @abc.abstractmethod + def get_container(self) -> Any: pass + class SdkTest(object, metaclass=abc.ABCMeta): diff --git a/tests/test_container.py b/tests/test_container.py new file mode 100644 index 0000000..069590e --- /dev/null +++ b/tests/test_container.py @@ -0,0 +1,213 @@ +"""Tests for the container execution backend.""" + +import os +import pytest + +from gvtest.container import ContainerConfig + + +class TestContainerConfig: + """Tests for ContainerConfig construction and command building.""" + + def test_basic_creation(self): + c = ContainerConfig(image='ubuntu:22.04') + assert c.image == 'ubuntu:22.04' + assert c.runtime == 'docker' + assert c.volumes == {} + assert c.env == {} + assert c.options == [] + assert c.setup is None + assert c.workdir is None + + def test_full_creation(self): + c = ContainerConfig( + image='ghcr.io/org/image:tag', + runtime='podman', + volumes={'/data': '/data'}, + env={'FOO': 'bar'}, + options=['--gpus', 'all'], + setup='pip install -e .', + workdir='/app', + ) + assert c.image == 'ghcr.io/org/image:tag' + assert c.runtime == 'podman' + assert c.volumes == {'/data': '/data'} + assert c.env == {'FOO': 'bar'} + assert c.options == ['--gpus', 'all'] + assert c.setup == 'pip install -e .' + assert c.workdir == '/app' + + def test_from_dict_minimal(self): + c = ContainerConfig.from_dict({'image': 'test:latest'}) + assert c.image == 'test:latest' + assert c.runtime == 'docker' + + def test_from_dict_full(self): + c = ContainerConfig.from_dict({ + 'image': 'test:latest', + 'runtime': 'podman', + 'volumes': {'/src': '/src'}, + 'env': {'KEY': 'val'}, + 'options': ['--net=host'], + 'setup': 'make deps', + 'workdir': '/build', + }) + assert c.image == 'test:latest' + assert c.runtime == 'podman' + assert c.volumes == {'/src': '/src'} + assert c.env == {'KEY': 'val'} + assert c.options == ['--net=host'] + assert c.setup == 'make deps' + assert c.workdir == '/build' + + def test_from_dict_missing_image(self): + with pytest.raises(ValueError, match="requires 'image'"): + ContainerConfig.from_dict({'runtime': 'docker'}) + + def test_from_dict_not_a_dict(self): + with pytest.raises(ValueError, match="must be a mapping"): + ContainerConfig.from_dict("not a dict") + + def test_repr(self): + c = ContainerConfig(image='test:latest') + assert 'test:latest' in repr(c) + assert 'docker' in repr(c) + + +class TestBuildRunCmd: + """Tests for ContainerConfig.build_run_cmd().""" + + def test_basic_cmd(self): + c = ContainerConfig(image='ubuntu:22.04') + cmd = c.build_run_cmd('echo hello', cwd='/workspace') + assert cmd[0] == 'docker' + assert cmd[1] == 'run' + assert cmd[2] == '--rm' + assert 'ubuntu:22.04' in cmd + assert cmd[-3:] == ['bash', '-c', 'echo hello'] + + def test_transparent_mount(self): + c = ContainerConfig(image='test:latest') + cmd = c.build_run_cmd('ls', cwd='/workspace/project') + # The cwd should be mounted at the same path + real_cwd = os.path.realpath('/workspace/project') + assert '-v' in cmd + v_idx = cmd.index('-v') + assert cmd[v_idx + 1] == f'{real_cwd}:{real_cwd}' + + def test_workdir_flag(self): + c = ContainerConfig(image='test:latest') + cmd = c.build_run_cmd('ls', cwd='/workspace') + real_cwd = os.path.realpath('/workspace') + assert '-w' in cmd + w_idx = cmd.index('-w') + assert cmd[w_idx + 1] == real_cwd + + def test_workdir_override(self): + c = ContainerConfig( + image='test:latest', workdir='/app' + ) + cmd = c.build_run_cmd('ls', cwd='/workspace') + w_idx = cmd.index('-w') + assert cmd[w_idx + 1] == '/app' + + def test_setup_prepended(self): + c = ContainerConfig( + image='test:latest', + setup='pip install -e .', + ) + cmd = c.build_run_cmd('pytest tests/') + assert cmd[-1] == 'pip install -e . && pytest tests/' + + def test_no_setup(self): + c = ContainerConfig(image='test:latest') + cmd = c.build_run_cmd('pytest tests/') + assert cmd[-1] == 'pytest tests/' + + def test_env_vars(self): + c = ContainerConfig( + image='test:latest', + env={'FOO': 'bar', 'BAZ': 'qux'}, + ) + cmd = c.build_run_cmd('echo test') + # Find all -e flags + env_pairs = [] + for i, arg in enumerate(cmd): + if arg == '-e' and i + 1 < len(cmd): + env_pairs.append(cmd[i + 1]) + assert 'FOO=bar' in env_pairs + assert 'BAZ=qux' in env_pairs + + def test_extra_env(self): + c = ContainerConfig( + image='test:latest', env={'A': '1'} + ) + cmd = c.build_run_cmd( + 'echo test', extra_env={'B': '2'} + ) + env_pairs = [] + for i, arg in enumerate(cmd): + if arg == '-e' and i + 1 < len(cmd): + env_pairs.append(cmd[i + 1]) + assert 'A=1' in env_pairs + assert 'B=2' in env_pairs + + def test_extra_volumes(self): + c = ContainerConfig(image='test:latest') + cmd = c.build_run_cmd( + 'ls', + cwd='/workspace', + extra_volumes={'/data': '/data'}, + ) + vol_pairs = [] + for i, arg in enumerate(cmd): + if arg == '-v' and i + 1 < len(cmd): + vol_pairs.append(cmd[i + 1]) + real_data = os.path.realpath('/data') + assert f'{real_data}:/data' in vol_pairs + + def test_options_forwarded(self): + c = ContainerConfig( + image='test:latest', + options=['--gpus', 'all', '--net=host'], + ) + cmd = c.build_run_cmd('echo test') + assert '--gpus' in cmd + assert 'all' in cmd + assert '--net=host' in cmd + + def test_podman_runtime(self): + c = ContainerConfig( + image='test:latest', runtime='podman' + ) + cmd = c.build_run_cmd('echo test') + assert cmd[0] == 'podman' + + def test_cwd_subpath_not_double_mounted(self): + """If an explicit volume is a subpath of cwd, + it should not be mounted separately.""" + c = ContainerConfig( + image='test:latest', + volumes={'/workspace/sub': '/workspace/sub'}, + ) + cmd = c.build_run_cmd('ls', cwd='/workspace') + vol_pairs = [] + for i, arg in enumerate(cmd): + if arg == '-v' and i + 1 < len(cmd): + vol_pairs.append(cmd[i + 1]) + # Should only have 1 mount (the cwd), not the sub + real_ws = os.path.realpath('/workspace') + assert len(vol_pairs) == 1 + assert vol_pairs[0] == f'{real_ws}:{real_ws}' + + +class TestContainerConfigValidate: + """Tests for ContainerConfig.validate().""" + + def test_validate_missing_runtime(self): + c = ContainerConfig( + image='test:latest', + runtime='nonexistent_runtime_xyz', + ) + with pytest.raises(RuntimeError, match="not found"): + c.validate()