From b2df81130eae1844e924bb1ddc689f802bf13d04 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Mon, 5 Oct 2026 15:25:09 -0400 Subject: [PATCH] fix: container volumes --- Dockerfile | 10 +++++++--- deploy/README.md | 2 ++ docker-entrypoint.sh | 19 +++++++++++++++++++ progression_backend/refresh.py | 12 ++++++++++++ progression_backend/workshop.py | 15 +++++++++++++++ tests/test_backend.py | 23 ++++++++++++++++++++++- 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 2f27237..4e8c8de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG DEBIAN_FRONTEND=noninteractive RUN dpkg --add-architecture i386 \ && apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl lib32gcc-s1 python3 python3-pip python3-venv \ + ca-certificates curl gosu lib32gcc-s1 python3 python3-pip python3-venv \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --shell /bin/bash progression \ && mkdir -p /opt/steamcmd/steamapps /srv/progression/.data \ @@ -19,9 +19,12 @@ RUN python3 -m venv /srv/venv \ COPY --chown=progression:progression progression_backend ./progression_backend COPY --chown=progression:progression art ./art -USER progression +COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/progression-entrypoint -ENV PROGRESSION_HOST=0.0.0.0 \ +ENV HOME=/home/progression \ + USER=progression \ + XDG_DATA_HOME=/home/progression/.local/share \ + PROGRESSION_HOST=0.0.0.0 \ PROGRESSION_PORT=8000 \ PROGRESSION_DB_PATH=.data/progression.db \ STEAMCMD_PATH=/opt/steamcmd/steamcmd.sh \ @@ -35,4 +38,5 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD curl -fsS http://127.0.0.1:8000/api/health || exit 1 +ENTRYPOINT ["/usr/local/bin/progression-entrypoint"] CMD ["/srv/venv/bin/python", "-m", "progression_backend"] diff --git a/deploy/README.md b/deploy/README.md index b77397a..124a37d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -19,4 +19,6 @@ On a new deployment, the hourly refresh timer stays paused while the first full Workshop download runs. Its hard limit defaults to 12 hours and can be changed with `INITIAL_DOWNLOAD_TIMEOUT_SECONDS`. Follow the per-mod title, Workshop ID, result, and completion percentage in Dockhand or with `docker compose logs -f`. +Failed batches also include SteamCMD's output tail so network, subscription, and +filesystem errors are visible directly in the container log. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..a4aea5c --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +prepare_volume() { + path="$1" + marker="$path/.progression-volume-ready" + mkdir -p "$path" + if [ ! -e "$marker" ]; then + echo "Preparing persistent volume permissions: $path" + chown -R progression:progression "$path" + touch "$marker" + chown progression:progression "$marker" + fi +} + +prepare_volume /srv/progression/.data +prepare_volume /opt/steamcmd/steamapps + +exec gosu progression "$@" diff --git a/progression_backend/refresh.py b/progression_backend/refresh.py index ccea53d..8ba2cd8 100644 --- a/progression_backend/refresh.py +++ b/progression_backend/refresh.py @@ -293,6 +293,18 @@ class RefreshService: result = self.runner.run_batch( batch, timeout_seconds=remaining ) + if result.status != "completed": + logger.warning( + "SteamCMD pass %d batch %d/%d finished with " + "status=%s exit_code=%s after %.1fs. Output:\n%s", + pass_number, + pass_batch_number, + len(batches), + result.status, + result.exit_code, + result.elapsed_seconds, + result.output_tail or "(no SteamCMD output)", + ) self.database.add_download_attempt( { "run_id": run_id, diff --git a/progression_backend/workshop.py b/progression_backend/workshop.py index b5e690a..7da35b2 100644 --- a/progression_backend/workshop.py +++ b/progression_backend/workshop.py @@ -116,6 +116,20 @@ class SteamCmdRunner: def available(self) -> bool: return self.executable_path() is not None + def ensure_workshop_writable(self) -> None: + workshop_dir = self.content_dir.parent.parent + try: + workshop_dir.mkdir(parents=True, exist_ok=True) + self.content_dir.mkdir(parents=True, exist_ok=True) + probe = workshop_dir / ".progression-write-test" + probe.write_text("ok", encoding="utf-8") + probe.unlink() + except OSError as error: + raise PermissionError( + f"SteamCMD Workshop directory is not writable: {workshop_dir}. " + "Check the container volume ownership." + ) from error + def run_batch( self, workshop_ids: list[str], @@ -129,6 +143,7 @@ class SteamCmdRunner: invalid = [item for item in workshop_ids if not item.isdigit()] if invalid: raise ValueError(f"Invalid Workshop ids: {', '.join(invalid)}") + self.ensure_workshop_writable() command = [executable, "+login", "anonymous"] for workshop_id in workshop_ids: diff --git a/tests/test_backend.py b/tests/test_backend.py index 7859b2b..8257b4b 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -23,7 +23,12 @@ from progression_backend.modlist import ( ) from progression_backend.refresh import RefreshCoordinator, RefreshService from progression_backend.steam_api import WorkshopDetails -from progression_backend.workshop import SteamCmdBatchResult, parse_about_xml, utc_now +from progression_backend.workshop import ( + SteamCmdBatchResult, + SteamCmdRunner, + parse_about_xml, + utc_now, +) class FakeSteam: @@ -176,6 +181,22 @@ class BackendTests(unittest.TestCase): self.assertEqual(parsed.package_id, "author.mod") self.assertEqual(parsed.display_name, "A & B") + def test_steamcmd_runner_prepares_writable_workshop_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content_dir = ( + Path(directory) + / "steamapps" + / "workshop" + / "content" + / "294100" + ) + runner = SteamCmdRunner("steamcmd", content_dir, 60) + runner.ensure_workshop_writable() + self.assertTrue(content_dir.is_dir()) + self.assertFalse( + (content_dir.parent.parent / ".progression-write-test").exists() + ) + def test_rml_uses_workshop_id_and_escapes_names(self) -> None: payload = generate_rml( [