From a90b9af616fc75d01196c433485d1a923f9ab96f Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 27 Feb 2026 09:54:39 +0100 Subject: [PATCH 1/5] Add apptainer to ci --- .github/workflows/python.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 31c2acc..59577ac 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -75,6 +75,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - uses: eWaterCycle/setup-apptainer@v2 + with: + apptainer-version: 1.4.5 - name: Setup Python uses: actions/setup-python@v5 with: From a24c787652f0129bd04eb34926f26548e5ae2444 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 27 Feb 2026 09:55:06 +0100 Subject: [PATCH 2/5] Add socket poller --- python/src/remotebmi/client/apptainer.py | 55 +++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/python/src/remotebmi/client/apptainer.py b/python/src/remotebmi/client/apptainer.py index 28d01d3..504535a 100644 --- a/python/src/remotebmi/client/apptainer.py +++ b/python/src/remotebmi/client/apptainer.py @@ -1,5 +1,6 @@ import logging import os +import socket import subprocess import time from collections.abc import Iterable @@ -16,8 +17,11 @@ def __init__( image: str, work_dir: str, input_dirs: Iterable[str] = (), - delay: int = 0, + delay: float = 0, capture_logs: bool = True, + # wait up to 10 minutes for container to start, some models can be slow to start up + startup_timeout: float = 600.0, + startup_poll_interval: float = 0.1, ): if isinstance(input_dirs, str): msg = ( @@ -73,8 +77,57 @@ def __init__( self.logs(), ) url = f"http://{host}:{port}" + + self._wait_until_connectable( + host=host, + port=port, + url=url, + image=image, + startup_timeout=startup_timeout, + startup_poll_interval=startup_poll_interval, + ) + super().__init__(url) + def _wait_until_connectable( + self, + host: str, + port: int, + url: str, + image: str, + startup_timeout: float, + startup_poll_interval: float, + ) -> None: + deadline = time.monotonic() + startup_timeout + while time.monotonic() < deadline: + returncode = self.container.poll() + if returncode is not None: + msg = f"apptainer container {image} prematurely exited with code {returncode}" + raise DeadContainerError( + msg, + returncode, + self.logs(), + ) + + try: + with socket.create_connection( + (host, port), timeout=startup_poll_interval + ): + return + except OSError: + time.sleep(startup_poll_interval) + + self.container.terminate() + self.container.wait() + logs = self.logs() + msg = ( + f"Timed out after {startup_timeout} seconds waiting for apptainer " + f"container {image} to accept connections on {url}" + ) + if logs: + msg += f". Container logs: {logs}" + raise TimeoutError(msg) + def __del__(self) -> None: if hasattr(self, "container"): self.container.terminate() From 7bd517cb1e754e1c7c5df1ab38750ea52ab8e8f9 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 27 Feb 2026 09:55:16 +0100 Subject: [PATCH 3/5] Add tests for apptainer --- .gitignore | 1 + python/tests/client/fixtures/leakybucket.def | 12 ++++++ python/tests/client/test_apptainer.py | 40 ++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 python/tests/client/fixtures/leakybucket.def create mode 100644 python/tests/client/test_apptainer.py diff --git a/.gitignore b/.gitignore index 17246fe..a6e3560 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ openapi-generator-cli.jar openapitools.json PEQ_Hupsel.dat walrus.yml +python/tests/client/fixtures/leakybucket.sif \ No newline at end of file diff --git a/python/tests/client/fixtures/leakybucket.def b/python/tests/client/fixtures/leakybucket.def new file mode 100644 index 0000000..6ec0f98 --- /dev/null +++ b/python/tests/client/fixtures/leakybucket.def @@ -0,0 +1,12 @@ +Bootstrap: docker +From: python:3.14-slim + +%environment + export BMI_MODULE=leakybucket.leakybucket_bmi + export BMI_CLASS=LeakyBucketBmi + +%post + python3 -m pip install --no-cache-dir leakybucket remotebmi + +%runscript + exec run-bmi-server "$@" diff --git a/python/tests/client/test_apptainer.py b/python/tests/client/test_apptainer.py new file mode 100644 index 0000000..1758dfc --- /dev/null +++ b/python/tests/client/test_apptainer.py @@ -0,0 +1,40 @@ +import subprocess +from pathlib import Path + +import pytest + +from remotebmi.client.apptainer import BmiClientApptainer, DeadContainerError + + +@pytest.fixture +def leakybucket_def() -> Path: + return Path(__file__).parent / "fixtures" / "leakybucket.def" + + +@pytest.fixture +def leakybucket_image(leakybucket_def: Path) -> Path: + image = leakybucket_def.parent / "leakybucket.sif" + if image.exists(): + return image + subprocess.run(["apptainer", "build", str(image), str(leakybucket_def)], check=True) + return image + + +@pytest.fixture +def leakybucket_client(leakybucket_image: Path, tmp_path: Path): + client = BmiClientApptainer( + image=str(leakybucket_image), + work_dir=str(tmp_path), + ) + yield client + del client + + +def test_get_component_name(leakybucket_client: BmiClientApptainer, tmp_path: Path): + assert leakybucket_client.get_component_name() == "leakybucket" + + +def test_image_not_found(tmp_path: Path): + bad_image = tmp_path / "nonexistent_image.sif" + with pytest.raises(DeadContainerError): + BmiClientApptainer(image=str(bad_image), work_dir=str(tmp_path)) From f778899a8c6d8a51d4e5180217abee18d97551a5 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 27 Feb 2026 10:04:40 +0100 Subject: [PATCH 4/5] Fix ruff check errors + only run apptainer tests once on ci + skip apptainer tests when apptainer is not found --- .github/workflows/python.yml | 6 +++--- python/src/remotebmi/__init__.py | 2 +- python/src/remotebmi/client/apptainer.py | 6 ++++-- python/src/remotebmi/client/docker.py | 2 +- python/src/remotebmi/server/__init__.py | 2 +- python/tests/client/test_apptainer.py | 22 ++++++++++++++++++++-- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 59577ac..8847896 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -75,9 +75,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - - uses: eWaterCycle/setup-apptainer@v2 - with: - apptainer-version: 1.4.5 - name: Setup Python uses: actions/setup-python@v5 with: @@ -93,6 +90,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - uses: eWaterCycle/setup-apptainer@v2 + with: + apptainer-version: 1.4.5 - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/python/src/remotebmi/__init__.py b/python/src/remotebmi/__init__.py index 25c1140..06e468d 100644 --- a/python/src/remotebmi/__init__.py +++ b/python/src/remotebmi/__init__.py @@ -2,4 +2,4 @@ from remotebmi.client.client import RemoteBmiClient from remotebmi.client.docker import BmiClientDocker -__all__ = ["RemoteBmiClient", "BmiClientApptainer", "BmiClientDocker"] +__all__ = ["BmiClientApptainer", "BmiClientDocker", "RemoteBmiClient"] diff --git a/python/src/remotebmi/client/apptainer.py b/python/src/remotebmi/client/apptainer.py index 504535a..28a7da4 100644 --- a/python/src/remotebmi/client/apptainer.py +++ b/python/src/remotebmi/client/apptainer.py @@ -10,6 +10,7 @@ from remotebmi.client.client import RemoteBmiClient from remotebmi.client.utils import DeadContainerError, get_unique_port +logger = logging.getLogger(__name__) class BmiClientApptainer(RemoteBmiClient): def __init__( @@ -20,7 +21,7 @@ def __init__( delay: float = 0, capture_logs: bool = True, # wait up to 10 minutes for container to start, some models can be slow to start up - startup_timeout: float = 600.0, + startup_timeout: float = 600.0, startup_poll_interval: float = 0.1, ): if isinstance(input_dirs, str): @@ -48,7 +49,7 @@ def __init__( # Change into working directory args += ["--pwd", self.work_dir] args.append(image) - logging.info(f"Running {image} apptainer container on port {port}") + logger.info(f"Running {image} apptainer container on port {port}") if capture_logs: self.logfile = SpooledTemporaryFile( # noqa: SIM115 - file is closed in __del__ max_size=2**16, # keep until 65Kb in memory if bigger write to disk @@ -71,6 +72,7 @@ def __init__( msg = ( f"apptainer container {image} prematurely exited with code {returncode}" ) + logger.error(msg) raise DeadContainerError( msg, returncode, diff --git a/python/src/remotebmi/client/docker.py b/python/src/remotebmi/client/docker.py index 362cc9a..f799952 100644 --- a/python/src/remotebmi/client/docker.py +++ b/python/src/remotebmi/client/docker.py @@ -4,7 +4,7 @@ from posixpath import abspath import docker -from docker.models.containers import Container # noqa: TCH002 +from docker.models.containers import Container # noqa: TC002 from remotebmi.client.client import RemoteBmiClient from remotebmi.client.utils import DeadContainerError, get_unique_port, getuser diff --git a/python/src/remotebmi/server/__init__.py b/python/src/remotebmi/server/__init__.py index 472b8cf..43bb836 100644 --- a/python/src/remotebmi/server/__init__.py +++ b/python/src/remotebmi/server/__init__.py @@ -47,7 +47,7 @@ async def lifespan_handler(app: ConnexionMiddleware) -> AsyncIterator: # noqa: def main(**kwargs): # type: ignore[no-untyped-def] model = from_env() app = make_app(model) - port = int(environ.get("BMI_PORT", 50051)) + port = int(environ.get("BMI_PORT", "50051")) uvicorn.run(app, port=port, **kwargs) diff --git a/python/tests/client/test_apptainer.py b/python/tests/client/test_apptainer.py index 1758dfc..e818a8e 100644 --- a/python/tests/client/test_apptainer.py +++ b/python/tests/client/test_apptainer.py @@ -5,6 +5,24 @@ from remotebmi.client.apptainer import BmiClientApptainer, DeadContainerError +try: + _apptainer_available = ( + subprocess.run( + ["apptainer", "--version"], # noqa: S607 + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode + == 0 + ) +except OSError: + _apptainer_available = False + +pytestmark = pytest.mark.skipif( + not _apptainer_available, + reason="apptainer is unavailable", +) + @pytest.fixture def leakybucket_def() -> Path: @@ -16,7 +34,7 @@ def leakybucket_image(leakybucket_def: Path) -> Path: image = leakybucket_def.parent / "leakybucket.sif" if image.exists(): return image - subprocess.run(["apptainer", "build", str(image), str(leakybucket_def)], check=True) + subprocess.run(["apptainer", "build", str(image), str(leakybucket_def)], check=True) # noqa: S603, S607 return image @@ -30,7 +48,7 @@ def leakybucket_client(leakybucket_image: Path, tmp_path: Path): del client -def test_get_component_name(leakybucket_client: BmiClientApptainer, tmp_path: Path): +def test_get_component_name(leakybucket_client: BmiClientApptainer): assert leakybucket_client.get_component_name() == "leakybucket" From a18f7b09e10d2511a1a3bad2498af021776b0054 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 27 Feb 2026 10:09:42 +0100 Subject: [PATCH 5/5] Please mypy --- python/src/remotebmi/client/apptainer.py | 1 + python/tests/client/test_apptainer.py | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/python/src/remotebmi/client/apptainer.py b/python/src/remotebmi/client/apptainer.py index 28a7da4..c81d56d 100644 --- a/python/src/remotebmi/client/apptainer.py +++ b/python/src/remotebmi/client/apptainer.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) + class BmiClientApptainer(RemoteBmiClient): def __init__( self, diff --git a/python/tests/client/test_apptainer.py b/python/tests/client/test_apptainer.py index e818a8e..3e3adca 100644 --- a/python/tests/client/test_apptainer.py +++ b/python/tests/client/test_apptainer.py @@ -1,4 +1,5 @@ import subprocess +from collections.abc import Generator from pathlib import Path import pytest @@ -39,7 +40,9 @@ def leakybucket_image(leakybucket_def: Path) -> Path: @pytest.fixture -def leakybucket_client(leakybucket_image: Path, tmp_path: Path): +def leakybucket_client( + leakybucket_image: Path, tmp_path: Path +) -> Generator[BmiClientApptainer, None, None]: client = BmiClientApptainer( image=str(leakybucket_image), work_dir=str(tmp_path), @@ -48,11 +51,11 @@ def leakybucket_client(leakybucket_image: Path, tmp_path: Path): del client -def test_get_component_name(leakybucket_client: BmiClientApptainer): +def test_get_component_name(leakybucket_client: BmiClientApptainer) -> None: assert leakybucket_client.get_component_name() == "leakybucket" -def test_image_not_found(tmp_path: Path): +def test_image_not_found(tmp_path: Path) -> None: bad_image = tmp_path / "nonexistent_image.sif" with pytest.raises(DeadContainerError): BmiClientApptainer(image=str(bad_image), work_dir=str(tmp_path))