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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.sh text eol=lf
scripts/rcon-cli text eol=lf
Dockerfile text eol=lf
*.py text eol=lf
62 changes: 62 additions & 0 deletions .github/workflows/docker-publish-arm64.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Build and Push ARM64 Docker Image

on:
push:
branches: ["main", "arm64", "arm64-v2", "v2-layout"]
paths:
- 'Dockerfile.arm64'
- 'scripts/**'
- '.github/workflows/docker-publish-arm64.yml'
release:
types: [published]
workflow_dispatch:

jobs:
build-and-push:
environment: production
# Native aarch64 runner - free for public repos (this one is public).
# The Multi-Stage Dockerfile compiles build2 and iw4x-launcher from source,
# so emulated QEMU build on ubuntu-latest would take 60-90 min. Native
# aarch64 cuts that to ~20-30 min and avoids QEMU edge cases entirely.
runs-on: ubuntu-24.04-arm

permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to the GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=arm64,enable={{is_default_branch}}
type=raw,value=arm64-v2,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v2-layout' || github.ref == 'refs/heads/arm64-v2' }}
type=ref,event=tag,suffix=-arm64
type=sha,suffix=-arm64

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.arm64
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=arm64
cache-to: type=gha,mode=max,scope=arm64
3 changes: 2 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build and Push Docker Image

on:
push:
branches: [ "main" ]
branches: [ "main", "v2-layout" ]
paths-ignore:
- 'README.md'
- 'LICENSE'
Expand Down Expand Up @@ -39,6 +39,7 @@ jobs:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=v2,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v2-layout' }}
type=ref,event=tag
type=ref,event=pr
type=sha
Expand Down
102 changes: 78 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,117 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Plutainer is a Docker image for running Plutonium, IW4x, and Alterware dedicated game servers (Call of Duty titles: T4/WaW, T5/BO1, T6/BO2, IW5/MW3, IW4x/MW2, T7x/BO3). It uses Wine on Debian to run the Windows game server binaries, configured entirely via environment variables.
Plutainer is a Docker image for running Plutonium, IW4x, and Alterware dedicated game servers (Call of Duty titles: T4/WaW, T5/BO1, T6/BO2, IW5/MW3, IW4x/MW2, T7x/BO3). It uses Wine on Arch Linux to run the Windows game server binaries, configured entirely via environment variables.

## Build & Test

```bash
# Build the Docker image locally
docker build -t plutainer .

# Run a Plutonium container example
docker run -e PLUTO_GAME=t6zm -e PLUTO_SERVER_KEY=<key> -e PLUTO_CONFIG_FILE=dedicated.cfg \
# Run a Plutonium container example (v2 env vars)
docker run -e PLUTAINER_GAME=t6zm -e PLUTO_SERVER_KEY=<key> -e PLUTAINER_CONFIG_FILE=dedicated.cfg \
-v /path/to/game_files:/home/plutainer/gamefiles:ro \
-v ./server-data:/home/plutainer/app \
-p 4976:4976/udp plutainer

# Run an IW4x container example
docker run -e IW4X_GAME=iw4x -e IW4X_CONFIG_FILE=server.cfg \
docker run -e PLUTAINER_GAME=iw4x -e PLUTAINER_CONFIG_FILE=server.cfg \
-v /path/to/game_files:/home/plutainer/gamefiles:ro \
-v ./server-data:/home/plutainer/app \
-p 28960:28960/udp plutainer
```

There are no automated tests or linters. The CI pipeline (`.github/workflows/docker-publish.yml`) builds and pushes to `ghcr.io` on pushes to `main` and on releases.

## Tags

- `ghcr.io/ayymoss/plutainer:v2` — built from `v2-layout` branch. New volume layout + unified `PLUTAINER_*` env vars. Opt-in. CI workflow tags it only on pushes to `v2-layout`; never promotes to `:latest`.
- `ghcr.io/ayymoss/plutainer:latest` — built from `main`. Deprecated v1 layout. No further v2 work merges here; bug-only updates if any.

CI logic in `.github/workflows/docker-publish.yml`:
- `type=raw,value=latest,enable={{is_default_branch}}` — only on main.
- `type=raw,value=v2,enable=${{ github.ref == 'refs/heads/v2-layout' }}` — only on v2-layout.
- Both branches also get `:sha-<short>`. Branches stay completely separated.

## Architecture

Everything runs as the `plutainer` user from `/home/plutainer/.plutainer`.
Everything runs as the `plutainer` user from `/home/plutainer/.plutainer`. All entry scripts run with `set -euo pipefail`.

