Skip to content
Merged
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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/publish-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
101 changes: 47 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost:8088>.

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:

Expand All @@ -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
Expand All @@ -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 |
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
```

Expand All @@ -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

Expand All @@ -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
Expand Down
97 changes: 91 additions & 6 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions compose.bind.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading