Skip to content
Open
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
3 changes: 0 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 13 additions & 9 deletions shard_core/service/app_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
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__)


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
)


Expand All @@ -37,28 +37,28 @@ 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):
log.warning(
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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions shard_core/util/subprocess.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
67 changes: 67 additions & 0 deletions tests/test_compose_command.py
Original file line number Diff line number Diff line change
@@ -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()
Loading