1. **`entrypoint.sh`** — Top-level dispatcher. Sources `game-config.sh`, calls `detect_game_type` (requires `PLUTAINER_GAME`), `check_volume_version` (refuses v1 volumes), then `exec`s the family-specific entry script. On any failure: `hold_indefinitely` (sleep infinity) instead of exiting, to avoid restart loops.

2. **`plutoentry.sh`** — Plutonium server entrypoint. Symlinks game files from the read-only gamefiles mount, runs `plutonium-updater`, seeds bundled configs into the SOT location, fans out symlinks from `app/configs/` to the engine and (if `PLUTAINER_MOD` is set) the mod config dir, calls `ensure_config_present` (auto-lift + refusal), then `launch_game wine ...` (30s exit-throttle wrapper).

3. **`iw4xentry.sh`** — IW4x server entrypoint. Same shape: symlinks game files, runs `iw4x-launcher`, fans out config symlinks (engine + optional mod dir), validates, `launch_game wine iw4x.exe`. No seed bundle.

4. **`alterentry.sh`** — Alterware (T7x/BO3) entrypoint. Symlinks game files, uses `wget -N` (timestamping) to fetch `t7x.exe` only when upstream is newer, seeds Dss0/t7-server-config bundle, fans out config symlinks, starts `Xvfb` (T7x requires a display), `launch_game wine t7x.exe`. No mod dir (alterware MOD is a Steam Workshop ID).

