diff --git a/.env.example b/.env.example index a9d38de..6bf4769 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,4 @@ -# Absolute path to this deployment directory on the Docker host (required). -SIMC_WEB_ROOT=/srv/simc-web - -# Optional settings. +# Optional settings. The default deployment uses Docker-managed storage. SIMC_WEB_PORT=8088 TZ=UTC SIMC_IMAGE=simulationcraftorg/simc:latest @@ -17,3 +14,7 @@ WEB_THREADS=4 # Optional Docker limits for each SimulationCraft run. # SIMC_CPUS=4 # SIMC_MEMORY=4g + +# Homelab bind-folder mode only. See docs/SETUP.md. +# COMPOSE_FILE=compose.yaml:compose.bind.yaml +# SIMC_WEB_ROOT=/srv/simc-web diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1005d80..0a484fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: run: | docker compose config --quiet docker compose -f compose.yaml -f compose.build.yaml config --quiet + docker compose -f compose.yaml -f compose.bind.yaml config --quiet + docker compose -f compose.yaml -f compose.bind.yaml -f compose.build.yaml config --quiet - name: Build web image run: docker build -t simc-web:test app diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index c30179d..e3021a5 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -39,6 +39,8 @@ jobs: run: | docker compose config --quiet docker compose -f compose.yaml -f compose.build.yaml config --quiet + docker compose -f compose.yaml -f compose.bind.yaml config --quiet + docker compose -f compose.yaml -f compose.bind.yaml -f compose.build.yaml config --quiet publish: needs: test diff --git a/CHANGELOG.md b/CHANGELOG.md index cd08948..1abbe53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.0] - 2026-07-17 + +### Added + +- Add Windows and macOS/Linux launchers, a desktop-focused setup guide, and an + optional bind-folder Compose overlay for homelab deployments. + +### Changed + +- Use persistent Docker-managed volumes by default with no required `.env` + file or absolute host path. +- Discover the web container's Docker mounts automatically so SimulationCraft + jobs can share either named volumes or homelab bind folders. + ## [0.2.0] - 2026-07-17 ### Added @@ -58,7 +72,8 @@ project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Unique per-run filenames and container names. - Documented the Docker socket trust boundary. -[Unreleased]: https://github.com/Bromeego/SimC-Local-Runner/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/Bromeego/SimC-Local-Runner/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/Bromeego/SimC-Local-Runner/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/Bromeego/SimC-Local-Runner/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/Bromeego/SimC-Local-Runner/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/Bromeego/SimC-Local-Runner/releases/tag/v0.1.0 diff --git a/README.md b/README.md index e60f958..69570d7 100644 --- a/README.md +++ b/README.md @@ -45,50 +45,38 @@ report that is easy to understand and maintain. ## Requirements -- A Linux host with Docker Engine and Docker Compose -- Permission to access the Docker socket -- An absolute host path for the deployment and its saved data +- Docker Desktop on Windows or macOS, or Docker Engine with Compose on Linux +- Linux container support and permission to access the Docker socket +- An x86-64 computer for native SimulationCraft performance The app runs a second container for each simulation. It therefore mounts the -host Docker socket and needs the host's absolute paths to the `input` and -`output` directories. +host Docker socket. Keep it on a trusted personal computer or homelab network. +Apple silicon can run the current x86-64 SimulationCraft image through Docker +emulation, but simulations may be slower. ## Quick start -1. Clone the repository and enter its directory: +1. Open the [latest release](https://github.com/Bromeego/SimC-Local-Runner/releases/latest), + download **Source code (zip)**, and extract it to a permanent folder. - ```sh - git clone https://github.com/Bromeego/SimC-Local-Runner.git - cd SimC-Local-Runner - ``` +2. Start the runner: -2. Create the data directories: + - Windows: double-click `start.cmd`. + - macOS: open Terminal in the folder and run `sh start.sh`. + - Linux: run `sh start.sh`. - ```sh - mkdir -p input output - ``` +3. Open . -3. Copy the example configuration: +The launcher checks Docker, pulls the current image, and starts the app. No +`.env` file or storage path is required. For a manual start, use: - ```sh - cp .env.example .env - ``` - -4. Edit `.env` and set `SIMC_WEB_ROOT` to the absolute path of this directory - on the Docker host. For example: - - ```dotenv - SIMC_WEB_ROOT=/srv/simc-web - ``` - -5. Pull the published image and start the app: - - ```sh - docker compose pull - docker compose up -d - ``` +```sh +docker compose pull simc-web +docker compose up -d +``` -6. Open `http://HOSTNAME-OR-IP:8088`. +See the [setup guide](docs/SETUP.md) for homelab bind folders, updates, +platform notes, and troubleshooting. View logs with: @@ -104,9 +92,9 @@ docker compose down ### Building locally -The default Compose file uses the published `linux/amd64` or `linux/arm64` -image from GitHub Container Registry. To build the web app from this checkout -instead, add the local-build override: +The default Compose file uses the published image from GitHub Container +Registry. To build the web app from this checkout instead, add the local-build +override: ```sh docker compose -f compose.yaml -f compose.build.yaml up -d --build @@ -122,7 +110,6 @@ The example values live in [`.env.example`](.env.example). | Variable | Default | Purpose | | --- | --- | --- | -| `SIMC_WEB_ROOT` | Required | Absolute host path to the deployment directory | | `SIMC_WEB_PORT` | `8088` | Port exposed by the web app | | `SIMC_WEB_IMAGE` | `ghcr.io/bromeego/simc-local-runner:latest` | Web interface image used by Compose | | `TZ` | `UTC` | Container timezone, using an IANA timezone name | @@ -136,8 +123,9 @@ The example values live in [`.env.example`](.env.example). | `REPORT_RETENTION_COUNT` | `100` | Reports retained after a successful run | | `WEB_THREADS` | `4` | Web threads for pages and long-running requests | -`SIMC_WEB_ROOT` must be a host path, not a path inside the web container. The -web app passes it to the Docker daemon when launching SimulationCraft. +The default deployment stores profiles and reports in Docker-managed volumes. +The optional homelab bind-folder mode uses `SIMC_WEB_ROOT`; see the +[setup guide](docs/SETUP.md). Before every simulation, the default `always` pull policy asks Docker for the current `SIMC_IMAGE`. Docker reuses existing image layers when the registry copy @@ -159,9 +147,10 @@ The values chosen in the web interface replace any existing `fight_style`, profile. DungeonSlice uses its own target flow and timing, so `desired_targets` and `max_time` are not added for that fight style. -Generated inputs are stored in `input/`, and generated reports are stored in -`output/`. These directories are excluded from Git because profiles and reports -can contain character information and can grow over time. +Generated inputs and reports are stored in persistent Docker volumes by +default. The homelab bind-folder mode stores them in `input/` and `output/` +under `SIMC_WEB_ROOT`. Profiles and reports can contain character information +and can grow over time. Each successful report also gets a matching `.json` metadata file in `output/`. It records the exact SimulationCraft image digest when Docker exposes one, the @@ -179,17 +168,16 @@ the configured retention count is exceeded. ## Updating -Pull the latest project and container changes: +Run `start.cmd` or `start.sh` again, or update manually: ```sh -git pull docker compose pull simc-web docker compose up -d ``` The runner refreshes the configured SimulationCraft engine automatically when -the next simulation starts. Existing inputs and reports remain in their -bind-mounted directories. +the next simulation starts. Existing Docker volumes or homelab folders remain +in place. For a local build, use the two-file command from the Building locally section after pulling the latest project changes. @@ -208,6 +196,7 @@ after pulling the latest project changes. |-- input/ # Submitted profiles (generated, not committed) |-- output/ # HTML reports (generated, not committed) |-- docs/screenshots/ # README previews +|-- docs/SETUP.md # Personal computer and homelab setup |-- examples/demo.simc # Anonymous profile for smoke testing |-- tests/ |-- .github/ # Checks, image publishing, and issue templates @@ -216,7 +205,10 @@ after pulling the latest project changes. |-- LICENSE |-- SECURITY.md |-- .env.example +|-- compose.bind.yaml # Optional homelab bind-folder storage |-- compose.build.yaml # Local source-build override +|-- start.cmd # Windows launcher +|-- start.sh # macOS and Linux launcher `-- compose.yaml # Published-image deployment ``` @@ -229,9 +221,9 @@ running and that the socket is mounted as shown in `compose.yaml`. ### Simulation starts but no report appears -Check the app logs and verify that `SIMC_WEB_ROOT` exactly matches the absolute -deployment path on the Docker host. The `input/` and `output/` directories must -also be writable. +Check the app logs for a Docker storage error. The web container must have +`/data/input`, `/data/output`, and `/var/run/docker.sock` mounted. In homelab +bind-folder mode, also confirm `SIMC_WEB_ROOT` exists and is writable. ### SimulationCraft image cannot be pulled @@ -256,19 +248,20 @@ network access, and keep Docker and the images up to date. Install the app requirements, then run the standard-library test suite: ```sh -python -m pip install -r app/requirements.txt -python -m unittest discover -s tests -v +.venv/bin/python -m pip install -r app/requirements.txt +.venv/bin/python -m unittest discover -s tests -v ``` Validate the deployment and build the web image with: ```sh -SIMC_WEB_ROOT=/tmp/simc-web docker compose config --quiet -SIMC_WEB_ROOT=/tmp/simc-web docker compose -f compose.yaml -f compose.build.yaml config --quiet +docker compose config --quiet +docker compose -f compose.yaml -f compose.build.yaml config --quiet +SIMC_WEB_ROOT=/tmp/simc-web docker compose -f compose.yaml -f compose.bind.yaml config --quiet docker build -t simc-web:test app ``` -On a Linux Docker host, run the real engine smoke test with: +Run the real engine smoke test with: ```sh sh tests/smoke-test.sh diff --git a/app/app.py b/app/app.py index fe24446..a587bac 100644 --- a/app/app.py +++ b/app/app.py @@ -40,8 +40,8 @@ def env_choice(name: str, default: str, choices: set[str]) -> str: INPUT_DIR = Path(os.environ.get("INPUT_DIR", "/data/input")) OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/data/output")) -HOST_INPUT_DIR = os.environ.get("HOST_INPUT_DIR", "/srv/simc-web/input") -HOST_OUTPUT_DIR = os.environ.get("HOST_OUTPUT_DIR", "/srv/simc-web/output") +HOST_INPUT_DIR = os.environ.get("HOST_INPUT_DIR", "").strip() +HOST_OUTPUT_DIR = os.environ.get("HOST_OUTPUT_DIR", "").strip() SIMC_IMAGE = os.environ.get("SIMC_IMAGE", "simulationcraftorg/simc:latest") SIMC_PULL_POLICY = env_choice( @@ -67,6 +67,84 @@ def env_choice(name: str, default: str, choices: set[str]) -> str: } +class DataMountError(RuntimeError): + """Raised when the SimulationCraft container cannot share app data.""" + + +def docker_mount_option(mount: dict, target: str, read_only: bool = False) -> str: + mount_type = mount.get("Type") + if mount_type == "volume": + source = mount.get("Name") + elif mount_type == "bind": + source = mount.get("Source") + else: + raise DataMountError(f"Unsupported Docker mount type: {mount_type or 'unknown'}") + + if not source: + raise DataMountError(f"Docker did not report a source for {target}.") + if "," in source: + raise DataMountError(f"Docker mount sources cannot contain commas: {source}") + + options = [f"type={mount_type}", f"source={source}", f"target={target}"] + if read_only: + options.append("readonly") + return ",".join(options) + + +def data_mount_args() -> list[str]: + """Return Docker CLI mounts that reuse this container's data storage.""" + if HOST_INPUT_DIR or HOST_OUTPUT_DIR: + if not HOST_INPUT_DIR or not HOST_OUTPUT_DIR: + raise DataMountError( + "HOST_INPUT_DIR and HOST_OUTPUT_DIR must be set together." + ) + mounts = { + "/data/input": {"Type": "bind", "Source": HOST_INPUT_DIR}, + "/data/output": {"Type": "bind", "Source": HOST_OUTPUT_DIR}, + } + else: + container_ref = os.environ.get("SIMC_WEB_CONTAINER", "").strip() + if not container_ref: + container_ref = os.environ.get("HOSTNAME", "").strip() + if not container_ref: + raise DataMountError("The web container identity is unavailable.") + + try: + result = subprocess.run( + ["docker", "inspect", container_ref], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as error: + raise DataMountError(f"Docker could not inspect the web container: {error}") + + if result.returncode != 0: + details = (result.stderr or result.stdout or "unknown Docker error").strip() + raise DataMountError(f"Docker could not inspect the web container: {details}") + + try: + details = json.loads(result.stdout)[0] + mounts = { + mount["Destination"]: mount for mount in details.get("Mounts", []) + } + except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error: + raise DataMountError(f"Docker returned invalid mount details: {error}") + + try: + input_mount = mounts["/data/input"] + output_mount = mounts["/data/output"] + except KeyError as error: + raise DataMountError(f"The web container is missing the {error.args[0]} mount.") + + return [ + "--mount", + docker_mount_option(input_mount, "/input", read_only=True), + "--mount", + docker_mount_option(output_mount, "/output"), + ] + + def ensure_data_dirs() -> None: INPUT_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) @@ -570,6 +648,16 @@ def run_sim(): try: ensure_data_dirs() + try: + mount_args = data_mount_args() + except DataMountError as error: + return render_error( + 503, + "Docker storage is unavailable", + "The runner could not share its saved data with SimulationCraft.", + str(error), + ) + input_name, output_name = build_names(report_name, uploaded_filename) input_path = INPUT_DIR / input_name input_path.write_text(final_text, encoding="utf-8") @@ -583,11 +671,8 @@ def run_sim(): "--rm", "--name", container_name, - "-v", - f"{HOST_INPUT_DIR}:/input", - "-v", - f"{HOST_OUTPUT_DIR}:/output", ] + cmd.extend(mount_args) if SIMC_CPUS: cmd.extend(["--cpus", SIMC_CPUS]) if SIMC_MEMORY: diff --git a/compose.bind.yaml b/compose.bind.yaml new file mode 100644 index 0000000..4bce3e3 --- /dev/null +++ b/compose.bind.yaml @@ -0,0 +1,9 @@ +services: + simc-web: + volumes: + - type: bind + source: "${SIMC_WEB_ROOT:?Set SIMC_WEB_ROOT to the deployment directory}/input" + target: /data/input + - type: bind + source: "${SIMC_WEB_ROOT:?Set SIMC_WEB_ROOT to the deployment directory}/output" + target: /data/output diff --git a/compose.yaml b/compose.yaml index 6c849e6..559f4a6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,3 +1,5 @@ +name: simc-local-runner + services: simc-web: image: "${SIMC_WEB_IMAGE:-ghcr.io/bromeego/simc-local-runner:latest}" @@ -10,15 +12,17 @@ services: SIMC_PULL_POLICY: "${SIMC_PULL_POLICY:-always}" SIMC_CPUS: "${SIMC_CPUS:-}" SIMC_MEMORY: "${SIMC_MEMORY:-}" - HOST_INPUT_DIR: "${SIMC_WEB_ROOT:?Set SIMC_WEB_ROOT in .env}/input" - HOST_OUTPUT_DIR: "${SIMC_WEB_ROOT:?Set SIMC_WEB_ROOT in .env}/output" SIMC_TIMEOUT_SECONDS: "${SIMC_TIMEOUT_SECONDS:-1800}" MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-2}" MAX_CONCURRENT_SIMS: "${MAX_CONCURRENT_SIMS:-1}" REPORT_RETENTION_COUNT: "${REPORT_RETENTION_COUNT:-100}" WEB_THREADS: "${WEB_THREADS:-4}" volumes: - - ./input:/data/input - - ./output:/data/output + - simc-input:/data/input + - simc-output:/data/output - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped + +volumes: + simc-input: + simc-output: diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..557b270 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,127 @@ +# Setup guide + +SimC Local Runner can run on a personal computer with Docker Desktop or on a +trusted homelab server with Docker Engine. The personal-computer setup keeps its +data in Docker-managed volumes. The homelab setup can instead keep profiles and +reports in normal host folders for direct backup access. + +## Personal computer + +### Before you start + +Install and start [Docker Desktop](https://www.docker.com/products/docker-desktop/). +On Windows, make sure Docker Desktop is using Linux containers. + +The web interface has native `linux/amd64` and `linux/arm64` images. The +official SimulationCraft image is currently `linux/amd64`, so Intel and AMD +computers provide the best performance. Apple silicon can use Docker's AMD64 +emulation, but simulations may be slower. + +### Download and start + +1. Open the [latest release](https://github.com/Bromeego/SimC-Local-Runner/releases/latest). +2. Download **Source code (zip)** and extract it to a permanent folder. +3. Start the runner: + + - Windows: double-click `start.cmd`. + - macOS: open Terminal in the extracted folder and run `sh start.sh`. + - Linux desktop: run `sh start.sh`. + +4. Open if it does not open automatically. + +The launcher checks that Docker is running, downloads the newest web image, and +starts the app. Docker creates persistent volumes for profiles and reports. A +normal stop, restart, update, or `docker compose down` keeps those volumes. + +### Stop and update + +Stop the runner without deleting saved data: + +```sh +docker compose down +``` + +Run the launcher again whenever you want to start or update the app. To update +manually, run: + +```sh +docker compose pull simc-web +docker compose up -d +``` + +Do not add `--volumes` to `docker compose down` unless you intentionally want +to delete every saved profile and report. + +## Homelab with host folders + +The default Docker-managed volumes are also suitable for a homelab. Use this +mode when you prefer profiles and reports under a known host path such as +`/srv/simc-web`. + +1. Clone the repository into the permanent deployment directory: + + ```sh + sudo mkdir -p /srv/simc-web + sudo chown "$USER":"$USER" /srv/simc-web + git clone https://github.com/Bromeego/SimC-Local-Runner.git /srv/simc-web + cd /srv/simc-web + mkdir -p input output + ``` + +2. Create `.env` from the example and set these homelab values: + + ```sh + cp .env.example .env + ``` + + ```dotenv + COMPOSE_FILE=compose.yaml:compose.bind.yaml + SIMC_WEB_ROOT=/srv/simc-web + ``` + +3. Start the runner: + + ```sh + docker compose pull simc-web + docker compose up -d + ``` + +4. Open `http://SERVER-IP:8088` from another device on the trusted network. + +The bind-folder override replaces the two Docker-managed data volumes. The app +discovers the resolved Docker mounts automatically and gives the same storage +to each temporary SimulationCraft container. + +For a different port, timezone, resource limit, or retention count, copy the +corresponding value from [`.env.example`](../.env.example) into `.env` and edit +it. Keep the deployment on a trusted network. If remote access is required, put +it behind a reverse proxy with TLS and authentication. + +## Troubleshooting + +### Docker is unavailable + +Confirm Docker Desktop or Docker Engine is running: + +```sh +docker info +docker compose version +``` + +### The page does not open + +Check the container and recent logs: + +```sh +docker compose ps +docker compose logs --tail=100 simc-web +``` + +The default address is . A homelab is reached through +the server's hostname or LAN IP instead of `localhost`. + +### A simulation cannot access its input or output + +The web container must have `/data/input`, `/data/output`, and +`/var/run/docker.sock` mounted. Recreate the deployment from the supplied +Compose files rather than starting the image directly without those mounts. diff --git a/start.cmd b/start.cmd new file mode 100644 index 0000000..87d6ab4 --- /dev/null +++ b/start.cmd @@ -0,0 +1,36 @@ +@echo off +setlocal +cd /d "%~dp0" + +where docker >nul 2>&1 +if errorlevel 1 ( + echo Docker was not found. Install Docker Desktop, then try again. + pause + exit /b 1 +) + +docker info >nul 2>&1 +if errorlevel 1 ( + echo Docker is not running. Start Docker Desktop, then try again. + pause + exit /b 1 +) + +echo Updating SimC Local Runner... +docker compose pull simc-web +if errorlevel 1 echo The update check failed. Trying the locally cached image instead. + +echo Starting SimC Local Runner... +docker compose up -d --wait --wait-timeout 90 +if errorlevel 1 goto failed + +echo. +echo SimC Local Runner is starting at http://localhost:8088 +start "" "http://localhost:8088" +exit /b 0 + +:failed +echo. +echo SimC Local Runner could not be started. The Docker output above may explain why. +pause +exit /b 1 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9e6f95a --- /dev/null +++ b/start.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$SCRIPT_DIR" + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker was not found. Install and start Docker Desktop, then try again." + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "Docker is not running. Start Docker Desktop or Docker Engine, then try again." + exit 1 +fi + +echo "Updating SimC Local Runner..." +if ! docker compose pull simc-web; then + echo "The update check failed. Trying the locally cached image instead." +fi + +echo "Starting SimC Local Runner..." +docker compose up -d --wait --wait-timeout 90 + +APP_URL="http://localhost:8088" +echo "" +echo "SimC Local Runner is starting at $APP_URL" + +case "$(uname -s)" in + Darwin) + open "$APP_URL" >/dev/null 2>&1 || true + ;; + Linux) + if [ -n "${DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1; then + xdg-open "$APP_URL" >/dev/null 2>&1 || true + fi + ;; +esac diff --git a/tests/test_app.py b/tests/test_app.py index 4a07bc5..1c58a51 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -22,6 +22,8 @@ def setUp(self): root = Path(self.temp_dir.name) simc_app.INPUT_DIR = root / "input" simc_app.OUTPUT_DIR = root / "output" + simc_app.HOST_INPUT_DIR = str(simc_app.INPUT_DIR) + simc_app.HOST_OUTPUT_DIR = str(simc_app.OUTPUT_DIR) simc_app.REPORT_RETENTION_COUNT = 100 simc_app.SIMULATION_SLOTS = BoundedSemaphore(1) simc_app.app.config.update(TESTING=True, MAX_CONTENT_LENGTH=2 * 1024 * 1024) @@ -56,6 +58,96 @@ def test_invalid_pull_policy_falls_back_to_always(self): ) self.assertEqual(policy, "always") + def test_data_mount_args_discovers_named_volumes(self): + details = [ + { + "Mounts": [ + { + "Type": "volume", + "Name": "simc-input", + "Source": "/var/lib/docker/volumes/simc-input/_data", + "Destination": "/data/input", + }, + { + "Type": "volume", + "Name": "simc-output", + "Source": "/var/lib/docker/volumes/simc-output/_data", + "Destination": "/data/output", + }, + ] + } + ] + completed = SimpleNamespace( + returncode=0, + stderr="", + stdout=json.dumps(details), + ) + + with ( + patch.object(simc_app, "HOST_INPUT_DIR", ""), + patch.object(simc_app, "HOST_OUTPUT_DIR", ""), + patch.dict(os.environ, {"HOSTNAME": "controller-id"}, clear=False), + patch.object(simc_app.subprocess, "run", return_value=completed) as run, + ): + args = simc_app.data_mount_args() + + run.assert_called_once_with( + ["docker", "inspect", "controller-id"], + capture_output=True, + text=True, + timeout=10, + ) + self.assertEqual( + args, + [ + "--mount", + "type=volume,source=simc-input,target=/input,readonly", + "--mount", + "type=volume,source=simc-output,target=/output", + ], + ) + + def test_data_mount_args_discovers_bind_mounts(self): + details = [ + { + "Mounts": [ + { + "Type": "bind", + "Source": "/srv/simc-web/input", + "Destination": "/data/input", + }, + { + "Type": "bind", + "Source": "/srv/simc-web/output", + "Destination": "/data/output", + }, + ] + } + ] + completed = SimpleNamespace( + returncode=0, + stderr="", + stdout=json.dumps(details), + ) + + with ( + patch.object(simc_app, "HOST_INPUT_DIR", ""), + patch.object(simc_app, "HOST_OUTPUT_DIR", ""), + patch.dict(os.environ, {"HOSTNAME": "controller-id"}, clear=False), + patch.object(simc_app.subprocess, "run", return_value=completed), + ): + args = simc_app.data_mount_args() + + self.assertEqual( + args, + [ + "--mount", + "type=bind,source=/srv/simc-web/input,target=/input,readonly", + "--mount", + "type=bind,source=/srv/simc-web/output,target=/output", + ], + ) + def test_favicon_is_served(self): response = self.client.get("/static/favicon.svg") try: @@ -217,6 +309,8 @@ def complete_run(command, **_kwargs): self.assertIn("--memory", command) self.assertIn("2g", command) self.assertIn("--name", command) + self.assertIn("--mount", command) + self.assertIn("target=/input,readonly", " ".join(command)) output_name = Path(command[-1].split("=", 1)[1]).name simc_app.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) (simc_app.OUTPUT_DIR / output_name).write_text(