From a9edd86e20a5dad8b7fb46613522f6508623c052 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Mon, 18 May 2026 19:16:30 +0000 Subject: [PATCH] Fix #35: use docker compose plugin (v2) with fallback to docker-compose Detect the compose command once at startup: prefer the plugin form ("docker compose") via "docker compose version", fall back to the standalone "docker-compose" binary when the plugin is unavailable. - shard_core/util/subprocess.py: add compose_command() with lru_cache detection - shard_core/service/app_tools.py: replace all hard-coded "docker-compose" calls - Dockerfile: drop standalone docker-compose APT package; Docker install provides the plugin - .github/workflows/test.yml: remove KengoTODA action; runner's built-in plugin is used - tests/test_compose_command.py: unit tests for detection and caching behaviour Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 3 -- Dockerfile | 3 +- shard_core/service/app_tools.py | 22 ++++++----- shard_core/util/subprocess.py | 21 +++++++++++ tests/test_compose_command.py | 67 +++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 tests/test_compose_command.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb70e1e..768dcda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,9 +29,6 @@ jobs: unittest: runs-on: ubuntu-latest steps: - - uses: KengoTODA/actions-setup-docker-compose@v1 - with: - version: '2.14.2' # the full version of `docker-compose` command - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 diff --git a/Dockerfile b/Dockerfile index c64a645..5952cac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,12 +27,11 @@ FROM python:3.13-slim-bookworm AS runtime # Install packages required for the project RUN apt-get update && apt-get install --no-install-recommends -y \ curl \ - docker-compose \ rclone \ tini \ && apt-get clean -# Install Docker +# Install Docker (includes the compose plugin) RUN curl -fsSL https://get.docker.com -o get-docker.sh RUN sh get-docker.sh diff --git a/shard_core/service/app_tools.py b/shard_core/service/app_tools.py index 6d0c165..92b39bf 100644 --- a/shard_core/service/app_tools.py +++ b/shard_core/service/app_tools.py @@ -15,7 +15,7 @@ from shard_core.settings import settings from shard_core.util import signals from shard_core.util.misc import throttle -from shard_core.util.subprocess import subprocess, SubprocessError +from shard_core.util.subprocess import subprocess, SubprocessError, compose_command log = logging.getLogger(__name__) @@ -23,7 +23,7 @@ async def docker_create_app_containers(name: str): log.debug(f"creating containers for app {name}") await subprocess( - "docker-compose", "up", "--no-start", cwd=get_installed_apps_path() / name + *compose_command(), "up", "--no-start", cwd=get_installed_apps_path() / name ) @@ -37,7 +37,7 @@ async def docker_start_app(name: str): log.debug(f"starting app {name=}") try: await subprocess( - "docker-compose", "up", "-d", cwd=get_installed_apps_path() / name + *compose_command(), "up", "-d", cwd=get_installed_apps_path() / name ) except SubprocessError as e: if "network" in str(e) and "not found" in str(e): @@ -45,20 +45,20 @@ async def docker_start_app(name: str): f"stale network reference for app {name=}, recreating containers" ) await subprocess( - "docker-compose", "down", cwd=get_installed_apps_path() / name + *compose_command(), "down", cwd=get_installed_apps_path() / name ) await subprocess( - "docker-compose", "up", "-d", cwd=get_installed_apps_path() / name + *compose_command(), "up", "-d", cwd=get_installed_apps_path() / name ) elif "Conflict" in str(e) and "already in use" in str(e): log.warning( f"stale containers for app {name=}, removing and recreating" ) await subprocess( - "docker-compose", "down", cwd=get_installed_apps_path() / name + *compose_command(), "down", cwd=get_installed_apps_path() / name ) await subprocess( - "docker-compose", "up", "-d", cwd=get_installed_apps_path() / name + *compose_command(), "up", "-d", cwd=get_installed_apps_path() / name ) else: raise @@ -74,7 +74,9 @@ async def docker_stop_app(name: str, set_status: bool = True): app = await db_installed_apps.get_by_name(conn, name) app_status = app["status"] if app else None if app_status in [Status.RUNNING, Status.UNINSTALLING]: - await subprocess("docker-compose", "stop", cwd=get_installed_apps_path() / name) + await subprocess( + *compose_command(), "stop", cwd=get_installed_apps_path() / name + ) if set_status: async with db_conn() as conn: await db_installed_apps.update_status(conn, name, Status.STOPPED) @@ -88,7 +90,9 @@ async def docker_shutdown_app(name: str, set_status: bool = True, force: bool = app = await db_installed_apps.get_by_name(conn, name) app_status = app["status"] if app else None if force or app_status in [Status.STOPPED, Status.UNINSTALLING]: - await subprocess("docker-compose", "down", cwd=get_installed_apps_path() / name) + await subprocess( + *compose_command(), "down", cwd=get_installed_apps_path() / name + ) if set_status: async with db_conn() as conn: await db_installed_apps.update_status(conn, name, Status.DOWN) diff --git a/shard_core/util/subprocess.py b/shard_core/util/subprocess.py index 3691dec..da28124 100644 --- a/shard_core/util/subprocess.py +++ b/shard_core/util/subprocess.py @@ -1,9 +1,30 @@ import asyncio +import functools import logging +import subprocess as _sp log = logging.getLogger(__name__) +@functools.lru_cache(maxsize=1) +def _detect_compose_command() -> tuple[str, ...]: + try: + result = _sp.run( + ["docker", "compose", "version"], + capture_output=True, + timeout=5, + ) + if result.returncode == 0: + return ("docker", "compose") + except (FileNotFoundError, _sp.TimeoutExpired): + pass + return ("docker-compose",) + + +def compose_command() -> tuple[str, ...]: + return _detect_compose_command() + + async def subprocess(*args, cwd=None): process = await asyncio.create_subprocess_exec( *args, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE diff --git a/tests/test_compose_command.py b/tests/test_compose_command.py new file mode 100644 index 0000000..be27702 --- /dev/null +++ b/tests/test_compose_command.py @@ -0,0 +1,67 @@ +from unittest.mock import patch, MagicMock +import subprocess as _sp + +from shard_core.util.subprocess import _detect_compose_command, compose_command + + +def _clear_cache(): + _detect_compose_command.cache_clear() + + +def test_compose_command_prefers_plugin(): + _clear_cache() + mock_result = MagicMock() + mock_result.returncode = 0 + with patch( + "shard_core.util.subprocess._sp.run", return_value=mock_result + ) as mock_run: + result = compose_command() + mock_run.assert_called_once_with( + ["docker", "compose", "version"], + capture_output=True, + timeout=5, + ) + assert result == ("docker", "compose") + _clear_cache() + + +def test_compose_command_falls_back_on_nonzero(): + _clear_cache() + mock_result = MagicMock() + mock_result.returncode = 1 + with patch("shard_core.util.subprocess._sp.run", return_value=mock_result): + result = compose_command() + assert result == ("docker-compose",) + _clear_cache() + + +def test_compose_command_falls_back_on_file_not_found(): + _clear_cache() + with patch("shard_core.util.subprocess._sp.run", side_effect=FileNotFoundError): + result = compose_command() + assert result == ("docker-compose",) + _clear_cache() + + +def test_compose_command_falls_back_on_timeout(): + _clear_cache() + with patch( + "shard_core.util.subprocess._sp.run", + side_effect=_sp.TimeoutExpired(cmd="docker", timeout=5), + ): + result = compose_command() + assert result == ("docker-compose",) + _clear_cache() + + +def test_compose_command_cached(): + _clear_cache() + mock_result = MagicMock() + mock_result.returncode = 0 + with patch( + "shard_core.util.subprocess._sp.run", return_value=mock_result + ) as mock_run: + compose_command() + compose_command() + assert mock_run.call_count == 1 + _clear_cache()