1. **`entrypoint.sh`** — Dispatcher that detects game type via `PLUTO_GAME`, `IW4X_GAME`, or `ALTER_GAME` env var and delegates to the appropriate entrypoint script.
5. **`game-config.sh`** — Shared shell library sourced by all other scripts. Key helpers:
- Volume path constants: `PLUTAINER_APP_DIR`, `PLUTAINER_CONFIGS_DIR`, `PLUTAINER_RUNTIME_DIR`, `PLUTAINER_GAMEFILES_DIR`, `PLUTAINER_PLUTONIUM_DIR`, `PLUTAINER_SOURCE_DIR`.
- `hold_indefinitely <msg>`: print the error, then `exec sleep infinity` so the container stays `Up` instead of looping through restarts. Used for any startup validation failure.
- `launch_game <cmd>...`: wraps the game invocation; on exit, sleeps 30s before letting the script exit, so docker's restart policy throttles to ~1 restart per 30s.
- `derive_family <game-tag>`: returns `plutonium`/`iw4x`/`alterware`.
- `detect_game_type`: validates `PLUTAINER_GAME` (no shim — only PLUTAINER_* accepted), sets `GAME_TYPE`/`GAME_NAME`/`BASE_GAME`/`CONFIG_FILE`/`CUSTOM_PORT`/`HEALTHCHECK_FLAG`.
- `resolve_default_port`, `resolve_engine_config_dir`, `resolve_mod_config_dir`.
- `resolve_config_layout`: sets `CONFIG_SOT_DIR` and `ALT_CONFIG_DIR` based on `PLUTAINER_USE_RAW_CONFIGS`. Default: SOT = `configs/`, ALT = engine dir. With raw mode on: swapped.
- `resolve_config_path`: convenience wrapper that resolves the engine dir + layout in one call so healthcheck/rcon-cli only need this.
- `link_files <src> <dest> <name1>...`: existence-guarded symlink helper; replaces unsafe `ln -sf src/{a,b,c} dest/` bash brace expansion.
- `seed_configs <game-key> <asset-root> <cfg-root-rel>`: walks bundled seed, lifts top-level `*.cfg` files inside `cfg-root-rel` into `CONFIG_SOT_DIR`, places everything else under `asset-root`. Idempotent.
- `link_configs <engine-dir1> [engine-dir2 ...]`: variadic. Fans out symlinks from every `configs/*.cfg` into each engine dir using relative paths. Refuses to overwrite a real (non-symlink) file at engine path (warns instead). Reaps dangling cfg symlinks. No-op when `PLUTAINER_USE_RAW_CONFIGS=true`.
- `ensure_config_present`: checks that `CONFIG_FILE` exists at `CONFIG_SOT_DIR`. If absent there but present as a real file at the ALT location, moves it (auto-lift). If absent everywhere, prints a refusal with a `find -iname` case-insensitive hint, returns non-zero.
- `check_volume_version`: refuses v1 volumes with explicit migration instructions; initialises fresh volumes; writes `.plutainer-version=2`.
- `extract_rcon_password`: parses `rcon_password` from `CONFIG_PATH`. Handles double-quoted, single-quoted, and unquoted values. Strips `//` comments. On failure, prints a structured `[WARN]` (don't block startup) telling the user the accepted forms and not to set the password via `PLUTAINER_EXTRA_ARGS`.

2. **`plutoentry.sh`** — Plutonium server entrypoint. Symlinks game-specific files from the read-only gamefiles mount, runs the plutonium-updater, validates env vars, resolves game-specific defaults, then `exec`s `wine bin/plutonium-bootstrapper-win32.exe`.
6. **`migrate-v1-to-v2.sh`** — One-shot migration tool, run via `docker run --entrypoint`. Moves `app/gamefiles` → `app/runtime/gamefiles`, `app/plutonium` → `app/runtime/plutonium`, lifts top-level cfg files from known engine config dirs into `app/configs/` and replaces them with relative symlinks, clears stale `app/logs/` entries, writes `.plutainer-version=2`. Supports `--dry-run`.

3. **`iw4xentry.sh`** — IW4x server entrypoint. Similar flow: symlinks game files, runs iw4x-launcher for updates, validates env vars, then `exec`s `wine iw4x.exe`.
7. **`log-watcher.sh`** — Background poller started by each entrypoint before `exec wine`. Discovers every `*.log` under `/home/plutainer/app/` (excluding `app/logs/` itself to avoid cycles) and maintains relative symlinks at `/home/plutainer/app/logs/<basename>` pointing at the active one. Active = newest mtime >= container boot time. Agnostic to log name. Symlinks are relative so they resolve the same on host, in this container, or in a sidecar IW4MAdmin container. Disable with `PLUTAINER_LOG_SYMLINKS=false`; poll interval via `PLUTAINER_LOG_POLL_INTERVAL` (default 2s).

4. **`alterentry.sh`** — Alterware server entrypoint (T7x/Black Ops 3). Symlinks game files, creates writable `zone/` directory with symlinked contents, downloads `t7x.exe` from upstream, validates env vars, then `exec`s `wine t7x.exe`.
8. **`healthcheck.sh`** — Sources `game-config.sh`, then uses `pyquake3.py` to send an RCON `status` command. Enabled by default; disable with `PLUTAINER_HEALTHCHECK=false`. HEALTHCHECK directive uses `--start-period=5m` to accommodate first-run downloads.

5. **`game-config.sh`** — Shared shell library sourced by all other scripts. Single source of truth for game detection, port defaults, config path resolution, and RCON password extraction.
9. **`rcon-cli`** — Python script providing interactive and one-shot RCON access via `docker exec`. Calls `game-config.sh` to resolve port/credentials. Supports Plutonium, IW4x, and Alterware.

6. **`log-watcher.sh`** — Background poller started by each entrypoint before `exec wine`. Discovers every `*.log` under `/home/plutainer/app/` (excluding `app/logs/` itself to avoid cycles) and maintains relative symlinks at `/home/plutainer/app/logs/<basename>` pointing at the active one. Active = newest mtime >= container boot time, so stale logs from prior sessions and abandoned mod dirs are ignored. Agnostic to log name (handles user-defined names like `games_koth.log`). Symlinks are relative so they resolve the same on host, this container, or a sidecar IW4MAdmin container. Disable with `PLUTAINER_LOG_SYMLINKS=false`; poll interval via `PLUTAINER_LOG_POLL_INTERVAL` (default 2s).
10. **`pyquake3.py`** — Python 3 Quake 3 protocol library (UDP). Used by the health check and `rcon-cli` for RCON queries.

7. **`healthcheck.sh`** — Sources `game-config.sh`, then uses `pyquake3.py` to send an RCON `status` command. Enabled by default; disable with `PLUTO_HEALTHCHECK=false`, `IW4X_HEALTHCHECK=false`, or `ALTER_HEALTHCHECK=false`.
## Volume Layout (v2)

8. **`rcon-cli`** — Python script providing interactive and one-shot RCON access via `docker exec`. Calls `game-config.sh` to resolve port/credentials. Supports Plutonium, IW4x, and Alterware.
```
/home/plutainer/gamefiles # read-only host gamefiles bind
/home/plutainer/app/
configs/ # User-facing real *.cfg files (flat).
# Edit here. Engine paths symlink in.
logs/ # Stable symlinks to active *.log files
# (maintained by log-watcher.sh).
# Sidecars (IW4MAdmin) mount this dir.
runtime/
gamefiles/ # Symlinks into host gamefiles plus
# writable game state (mods, .iwd, etc).
plutonium/ # Plutonium binaries + storage state.
.plutainer-version # "2" — layout marker.
/home/plutainer/.plutainer/ # Scripts, updaters, pyquake3, seed-configs.
```

9. **`pyquake3.py`** — Python 3 Quake 3 protocol library (UDP). Used by the health check and `rcon-cli` for RCON queries.
**Config flow:** user edits `app/configs/<file>.cfg` → entrypoint places a relative symlink at the engine's expected path → game reads via symlink. RCON `writeconfig` writes through the symlink, modifying the real file in `configs/`.

## Game-Specific Behavior

For Plutonium, `BASE_GAME` is derived by stripping the last two chars from `PLUTO_GAME` (e.g., `t6zm` → `t6`). This drives:
For Plutonium, `BASE_GAME` is derived by stripping the last two chars from `PLUTAINER_GAME` (e.g., `t6zm` → `t6`). This drives:

- **Default ports**: iw4x→28960, iw5→27016, t4/t5→28960, t6→4976, t7x→27017.
- **Engine config dirs** (where the game reads `+exec`'d cfg files): t4 → `runtime/gamefiles/main/`, iw5 → `runtime/gamefiles/admin/`, iw4x → `runtime/gamefiles/userraw/`, t7x → `runtime/gamefiles/zone/`, others → `runtime/plutonium/storage/<base_game>/`.
- **Command args**: iw5 uses `+set sv_config` and `+start_map_rotate`; others use `+exec` and `+map_rotate`.
- **Game-file symlinks** differ per base game (see `plutoentry.sh` case statement).

## Compatibility surface

The `:v2` image is a **clean break** from `:latest`. The two share no env vars (other than the always-unique `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, `IW4X_NET_LOG_IP`) and no volume layout. There is no env-var shim in `:v2` — legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` names are silently ignored.

A v1 `app/` volume is refused on startup (`check_volume_version`) with explicit instructions to run `migrate-v1-to-v2.sh`.

## Restart behavior

- **Default ports**: iw4x→28960, iw5→27016, t4/t5→28960, t6→4976, t7x→27017
- **Config file paths**: t4→`app/gamefiles/main/`, iw5→`app/gamefiles/admin/`, iw4x→`app/gamefiles/userraw/`, t7x→`app/gamefiles/zone/`, others→`app/plutonium/storage/{base_game}/`
- **Command args**: iw5 uses `+set sv_config` and `+start_map_rotate`; others use `+exec` and `+map_rotate`
- **Game file symlinks** differ per base game (see `plutoentry.sh` case statement)
Two distinct failure modes:

## Container Layout
- **Configuration errors** (validation failures, missing env vars, missing config file, v1 volume, unknown game): `hold_indefinitely` → `exec sleep infinity`. Container stays `Up`; healthcheck eventually marks it unhealthy. No restart loop. User fixes and runs `docker restart <name>`.
- **Runtime crashes** (wine exits): `launch_game` wrapper catches the exit, sleeps 30s, then exits with the original return code. Docker's restart policy fires after that, giving ~1 restart per 30s instead of immediate churn.

- `/home/plutainer/gamefiles` — bind-mounted read-only game files from host
- `/home/plutainer/app` — persistent volume (gamefiles symlinks, plutonium data, configs, logs)
- `/home/plutainer/app/logs` — stable symlinks to active game logs, maintained by `log-watcher.sh`. Host-side IW4MAdmin should bind-mount this dir and read logs from there instead of reaching into nested mod/storage paths.
- `/home/plutainer/.plutainer` — working directory containing scripts, updaters, and pyquake3
`STOPSIGNAL` is `SIGKILL`, so neither path interferes with `docker stop` — that's instant by design.
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,16 @@ RUN set -eux; \
find /home/plutainer/.plutainer/seed-configs -type f \( -iname '*.bat' -o -iname '*.sh' -o -iname 'README*' \) -delete

COPY --chown=plutainer:plutainer scripts/ .
RUN chmod +x entrypoint.sh healthcheck.sh plutoentry.sh iw4xentry.sh alterentry.sh log-watcher.sh rcon-cli game-config.sh
RUN chmod +x entrypoint.sh healthcheck.sh plutoentry.sh iw4xentry.sh alterentry.sh \
log-watcher.sh rcon-cli game-config.sh migrate-v1-to-v2.sh

USER root
RUN ln -s /home/plutainer/.plutainer/rcon-cli /usr/local/bin/rcon-cli
USER plutainer

STOPSIGNAL SIGKILL

HEALTHCHECK --interval=1m --timeout=10s --start-period=1m --retries=3 \
HEALTHCHECK --interval=1m --timeout=10s --start-period=5m --retries=3 \
CMD ./healthcheck.sh

ENTRYPOINT ["./entrypoint.sh"]
Loading